sar3d: GPU port of the notebook with four views
The sar3d processor is rewritten as a checked port of Multi_antenna_3D_clean_v1.ipynb: NVRTC kernels, timing from the trace stamps, notebook parameter names and one cubic SAR_VOXEL_M. It adds front, side, top and 3D views, drops gpr_cpp and the parity tools, and the GUI speed now applies only while the socket speed is ignored.
This commit is contained in:
@@ -3,8 +3,6 @@ __pycache__/
|
||||
*.py[codz]
|
||||
*$py.class
|
||||
build*
|
||||
# build* иначе прячет скрипт сборки вендоренного gpr_cpp (его build/ остаётся игнорируемым)
|
||||
!data_acq_and_processing/gpr_cpp/build_cmake.sh
|
||||
.codex
|
||||
.DS_Store
|
||||
# C extensions
|
||||
|
||||
@@ -8,43 +8,28 @@ VISA_LDFLAGS := $(LDFLAGS) -lvisa
|
||||
BUILD_DIR := build
|
||||
BIN_DIR := $(BUILD_DIR)/bin
|
||||
|
||||
# --- gpr_cpp: vendored C++/CUDA 3D SAR library (originally from VNA_AlGo).
|
||||
# Built automatically by the $(GPR_CPP_LIB) rule below via its own CMake
|
||||
# (build_cmake.sh). CUDA headers and libnvrtc come from the pip nvidia-*
|
||||
# packages inside this repo's venv (same detection as build_cmake.sh); when
|
||||
# those are absent (e.g. Jetson, where CUDA comes with JetPack) the system
|
||||
# toolkit at /usr/local/cuda is used. libcuda is always the system driver.
|
||||
GPR_CPP_DIR ?= data_acq_and_processing/gpr_cpp
|
||||
GPR_CPP_LIB ?= $(GPR_CPP_DIR)/build/libgpr_core_cpp.a
|
||||
# Absolute so the derived -rpath (libnvrtc dir) survives running the binary
|
||||
# from outside the repo root.
|
||||
GPR_VENV ?= $(CURDIR)/.venv
|
||||
GPR_CUDA_INC_DIR ?= $(dir $(firstword $(wildcard $(GPR_VENV)/lib/python*/site-packages/nvidia/*/include/nvrtc.h)))
|
||||
# cuda.h can live in a DIFFERENT pip package than nvrtc.h (Jetson: cuda_runtime
|
||||
# vs cuda_nvrtc); the unified cu13 package on dev machines has both in one dir.
|
||||
GPR_CUDA_RT_INC_DIR ?= $(dir $(firstword $(wildcard $(GPR_VENV)/lib/python*/site-packages/nvidia/*/include/cuda.h)))
|
||||
GPR_NVRTC_LIB ?= $(firstword $(wildcard $(GPR_VENV)/lib/python*/site-packages/nvidia/*/lib/libnvrtc.so*))
|
||||
# System-CUDA fallback. An empty GPR_CUDA_INC_DIR would also corrupt the compile
|
||||
# command: a bare `-I` swallows the next argument.
|
||||
ifeq ($(strip $(GPR_CUDA_INC_DIR)),)
|
||||
GPR_CUDA_INC_DIR := /usr/local/cuda/include
|
||||
# --- CUDA for the sar3d processor (optional). Its kernels are compiled at run
|
||||
# time by NVRTC and launched through the CUDA driver API, so no nvcc is needed:
|
||||
# only nvrtc.h, cuda.h, libnvrtc and the NVIDIA driver's libcuda. Headers and
|
||||
# libnvrtc come from the pip nvidia-cuda-* packages in .venv (requirements.txt,
|
||||
# x86_64) or from a system toolkit in /usr/local/cuda (Jetson). Without them
|
||||
# data_processor still builds; sar3d then reports that CUDA is missing.
|
||||
CUDA_ROOTS := $(wildcard $(CURDIR)/.venv/lib/python3*/site-packages/nvidia/*) /usr/local/cuda
|
||||
CUDA_NVRTC_HEADER := $(firstword $(wildcard $(addsuffix /include/nvrtc.h,$(CUDA_ROOTS))))
|
||||
CUDA_DRIVER_HEADER := $(firstword $(wildcard $(addsuffix /include/cuda.h,$(CUDA_ROOTS))))
|
||||
CUDA_NVRTC_LIB := $(firstword $(wildcard $(addsuffix /lib/libnvrtc.so.*,$(CUDA_ROOTS)) $(addsuffix /lib64/libnvrtc.so.*,$(CUDA_ROOTS))))
|
||||
CUDA_DRIVER_LIB := $(firstword $(wildcard /usr/lib/*-linux-gnu/libcuda.so.1 /usr/lib/*-linux-gnu/nvidia/libcuda.so.1 /usr/lib64/libcuda.so.1))
|
||||
ifneq ($(and $(CUDA_NVRTC_HEADER),$(CUDA_DRIVER_HEADER),$(CUDA_NVRTC_LIB),$(CUDA_DRIVER_LIB)),)
|
||||
SAR3D_CXXFLAGS := -DSAR3D_WITH_CUDA $(addprefix -I,$(sort $(dir $(CUDA_NVRTC_HEADER) $(CUDA_DRIVER_HEADER))))
|
||||
# The rpath lets data_processor find the pip libnvrtc without LD_LIBRARY_PATH.
|
||||
SAR3D_LDFLAGS := $(CUDA_NVRTC_LIB) $(CUDA_DRIVER_LIB) -Wl,-rpath,$(dir $(CUDA_NVRTC_LIB))
|
||||
endif
|
||||
ifeq ($(strip $(GPR_CUDA_RT_INC_DIR)),)
|
||||
GPR_CUDA_RT_INC_DIR := /usr/local/cuda/include
|
||||
endif
|
||||
ifeq ($(strip $(GPR_NVRTC_LIB)),)
|
||||
GPR_NVRTC_LIB := $(firstword $(wildcard /usr/local/cuda/lib64/libnvrtc.so*))
|
||||
endif
|
||||
# Deduplicated CUDA include flags (a single dir on dev machines).
|
||||
GPR_CUDA_INC_FLAGS := $(addprefix -I,$(sort $(GPR_CUDA_INC_DIR) $(GPR_CUDA_RT_INC_DIR)))
|
||||
GPR_CUDA_LIB_DIR := $(dir $(GPR_NVRTC_LIB))
|
||||
# libnvrtc from pip ships only as libnvrtc.so.N (no .so symlink), so link the
|
||||
# full path; the rpath makes the binary find it without LD_LIBRARY_PATH.
|
||||
DATA_PROCESSOR_LDFLAGS := $(LDFLAGS) $(GPR_CPP_LIB) $(GPR_NVRTC_LIB) -lcuda -Wl,-rpath,$(GPR_CUDA_LIB_DIR)
|
||||
# Rewritten only when the CUDA setup changes, so installing (or removing) CUDA
|
||||
# rebuilds the sar3d GPU code instead of keeping a stale build.
|
||||
SAR3D_CUDA_STAMP := $(BUILD_DIR)/sar3d_cuda.flags
|
||||
$(shell mkdir -p $(BUILD_DIR) && echo '$(SAR3D_CXXFLAGS) $(SAR3D_LDFLAGS)' | cmp -s - $(SAR3D_CUDA_STAMP) || echo '$(SAR3D_CXXFLAGS) $(SAR3D_LDFLAGS)' > $(SAR3D_CUDA_STAMP))
|
||||
|
||||
INCLUDES := \
|
||||
-I$(GPR_CPP_DIR)/include \
|
||||
$(GPR_CUDA_INC_FLAGS) \
|
||||
-Idata_acq_and_processing/common_cpp/ipc/include \
|
||||
-Idata_acq_and_processing/common_cpp/config/include \
|
||||
-Idata_acq_and_processing/third_party \
|
||||
@@ -92,6 +77,7 @@ PROCESSOR_SOURCES := \
|
||||
data_acq_and_processing/processing/processors/src/gpr_processor.cpp \
|
||||
data_acq_and_processing/processing/processors/src/passthrough_processor.cpp \
|
||||
data_acq_and_processing/processing/processors/src/sar3d_processor.cpp \
|
||||
data_acq_and_processing/processing/processors/src/sar3d_gpu.cpp \
|
||||
data_acq_and_processing/processing/data_processor/src/processing_live_config.cpp \
|
||||
data_acq_and_processing/processing/data_processor/src/data_processor.cpp \
|
||||
data_acq_and_processing/processing/data_processor/src/main.cpp \
|
||||
@@ -139,27 +125,12 @@ TARGETS := \
|
||||
$(BIN_DIR)/data_preprocessor \
|
||||
$(BIN_DIR)/data_processor
|
||||
|
||||
.PHONY: all clean kamil_adc_collector sar3d_parity
|
||||
.PHONY: all clean kamil_adc_collector
|
||||
|
||||
all: $(TARGETS)
|
||||
|
||||
kamil_adc_collector: $(BIN_DIR)/kamil_adc_collector
|
||||
|
||||
# On-demand parity check of the sar3d processor against the reference notebook
|
||||
# volumes (scripts/sar3d_volume_compare.py diffs the dumped volume against a
|
||||
# *_volume.npz export). Not part of `all`.
|
||||
SAR3D_PARITY_OBJS := $(addprefix $(BUILD_DIR)/, \
|
||||
data_acq_and_processing/processing/tools/sar3d_parity_main.o \
|
||||
data_acq_and_processing/processing/processors/src/sar3d_processor.o \
|
||||
data_acq_and_processing/common_cpp/config/src/run_config.o \
|
||||
data_acq_and_processing/common_cpp/ipc/src/shared_types.o)
|
||||
|
||||
sar3d_parity: $(BIN_DIR)/sar3d_parity
|
||||
|
||||
$(BIN_DIR)/sar3d_parity: $(SAR3D_PARITY_OBJS) $(GPR_CPP_LIB)
|
||||
@mkdir -p $(BIN_DIR)
|
||||
$(CXX) $(SAR3D_PARITY_OBJS) -o $@ $(DATA_PROCESSOR_LDFLAGS)
|
||||
|
||||
$(BIN_DIR)/sweep_orchestrator: $(SWEEP_ORCH_OBJS)
|
||||
@mkdir -p $(BIN_DIR)
|
||||
$(CXX) $(SWEEP_ORCH_OBJS) -o $@ $(ORCH_LDFLAGS)
|
||||
@@ -168,17 +139,13 @@ $(BIN_DIR)/data_preprocessor: $(PREPROCESSOR_OBJS)
|
||||
@mkdir -p $(BIN_DIR)
|
||||
$(CXX) $(PREPROCESSOR_OBJS) -o $@ $(LDFLAGS)
|
||||
|
||||
$(BIN_DIR)/data_processor: $(DATA_PROCESSOR_OBJS) $(GPR_CPP_LIB)
|
||||
$(BIN_DIR)/data_processor: $(DATA_PROCESSOR_OBJS) $(SAR3D_CUDA_STAMP)
|
||||
@mkdir -p $(BIN_DIR)
|
||||
$(CXX) $(DATA_PROCESSOR_OBJS) -o $@ $(DATA_PROCESSOR_LDFLAGS)
|
||||
$(CXX) $(DATA_PROCESSOR_OBJS) -o $@ $(LDFLAGS) $(SAR3D_LDFLAGS)
|
||||
$(if $(SAR3D_LDFLAGS),,@echo "note: CUDA not found, data_processor built without the sar3d GPU backend")
|
||||
|
||||
# Vendored gpr_cpp static lib: delegate to its CMake build (also produces the
|
||||
# standalone_bp example used by sar3d_parity cross-checks).
|
||||
GPR_CPP_SRC_FILES := $(wildcard $(GPR_CPP_DIR)/src/*) $(wildcard $(GPR_CPP_DIR)/include/gpr/*) \
|
||||
$(wildcard $(GPR_CPP_DIR)/examples/*) $(GPR_CPP_DIR)/CMakeLists.txt $(GPR_CPP_DIR)/build_cmake.sh
|
||||
|
||||
$(GPR_CPP_LIB): $(GPR_CPP_SRC_FILES)
|
||||
GPR_BUILD_PYTHON=OFF VENV=$(abspath $(GPR_VENV)) bash $(GPR_CPP_DIR)/build_cmake.sh
|
||||
$(BUILD_DIR)/data_acq_and_processing/processing/processors/src/sar3d_gpu.o: CXXFLAGS += $(SAR3D_CXXFLAGS)
|
||||
$(BUILD_DIR)/data_acq_and_processing/processing/processors/src/sar3d_gpu.o: $(SAR3D_CUDA_STAMP)
|
||||
|
||||
# The collector compiles into its own build/kamil/ tree with collector-only
|
||||
# includes (no VISA), so the shared driver/config sources it reuses never collide
|
||||
@@ -201,4 +168,4 @@ $(BUILD_DIR)/%.o: %.cpp
|
||||
-include $(DEPFILES)
|
||||
|
||||
clean:
|
||||
rm -rf $(BUILD_DIR) $(GPR_CPP_DIR)/build
|
||||
rm -rf $(BUILD_DIR)
|
||||
|
||||
@@ -23,9 +23,9 @@ simply skips to the freshest frame:
|
||||
2. **Preprocessing** — `data_preprocessor` applies through/OSL calibration,
|
||||
reference subtraction, and optional frequency-domain notch filtering.
|
||||
3. **Processing** — `data_processor` runs the selected processing mode and emits
|
||||
results (pass-through traces, B-scan images, or GPR images with detected
|
||||
objects). Detected objects are additionally broadcast to subscribers over a TCP
|
||||
locator server.
|
||||
results (pass-through traces, B-scan images, GPR images with detected objects,
|
||||
or 3D SAR views). Detected objects are additionally broadcast to subscribers
|
||||
over a TCP locator server.
|
||||
|
||||
The **Python application** (`python_app`) starts and monitors the C++ binaries,
|
||||
reads the result rings, renders the live view with pyqtgraph, and exposes the same
|
||||
@@ -55,12 +55,19 @@ controls plus a pixel-identical live plot through an embedded web UI.
|
||||
- **bscan** — depth-versus-sweep B-scan heatmap.
|
||||
- **gpr** — coherent back-projection GPR imaging with object detection.
|
||||
- **legacy_gpr** — ellipse-intersection MIMO localization (point and extended/region modes).
|
||||
- **sar3d** — 3D SAR of the reference notebook `Multi_antenna_3D_clean_v1.ipynb`
|
||||
on an NVIDIA GPU: front (X-Y), side (Z-Y), top (X-Z) and 3D views of the imaged
|
||||
volume. Its settings carry the notebook's parameter names; the scene window is
|
||||
fixed (X −2…2 m, height −0.5…2 m above the ground), its range extent follows
|
||||
`MIN_DEPTH`/`MAX_DEPTH`, and the voxels are cubes with edge `SAR_VOXEL_M`
|
||||
(default 2 cm). The radar motion is corrected from each trace's capture
|
||||
timestamps, so the sweep timing needs no parameters.
|
||||
|
||||
## Repository layout
|
||||
|
||||
| Path | Contents |
|
||||
| --- | --- |
|
||||
| `data_acq_and_processing/` | C++ pipeline: `sweep_orchestrator/`, `preprocessing/`, `processing/` (including `locator/`), shared `common_cpp/` (config, IPC rings), the vendored `gpr_cpp/` C++/CUDA 3D SAR library, and vendored `third_party/` (Eigen, nlohmann/json). |
|
||||
| `data_acq_and_processing/` | C++ pipeline: `sweep_orchestrator/`, `preprocessing/`, `processing/` (including `locator/`), shared `common_cpp/` (config, IPC rings), and vendored `third_party/` (Eigen, nlohmann/json). |
|
||||
| `python_app/` | Python application: `gui/`, `orchestration/` (process supervisor, shared-memory readers, config), `hardware_full/` (device drivers and services), `webui/`, `storage/`, `models/`, `workflows/`, `scripts/` (producers and tools), and `tests/`. |
|
||||
| `run_config_examples/` | Ready-made configuration profiles, one per hardware setup. |
|
||||
| `deploy/` | systemd daemon installer for Raspberry Pi appliances. |
|
||||
@@ -74,13 +81,13 @@ controls plus a pixel-identical live plot through an embedded web UI.
|
||||
specific (GPIO12/13/18/19, `dtoverlay=pwm-2chan`).
|
||||
- A C++20 compiler and `make` — Eigen and nlohmann/json are vendored, so no extra C++ dependencies are needed.
|
||||
- **Python 3.10 or newer**, in a virtual environment.
|
||||
- For the `sar3d` processor (3D SAR): an NVIDIA GPU with the driver installed
|
||||
(`libcuda`) and `cmake` (the vendored `gpr_cpp` library at
|
||||
`data_acq_and_processing/gpr_cpp/` builds via its own CMake, invoked
|
||||
automatically by `make`). CUDA headers/libnvrtc are taken from the pip
|
||||
`nvidia-*` packages in this repo's venv (installed by `requirements.txt` on
|
||||
x86_64), otherwise from the system toolkit at `/usr/local/cuda`
|
||||
(Jetson/JetPack, apt `cuda-toolkit`).
|
||||
- For the `sar3d` processor: an NVIDIA GPU with its driver (`libcuda`). The
|
||||
kernels are compiled at run time by NVRTC, so there is no nvcc step; `make`
|
||||
needs only `nvrtc.h`, `cuda.h` and `libnvrtc`, taken from the pip
|
||||
`nvidia-cuda-*` packages in `.venv` (`requirements.txt`, x86_64; CUDA 13 covers
|
||||
Turing and newer GPUs) or from a system toolkit in `/usr/local/cuda` (Jetson).
|
||||
Without them everything else still builds, and sar3d reports that CUDA is
|
||||
missing — a Raspberry Pi needs none of it.
|
||||
|
||||
## Build and run
|
||||
|
||||
@@ -93,20 +100,9 @@ python3 -m venv .venv
|
||||
make # build the C++ binaries into build/bin/
|
||||
```
|
||||
|
||||
`make` also builds the vendored 3D SAR library
|
||||
(`data_acq_and_processing/gpr_cpp/`, CMake + NVRTC — needs `cmake`; CUDA
|
||||
headers come from the pip `nvidia-*` packages in `.venv`, or `/usr/local/cuda`
|
||||
as a fallback).
|
||||
|
||||
To verify the sar3d processor against the gpr_cpp reference on the recorded
|
||||
dataset, build and run the on-demand parity tool:
|
||||
|
||||
```bash
|
||||
make sar3d_parity
|
||||
./build/bin/sar3d_parity <preprocessed_dataset_dir> <center_snapshot_name>
|
||||
# compare the printed peak/argmax with
|
||||
# ./data_acq_and_processing/gpr_cpp/build/standalone_bp on the same arguments
|
||||
```
|
||||
`make` builds the sar3d GPU code when it finds CUDA (see Requirements) and
|
||||
prints a note when it does not. After installing the CUDA requirements into an
|
||||
existing venv, run `make` again: it notices the change and rebuilds that part.
|
||||
|
||||
Run the desktop GUI:
|
||||
|
||||
@@ -134,17 +130,12 @@ for the CUDA-backed `sar3d` processor:
|
||||
# radar.replay.dataset_dir in the profile at the local copy — the path in
|
||||
# run_config_examples/*.json is machine-specific.
|
||||
|
||||
# 2. normal launch; the vendored gpr_cpp builds against the JetPack system CUDA
|
||||
# (requirements.txt installs no pip nvidia-* packages on aarch64, and
|
||||
# build_cmake.sh falls back to /usr/local/cuda automatically; override with
|
||||
# CUDA_INC=... CUDA_LIB=... if the toolkit lives elsewhere):
|
||||
# 2. normal launch; make takes the JetPack CUDA from /usr/local/cuda
|
||||
# (requirements.txt installs no pip nvidia-* packages on aarch64) and the
|
||||
# driver's /usr/lib/aarch64-linux-gnu/nvidia/libcuda.so.1:
|
||||
./start.sh --profile run_config_examples/run_config_replay_vna.example.json
|
||||
```
|
||||
|
||||
If linking fails with `cannot find -lcuda`, the driver stub lives at
|
||||
`/usr/local/cuda/lib64/stubs/libcuda.so`; on JetPack the real driver is
|
||||
`/usr/lib/aarch64-linux-gnu/nvidia/libcuda.so.1` (present after boot).
|
||||
|
||||
## Running without hardware (emulation)
|
||||
|
||||
Two ways to run the full pipeline with no radar attached:
|
||||
@@ -175,14 +166,13 @@ A ready profile for the VNA_AlGo tank005 dataset is provided:
|
||||
--config run_config_examples/run_config_replay_vna.example.json
|
||||
# interactive GUI (mode sar3d is pre-selected by the profile's gui section):
|
||||
./start.sh --profile run_config_examples/run_config_replay_vna.example.json
|
||||
# or a fully headless end-to-end check:
|
||||
.venv/bin/python -m python_app.scripts.replay_smoke_run --duration 30
|
||||
```
|
||||
|
||||
Note: the sar3d processor allocates GPU memory; if the desktop (e.g. a browser)
|
||||
has filled the GPU, sar3d logs `out of memory` and publishes empty results until
|
||||
memory frees. Reduce `sar3d_grid_nx/ny/nz` in the processing panel to shrink
|
||||
the footprint.
|
||||
The sar3d processor keeps about 75 MB of buffers on the GPU at the default
|
||||
2 cm voxel and depth 2…8 m (plus the CUDA context). The volume grows as
|
||||
1/`SAR_VOXEL_M`³: 1 cm takes about 600 MB and ran about 6 times slower in
|
||||
testing. If the GPU has no room, it logs the CUDA error once and publishes empty
|
||||
results until memory frees.
|
||||
|
||||
The web interface is served in-process at `http://<host>:8080`. It mirrors the live
|
||||
plot and offers Start / Stop / Capture controls and the active processor settings.
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
# 3.22 = apt cmake на Ubuntu 22.04 (Jetson/JetPack); фич новее здесь нет.
|
||||
cmake_minimum_required(VERSION 3.22)
|
||||
project(gpr_cpp LANGUAGES CXX) # CXX-only: CUDA компилируется в рантайме через NVRTC
|
||||
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
if(NOT CMAKE_BUILD_TYPE)
|
||||
set(CMAKE_BUILD_TYPE Release)
|
||||
endif()
|
||||
set(CMAKE_CXX_FLAGS_RELEASE "-O3")
|
||||
|
||||
# bindings/pymodule.cpp не вендорен в radar_system — включение потребует вернуть его из VNA_AlGo
|
||||
option(GPR_BUILD_PYTHON "Build pybind11 module" OFF)
|
||||
option(GPR_BUILD_TESTS "Build C++ parity tests" OFF)
|
||||
option(GPR_BUILD_EXAMPLES "Build standalone C++ example" ON)
|
||||
|
||||
# Python окружение (venv) — источник pybind11 и pip CUDA-библиотек (nvrtc, cudart).
|
||||
if(NOT DEFINED PYTHON_EXECUTABLE)
|
||||
set(PYTHON_EXECUTABLE python3)
|
||||
endif()
|
||||
|
||||
# CUDA include/lib из pip-пакетов nvidia-*; задаются -DGPR_CUDA_INC / -DGPR_CUDA_LIB
|
||||
# (детект делает build_cmake.sh). Заголовки: nvrtc.h, cuda.h (Driver API).
|
||||
# GPR_CUDA_INC может быть СПИСКОМ каталогов (через ';'): в раздельных pip-пакетах
|
||||
# (Jetson: cuda_nvrtc + cuda_runtime) nvrtc.h и cuda.h лежат в разных include/.
|
||||
if(NOT DEFINED GPR_CUDA_INC OR NOT DEFINED GPR_CUDA_LIB)
|
||||
message(FATAL_ERROR "Задайте -DGPR_CUDA_INC=<dir(ы) с nvrtc.h,cuda.h; ';'-список> и -DGPR_CUDA_LIB=<dir с libnvrtc.so*>")
|
||||
endif()
|
||||
|
||||
# libcuda (драйвер) — системный
|
||||
find_library(CUDA_DRIVER_LIB cuda PATHS /usr/lib/x86_64-linux-gnu /usr/lib/aarch64-linux-gnu
|
||||
/usr/lib REQUIRED)
|
||||
# libnvrtc из pip
|
||||
find_library(NVRTC_LIB NAMES nvrtc libnvrtc.so.13 libnvrtc.so.12 nvrtc.so
|
||||
PATHS ${GPR_CUDA_LIB} REQUIRED NO_DEFAULT_PATH)
|
||||
|
||||
# PocketFFT (header-only, vendored)
|
||||
add_library(pocketfft INTERFACE)
|
||||
target_include_directories(pocketfft INTERFACE ${CMAKE_CURRENT_SOURCE_DIR}/third_party/pocketfft)
|
||||
|
||||
# --- ядро библиотеки (usable без Python) ---
|
||||
add_library(gpr_core_cpp STATIC
|
||||
src/geometry.cpp
|
||||
src/ascan.cpp
|
||||
src/device.cpp
|
||||
src/nvrtc_kernel.cpp
|
||||
src/sar3d.cpp)
|
||||
target_include_directories(gpr_core_cpp PUBLIC
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/include
|
||||
${GPR_CUDA_INC})
|
||||
target_link_libraries(gpr_core_cpp PUBLIC pocketfft ${NVRTC_LIB} ${CUDA_DRIVER_LIB})
|
||||
# rpath на каталог pip libnvrtc — чтобы бинарник/модуль грузились без LD_LIBRARY_PATH
|
||||
set_target_properties(gpr_core_cpp PROPERTIES POSITION_INDEPENDENT_CODE ON
|
||||
BUILD_RPATH "${GPR_CUDA_LIB}" INSTALL_RPATH "${GPR_CUDA_LIB}")
|
||||
|
||||
if(GPR_BUILD_PYTHON)
|
||||
execute_process(COMMAND ${PYTHON_EXECUTABLE} -m pybind11 --cmakedir
|
||||
OUTPUT_VARIABLE PB11_DIR OUTPUT_STRIP_TRAILING_WHITESPACE RESULT_VARIABLE PB11_RC)
|
||||
if(PB11_RC EQUAL 0)
|
||||
find_package(pybind11 CONFIG REQUIRED HINTS ${PB11_DIR})
|
||||
else()
|
||||
find_package(pybind11 CONFIG REQUIRED)
|
||||
endif()
|
||||
pybind11_add_module(_gpr_cpp bindings/pymodule.cpp)
|
||||
target_link_libraries(_gpr_cpp PRIVATE gpr_core_cpp)
|
||||
set_target_properties(_gpr_cpp PROPERTIES BUILD_RPATH "${GPR_CUDA_LIB}" INSTALL_RPATH "${GPR_CUDA_LIB}")
|
||||
endif()
|
||||
|
||||
if(GPR_BUILD_TESTS)
|
||||
add_executable(gpr_test_parity tests/cpp/test_parity.cpp)
|
||||
target_link_libraries(gpr_test_parity PRIVATE gpr_core_cpp)
|
||||
endif()
|
||||
|
||||
if(GPR_BUILD_EXAMPLES)
|
||||
add_executable(standalone_bp examples/standalone_bp.cpp)
|
||||
target_link_libraries(standalone_bp PRIVATE gpr_core_cpp)
|
||||
set_target_properties(standalone_bp PROPERTIES BUILD_RPATH "${GPR_CUDA_LIB}" INSTALL_RPATH "${GPR_CUDA_LIB}")
|
||||
endif()
|
||||
@@ -1,85 +0,0 @@
|
||||
# gpr_cpp — C++/CUDA счёт-ядро MIMO GPR 3D SAR
|
||||
|
||||
> Инфраструктура (сборка, NVRTC, PocketFFT, npy) вендорена из репозитория
|
||||
> VNA_AlGo (`gpr_cpp/`, коммит `544dc59`) без python-биндингов
|
||||
> (`GPR_BUILD_PYTHON=OFF`). АЛГОРИТМ с сентября 2026 переписан: это порт
|
||||
> эталонного ноутбука `Multi_antenna_3D_clean_v1.ipynb` (ячейки 0 и 4), а не
|
||||
> старого `gpr_core`/Horns_3D. Паритет с ноутбуком проверяется инструментом
|
||||
> `sar3d_parity` + `python_app/scripts/sar3d_volume_compare.py` по
|
||||
> референсным `*_volume.npz`. Собирается из корневого Makefile radar_system
|
||||
> (цель `$(GPR_CPP_LIB)` вызывает `build_cmake.sh`).
|
||||
|
||||
## Что внутри
|
||||
|
||||
**В C++ (`libgpr_core_cpp`):** сегментированный тайминг кадра по per-trace
|
||||
меткам захвата (`compute_pair_timestamps`), препроцессинг A-сканов (bg-subtract,
|
||||
полная фазовая коррекция движения intra+pair до IFFT, k0-вставка + Ханн + IFFT
|
||||
через PocketFFT, нормировка пар), полнообластная ground-сетка
|
||||
(`build_full_region_grid`), 3D SAR апертурный объём (одно CUDA-ядро на всю
|
||||
апертуру; геометрия в double, A-сканы float32) и финализация
|
||||
(`finish_volume`: |acc/cnt| → маска глубины → нормировка без Y-краёв → гаусс
|
||||
по (Z,X) → нормировка; возвращает объём [0,1] + level).
|
||||
|
||||
## Ключевое решение: CUDA без nvcc (NVRTC + Driver API)
|
||||
|
||||
На целевых машинах (x86+T600, Jetson) нет полного CUDA toolkit — только runtime +
|
||||
**NVRTC** (как у cupy). Поэтому ядро **не компилируется nvcc'ом**: его исходник
|
||||
встроен строкой ([src/kernel_src.hpp](src/kernel_src.hpp)), компилируется в рантайме
|
||||
через NVRTC в **cubin под точную арх устройства** (запрос compute capability →
|
||||
`sm_XY`, без JIT PTX), грузится и запускается через **CUDA Driver API**. Хост-часть
|
||||
собирается обычным g++. Плюс: без toolkit и без привязки к SM — один код для
|
||||
x86 (sm_75) и Jetson (sm_87).
|
||||
|
||||
## Сборка
|
||||
|
||||
```bash
|
||||
bash build_cmake.sh # авто-детект pip CUDA-путей; собирает всё
|
||||
# артефакты в build/: libgpr_core_cpp.a, _gpr_cpp*.so, standalone_bp
|
||||
```
|
||||
Требуется: g++ ≥13, cmake ≥3.24, pybind11 (`pip install pybind11`), pip-пакеты
|
||||
`nvidia-*` (nvrtc+cudart, идут с cupy), системный `libcuda`. rpath на каталог
|
||||
libnvrtc прописан — бинарники/модуль грузятся без `LD_LIBRARY_PATH`.
|
||||
|
||||
## Публичный C++ API ([include/gpr/](include/gpr/))
|
||||
|
||||
```cpp
|
||||
#include "gpr/config.hpp" // GeometryConfig/BPConfig/MotionConfig/SARConfig (дефолты = ноутбук)
|
||||
#include "gpr/ascan.hpp" // compute_pair_timestamps, preprocess_snapshot(...) -> AScanSet
|
||||
#include "gpr/sar3d.hpp" // SarEngine::aperture_volume -> VolumeRaw; finish_volume -> FinishedVolume
|
||||
#include "gpr/geometry.hpp" // frame_offsets, frame_weights, build_full_region_grid
|
||||
|
||||
gpr::SarEngine engine(geom, bp, mot, sar); // компилит ядро под устройство
|
||||
auto raw = engine.aperture_volume(frames, grid); // один запуск ядра на апертуру
|
||||
auto fin = gpr::finish_volume(raw, grid, mot.pitch_deg, bp, sar); // объём [0,1] + level
|
||||
```
|
||||
Пример без Python: [examples/standalone_bp.cpp](examples/standalone_bp.cpp) — грузит
|
||||
S21 из `.npy`, строит объём, печатает максимум.
|
||||
```bash
|
||||
./build/standalone_bp <preprocessed_dir> 0038_id39_ns11361438137486
|
||||
```
|
||||
|
||||
## Python-модуль
|
||||
|
||||
```python
|
||||
import _gpr_cpp # из gpr_cpp/build
|
||||
eng = _gpr_cpp.SarEngine(_gpr_cpp.GeometryConfig(), _gpr_cpp.BPConfig(),
|
||||
_gpr_cpp.MotionConfig(), _gpr_cpp.SARConfig())
|
||||
a = _gpr_cpp.preprocess_snapshot(s21, freq, background, mot, geom, bp, True) # -> AScanSet
|
||||
acc, cnt = eng.aperture_volume([(a, dx, dy, dz, w), ...], gx, gy, gz) # numpy
|
||||
```
|
||||
|
||||
## Паритет с ноутбуком (проверено на x86 T600, датасет 20260922_mine_fast_true)
|
||||
|
||||
Кадры 0034/0035/0036 против референсных `*_volume.npz` ноутбука
|
||||
(`make sar3d_parity` + `python_app/scripts/sar3d_volume_compare.py`):
|
||||
max|Δ| ≤ 1.6e-6 вне ножевой границы маски глубины, argmax и level совпадают
|
||||
(level rel ≤ 7e-7). Python-эталон той же цепочки:
|
||||
`python_app/scripts/sar3d_reference_check.py`.
|
||||
|
||||
## Статус
|
||||
|
||||
- [x] x86 (T600, sm_75, CUDA 13): собрано, паритет, standalone работает.
|
||||
- [ ] Jetson (Orin NX, sm_87, CUDA 12.6): порт тривиален (NVRTC сам берёт sm_87);
|
||||
собрать `build_cmake.sh` на Jetson с его venv.
|
||||
- Опц.: ветка `impl='cpp'` в `gpr_core/sar3d.py` (адаптер `gpr_cpp_backend.py`),
|
||||
кэш device-A-сканов (сейчас каждый вызов грузит стек на GPU).
|
||||
@@ -1,87 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# CMake-сборка libgpr_core_cpp + standalone (без nvcc; NVRTC/driver).
|
||||
#
|
||||
# Источник CUDA (nvrtc.h / libnvrtc), по убыванию приоритета:
|
||||
# 1. env CUDA_INC / CUDA_LIB — заданы явно;
|
||||
# 2. pip-пакеты nvidia-* в venv ($VENV, по умолчанию ../../.venv — venv
|
||||
# radar_system) — дев-машина;
|
||||
# 3. системный toolkit /usr/local/cuda — Jetson/JetPack, apt cuda-toolkit.
|
||||
#
|
||||
# GPR_BUILD_PYTHON=ON вернул бы pybind-модуль, но bindings/ не вендорен в
|
||||
# radar_system (см. README) — по умолчанию OFF.
|
||||
set -eu
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||
VENV="${VENV:-$HERE/../../.venv}"
|
||||
PY="$VENV/bin/python"
|
||||
GPR_BUILD_PYTHON="${GPR_BUILD_PYTHON:-OFF}"
|
||||
|
||||
CUDA_INC="${CUDA_INC:-}"
|
||||
CUDA_LIB="${CUDA_LIB:-}"
|
||||
|
||||
if [ -z "$CUDA_INC" ] || [ -z "$CUDA_LIB" ]; then
|
||||
if [ -x "$PY" ]; then
|
||||
# Печатается только когда найдены nvrtc.h, cuda.h И библиотека — частичная
|
||||
# детекция не может подсунуть мусорный путь. nvrtc.h и cuda.h могут лежать
|
||||
# в РАЗНЫХ каталогах (раздельные pip-пакеты cuda_nvrtc/cuda_runtime на
|
||||
# Jetson) — тогда CUDA_INC становится ';'-списком для CMake.
|
||||
PIP_CUDA=$("$PY" - <<'P' 2>/dev/null || true
|
||||
import glob, os, site
|
||||
sp = site.getsitepackages()[0]
|
||||
nvrtc_hdr = glob.glob(os.path.join(sp, 'nvidia', '**', 'include', 'nvrtc.h'), recursive=True)
|
||||
cuda_hdr = glob.glob(os.path.join(sp, 'nvidia', '**', 'include', 'cuda.h'), recursive=True)
|
||||
lib = sorted(glob.glob(os.path.join(sp, 'nvidia', '**', 'lib', 'libnvrtc.so*'), recursive=True))
|
||||
if nvrtc_hdr and cuda_hdr and lib:
|
||||
inc_dirs = []
|
||||
for hdr in (nvrtc_hdr[0], cuda_hdr[0]):
|
||||
d = os.path.dirname(hdr)
|
||||
if d not in inc_dirs:
|
||||
inc_dirs.append(d)
|
||||
print(';'.join(inc_dirs))
|
||||
print(os.path.dirname(lib[0]))
|
||||
P
|
||||
)
|
||||
if [ -n "$PIP_CUDA" ]; then
|
||||
CUDA_INC=$(printf '%s\n' "$PIP_CUDA" | sed -n 1p)
|
||||
CUDA_LIB=$(printf '%s\n' "$PIP_CUDA" | sed -n 2p)
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$CUDA_INC" ] || [ -z "$CUDA_LIB" ]; then
|
||||
if [ -e /usr/local/cuda/include/nvrtc.h ]; then
|
||||
CUDA_INC=/usr/local/cuda/include
|
||||
CUDA_LIB=/usr/local/cuda/lib64
|
||||
echo "[build_cmake] pip nvidia-* не найдены; использую системный CUDA: $CUDA_INC"
|
||||
else
|
||||
echo "[build_cmake] CUDA не найден: нет ни pip nvidia-* в $VENV, ни /usr/local/cuda." >&2
|
||||
echo "[build_cmake] Задайте CUDA_INC=<dir с nvrtc.h,cuda.h> и CUDA_LIB=<dir с libnvrtc.so*>." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
PYTHON_ARG=()
|
||||
if [ "$GPR_BUILD_PYTHON" = "ON" ] && [ -x "$PY" ]; then
|
||||
PYTHON_ARG=(-DPYTHON_EXECUTABLE="$PY")
|
||||
fi
|
||||
|
||||
# cmake: сначала из venv (pip install cmake — свежий без sudo), потом системный.
|
||||
CMAKE="$VENV/bin/cmake"
|
||||
if [ ! -x "$CMAKE" ]; then
|
||||
CMAKE=$(command -v cmake || true)
|
||||
fi
|
||||
if [ -z "$CMAKE" ]; then
|
||||
echo "[build_cmake] cmake не найден. Поставьте его в venv: \"$VENV/bin/pip\" install cmake" >&2
|
||||
echo "[build_cmake] (или системно: sudo apt install cmake)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
"$CMAKE" -S "$HERE" -B "$HERE/build" \
|
||||
${PYTHON_ARG[@]+"${PYTHON_ARG[@]}"} \
|
||||
-DGPR_BUILD_PYTHON="$GPR_BUILD_PYTHON" \
|
||||
-DGPR_CUDA_INC="$CUDA_INC" \
|
||||
-DGPR_CUDA_LIB="$CUDA_LIB" \
|
||||
-DCMAKE_BUILD_TYPE=Release "$@" 2>&1 | tail -8
|
||||
"$CMAKE" --build "$HERE/build" -j4 2>&1 | tail -15
|
||||
echo "--- артефакты ---"
|
||||
ls -la "$HERE/build/libgpr_core_cpp.a" "$HERE/build/standalone_bp" "$HERE"/build/_gpr_cpp*.so 2>/dev/null | awk '{print $5,$9}'
|
||||
echo "CUDA_LIB=$CUDA_LIB (для LD_LIBRARY_PATH при запуске)"
|
||||
@@ -1,159 +0,0 @@
|
||||
// Автономный пример: C++ без Python. Грузит S21 снапшота из .npy,
|
||||
// строит A-сканы и 3D SAR-объём апертуры, печатает максимум |acc/cnt|.
|
||||
//
|
||||
// Использование:
|
||||
// standalone_bp <preprocessed_dir> <center_snapshot_name>
|
||||
// где preprocessed_dir содержит папки снапшотов с i{rx}_o{tx}_s21.npy и _freq.npy.
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <complex>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <dirent.h>
|
||||
#include <fstream>
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "gpr/ascan.hpp"
|
||||
#include "gpr/config.hpp"
|
||||
#include "gpr/geometry.hpp"
|
||||
#include "gpr/sar3d.hpp"
|
||||
|
||||
using namespace gpr;
|
||||
|
||||
// --- минимальный загрузчик numpy .npy (v1.0, 1-D complex128 или float64) ---
|
||||
struct NpyArray { std::vector<double> f64; std::vector<c128> c128v; bool is_complex = false; };
|
||||
|
||||
static NpyArray load_npy(const std::string& path) {
|
||||
std::ifstream in(path, std::ios::binary);
|
||||
if (!in) throw std::runtime_error("не открыть " + path);
|
||||
char magic[6]; in.read(magic, 6);
|
||||
uint8_t ver[2]; in.read((char*)ver, 2);
|
||||
uint16_t hlen; in.read((char*)&hlen, 2);
|
||||
std::string hdr(hlen, '\0'); in.read(&hdr[0], hlen);
|
||||
auto sp = hdr.find("'shape':");
|
||||
auto lp = hdr.find('(', sp), rp = hdr.find(')', sp);
|
||||
long n = std::stol(hdr.substr(lp + 1, rp - lp - 1));
|
||||
NpyArray a;
|
||||
auto has = [&](const char* s) { return hdr.find(s) != std::string::npos; };
|
||||
if (has("c8")) { // complex64 → c128
|
||||
a.is_complex = true; a.c128v.resize(n);
|
||||
std::vector<std::complex<float>> tmp(n);
|
||||
in.read((char*)tmp.data(), n * sizeof(std::complex<float>));
|
||||
for (long i = 0; i < n; ++i) a.c128v[i] = c128(tmp[i].real(), tmp[i].imag());
|
||||
} else if (has("c16")) { // complex128
|
||||
a.is_complex = true; a.c128v.resize(n);
|
||||
in.read((char*)a.c128v.data(), n * sizeof(c128));
|
||||
} else if (has("f4")) { // float32 → double
|
||||
a.f64.resize(n);
|
||||
std::vector<float> tmp(n); in.read((char*)tmp.data(), n * sizeof(float));
|
||||
for (long i = 0; i < n; ++i) a.f64[i] = tmp[i];
|
||||
} else { // float64
|
||||
a.f64.resize(n); in.read((char*)a.f64.data(), n * sizeof(double));
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
if (argc < 3) { printf("usage: %s <preprocessed_dir> <center_snapshot>\n", argv[0]); return 1; }
|
||||
std::string root = argv[1], center = argv[2];
|
||||
|
||||
GeometryConfig geom; geom.finalize();
|
||||
BPConfig bp; MotionConfig mot; SARConfig sar;
|
||||
auto pairs = geom.pair_keys();
|
||||
auto tx = geom.output_idx, rx = geom.input_idx;
|
||||
std::sort(tx.begin(), tx.end()); std::sort(rx.begin(), rx.end());
|
||||
|
||||
// список снапшотов, выбор апертуры вокруг center
|
||||
std::vector<std::string> snaps;
|
||||
if (DIR* d = opendir(root.c_str())) {
|
||||
for (dirent* e; (e = readdir(d));) {
|
||||
std::string n = e->d_name;
|
||||
if (!n.empty() && isdigit(n[0])) snaps.push_back(n);
|
||||
}
|
||||
closedir(d);
|
||||
}
|
||||
std::sort(snaps.begin(), snaps.end());
|
||||
int ci = -1; for (int i = 0; i < (int)snaps.size(); ++i) if (snaps[i] == center) ci = i;
|
||||
if (ci < 0) { printf("центр %s не найден\n", center.c_str()); return 1; }
|
||||
int lo = std::max(0, ci - sar.frame_radius), hi = std::min((int)snaps.size(), ci + sar.frame_radius + 1);
|
||||
std::vector<std::string> aperture(snaps.begin() + lo, snaps.begin() + hi);
|
||||
printf("апертура: %d кадров вокруг %s\n", (int)aperture.size(), center.c_str());
|
||||
|
||||
auto load_snapshot = [&](const std::string& snap,
|
||||
std::map<PairKey, std::vector<c128>>& s21,
|
||||
std::map<PairKey, std::vector<double>>& freq) {
|
||||
for (int p = 0; p < (int)pairs.size(); ++p) {
|
||||
int i_tx = pairs[p].first, i_rx = pairs[p].second;
|
||||
int phys_tx = tx[i_tx], phys_rx = rx[i_rx];
|
||||
char fn[512];
|
||||
snprintf(fn, sizeof(fn), "%s/%s/i%d_o%d_s21.npy", root.c_str(), snap.c_str(), phys_rx, phys_tx);
|
||||
s21[{i_tx, i_rx}] = load_npy(fn).c128v;
|
||||
snprintf(fn, sizeof(fn), "%s/%s/i%d_o%d_freq.npy", root.c_str(), snap.c_str(), phys_rx, phys_tx);
|
||||
freq[{i_tx, i_rx}] = load_npy(fn).f64;
|
||||
}
|
||||
};
|
||||
|
||||
// Демо без чтения meta.json: равномерные сегменты по интервалу между
|
||||
// соседними снапшотами (реальные per-trace окна читает sar3d_parity).
|
||||
double acq_s = 0.0;
|
||||
{
|
||||
std::vector<double> stamps;
|
||||
for (auto& s : snaps) {
|
||||
auto pos = s.rfind("_ns");
|
||||
if (pos != std::string::npos) stamps.push_back(std::stod(s.substr(pos + 3)) * 1e-9);
|
||||
}
|
||||
std::sort(stamps.begin(), stamps.end());
|
||||
if (stamps.size() >= 2) acq_s = stamps[1] - stamps[0];
|
||||
}
|
||||
auto windows = uniform_tx_windows(geom.output_idx, acq_s, mot.tx_per_segment);
|
||||
auto ts = compute_pair_timestamps(windows, acq_s, geom.output_idx, geom, mot);
|
||||
SarEngine engine(geom, bp, mot, sar);
|
||||
|
||||
// держим AScanSet живыми
|
||||
std::vector<std::shared_ptr<AScanSet>> keep;
|
||||
std::vector<ApertureFrame> frames;
|
||||
std::vector<double> frame_times;
|
||||
for (auto& snap : aperture) {
|
||||
// время из суффикса _nsNNNN
|
||||
auto pos = snap.rfind("_ns");
|
||||
double t = (pos != std::string::npos) ? std::stod(snap.substr(pos + 3)) * 1e-9 : 0.0;
|
||||
frame_times.push_back(t);
|
||||
}
|
||||
double t_ref = frame_times[ci - lo];
|
||||
auto offs = frame_offsets(frame_times, t_ref, mot);
|
||||
auto wts = frame_weights(offs, sar.edge_frame_weight);
|
||||
|
||||
for (int f = 0; f < (int)aperture.size(); ++f) {
|
||||
std::map<PairKey, std::vector<c128>> s21;
|
||||
std::map<PairKey, std::vector<double>> freq;
|
||||
load_snapshot(aperture[f], s21, freq);
|
||||
auto a = std::make_shared<AScanSet>(
|
||||
preprocess_snapshot(s21, freq, nullptr, mot, geom, bp, ts, true));
|
||||
keep.push_back(a);
|
||||
frames.push_back(ApertureFrame{a.get(), offs[f], wts[f]});
|
||||
}
|
||||
|
||||
// ноутбучные границы сцены (размах антенн ±2 м, полоса глубин) вместо
|
||||
// фиксированного окна по умолчанию
|
||||
notebook_grid_bounds(geom, bp, sar, mot.pitch_deg, 2.0,
|
||||
sar.x_min_m, sar.x_max_m, sar.z_min_m, sar.z_max_m);
|
||||
Grid grid = build_full_region_grid(sar);
|
||||
printf("сетка %dx%dx%d, счёт объёма...\n", grid.NX(), grid.NY(), grid.NZ());
|
||||
|
||||
VolumeRaw vol = engine.aperture_volume(frames, grid);
|
||||
FinishedVolume fin = finish_volume(vol, grid, mot.pitch_deg, bp, sar);
|
||||
|
||||
double vmax = 0; size_t amax = 0;
|
||||
for (size_t i = 0; i < fin.volume.size(); ++i)
|
||||
if (fin.volume[i] > vmax) { vmax = fin.volume[i]; amax = i; }
|
||||
int NX = vol.NX, NZ = vol.NZ;
|
||||
int ix = amax % NX, iz = (amax / NX) % NZ, iy = amax / ((size_t)NX * NZ);
|
||||
printf("МАКСИМУМ объёма: %.4f (level %.4e) в вокселе (ix=%d,iy=%d,iz=%d)\n",
|
||||
vmax, fin.level, ix, iy, iz);
|
||||
printf(" X=%.1f см, Y=%.1f см, Z=%.1f см\n",
|
||||
grid.x[ix] * 100, grid.y[iy] * 100, grid.z[iz] * 100);
|
||||
printf("OK: автономный C++ путь отработал без Python.\n");
|
||||
return 0;
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
// Препроцессинг S21 → комплексные A-сканы (host, float64).
|
||||
// Порт ячейки 0 Multi_antenna_3D_clean_v1.ipynb (segmented_interleaved тайминг,
|
||||
// frequency-domain коррекция движения, k0-IFFT, нормировка пар).
|
||||
#pragma once
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include "gpr/config.hpp"
|
||||
#include "gpr/types.hpp"
|
||||
|
||||
namespace gpr {
|
||||
|
||||
using PairKey = std::pair<int, int>;
|
||||
|
||||
// Тайминг пары в модели segmented_interleaved: пара живёт в сегменте своего Tx
|
||||
// (окно [segment_start, segment_start+segment_time] от начала акквизиции),
|
||||
// внутри сегмента передатчики чередуются по частоте (slot из n_slots).
|
||||
struct PairTiming {
|
||||
int tx_interleave_slot = 0;
|
||||
int tx_interleave_slots = 1;
|
||||
double segment_start_s = 0.0;
|
||||
double segment_time_s = 0.0;
|
||||
double t_center_s = 0.0; // центр окна сегмента
|
||||
// Грубый межсегментный сдвиг: dtau центра сегмента относительно центра
|
||||
// акквизиции кадра (2*dz/v, dz = dir*speed*dt*cos(look)).
|
||||
double dtau_pair_s = 0.0;
|
||||
};
|
||||
using PairTimestamps = std::map<PairKey, PairTiming>;
|
||||
|
||||
// Окна захвата по Tx (output_pos → [start,end] сек от начала акквизиции).
|
||||
using TxWindows = std::map<int, std::pair<double, double>>;
|
||||
|
||||
// Fallback без per-trace меток: сегменты по tx_per_segment штук делят кадр
|
||||
// [0, acq_s] поровну (медианная модель ноутбука compute_pair_timestamps).
|
||||
TxWindows uniform_tx_windows(const std::vector<int>& tx_order, double acq_s, int tx_per_segment);
|
||||
|
||||
// Порт compute_pair_timestamps_for_snapshot: Tx с одинаковым окном — один
|
||||
// сегмент (сегменты по возрастанию окна), слот — порядок в tx_order (полный
|
||||
// аппаратный порядок из run.combos; выбранные каналы его не меняют).
|
||||
PairTimestamps compute_pair_timestamps(const TxWindows& tx_windows_s, double acq_s,
|
||||
const std::vector<int>& tx_order,
|
||||
const GeometryConfig&, const MotionConfig&);
|
||||
|
||||
// A-сканы одного снапшота, общая ось времени у всех пар (assert).
|
||||
struct AScanSet {
|
||||
int n_pairs = 0;
|
||||
int n_fft = 0;
|
||||
double t0 = 0.0, dt = 0.0; // общая ось: t_k = t0 + k*dt
|
||||
std::vector<PairKey> pairs; // порядок как в geom.pair_keys()
|
||||
std::vector<c128> H; // [n_pairs * n_fft] float64
|
||||
const c128* pair_ptr(int p) const { return H.data() + (size_t)p * n_fft; }
|
||||
};
|
||||
|
||||
// Порт sar_pair_ascans: bg-subtract → phase correction → k0-IFFT → норма.
|
||||
// s21/freq/background — по ключам (i_tx,i_rx); background может быть nullptr.
|
||||
AScanSet preprocess_snapshot(
|
||||
const std::map<PairKey, std::vector<c128>>& s21,
|
||||
const std::map<PairKey, std::vector<double>>& freq,
|
||||
const std::map<PairKey, std::vector<c128>>* background,
|
||||
const MotionConfig&, const GeometryConfig&, const BPConfig&,
|
||||
const PairTimestamps&, bool normalize = true);
|
||||
|
||||
// Отдельные шаги (для тестов/переиспользования):
|
||||
// Полная фазовая модель движения до IFFT: dtau_intra(f) + dtau_pair одной
|
||||
// фазой exp(sign*j*2*pi*f*dtau_total) (порт apply_intra_sweep_phase_correction).
|
||||
std::vector<c128> apply_intra_sweep_phase_correction(
|
||||
const std::vector<c128>& s21, const std::vector<double>& freq_full,
|
||||
const PairTiming&, const MotionConfig&, double wave_speed,
|
||||
double f_start, double f_stop);
|
||||
|
||||
// Возвращает t (ось), h (комплексный A-скан). n_fft через out-параметр.
|
||||
void compute_ascan_bp(const std::vector<c128>& s21, const std::vector<double>& freq,
|
||||
double f_start, double f_stop, bool window, int oversample,
|
||||
std::vector<double>& t_out, std::vector<c128>& h_out);
|
||||
|
||||
double pair_norm_scale(const std::vector<c128>& h, const std::vector<double>& t,
|
||||
double wave_speed, const BPConfig&);
|
||||
|
||||
} // namespace gpr
|
||||
@@ -1,114 +0,0 @@
|
||||
// Конфигурация счёт-ядра. Дефолты 1:1 повторяют Multi_antenna_3D_clean_v1.ipynb
|
||||
// (ячейки 0 и 4) — эталонный ноутбук 3D SAR.
|
||||
#pragma once
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace gpr {
|
||||
|
||||
struct GeometryConfig {
|
||||
std::vector<int> input_idx{0, 1, 2, 3};
|
||||
std::vector<int> output_idx{0, 1, 2, 3};
|
||||
// (x,y,z) метры; порядок соответствует input_idx/output_idx
|
||||
std::vector<std::array<double, 3>> tx_positions{
|
||||
{-0.88, 0.0, 0.0}, {0.89, 0.0, 0.0}, {-0.33, 0.445, 0.0}, {0.33, 0.445, 0.0}};
|
||||
std::vector<std::array<double, 3>> rx_positions{
|
||||
{0.23, 0.0, 0.0}, {0.54, 0.15, 0.0}, {-0.55, 0.3, 0.0}, {-0.23, 0.0, 0.0}};
|
||||
double eps_r = 1.0;
|
||||
|
||||
// --- производные (заполняются finalize) ---
|
||||
std::vector<double> x_tx, y_tx, z_tx; // отсортированы по phys-индексу
|
||||
std::vector<double> x_rx, y_rx, z_rx;
|
||||
int n_tx = 0, n_rx = 0;
|
||||
double v = 0.0;
|
||||
|
||||
void finalize() {
|
||||
auto sort_xyz = [](std::vector<int> idx, const std::vector<std::array<double, 3>>& pos,
|
||||
std::vector<double>& X, std::vector<double>& Y, std::vector<double>& Z) {
|
||||
std::vector<int> order(idx.size());
|
||||
for (size_t i = 0; i < idx.size(); ++i) order[i] = (int)i;
|
||||
std::sort(order.begin(), order.end(), [&](int a, int b) { return idx[a] < idx[b]; });
|
||||
X.clear(); Y.clear(); Z.clear();
|
||||
for (int o : order) { X.push_back(pos[o][0]); Y.push_back(pos[o][1]); Z.push_back(pos[o][2]); }
|
||||
};
|
||||
sort_xyz(output_idx, tx_positions, x_tx, y_tx, z_tx);
|
||||
sort_xyz(input_idx, rx_positions, x_rx, y_rx, z_rx);
|
||||
n_tx = (int)output_idx.size();
|
||||
n_rx = (int)input_idx.size();
|
||||
v = 3e8 / std::sqrt(eps_r);
|
||||
}
|
||||
|
||||
std::vector<std::pair<int, int>> pair_keys() const {
|
||||
std::vector<std::pair<int, int>> p;
|
||||
for (int i = 0; i < n_tx; ++i)
|
||||
for (int j = 0; j < n_rx; ++j) p.emplace_back(i, j);
|
||||
return p;
|
||||
}
|
||||
};
|
||||
|
||||
struct BPConfig {
|
||||
double f_start = 2.0e9;
|
||||
double f_stop = 6.0e9;
|
||||
double min_depth = 2.5;
|
||||
double max_depth = 8.0;
|
||||
int oversample = 8;
|
||||
bool window = true; // окно Ханна на полосе
|
||||
bool pair_normalize = true;
|
||||
double pair_norm_percentile = 50.0;
|
||||
double pair_norm_depth_min = 2.5;
|
||||
double pair_norm_depth_max = 8.0;
|
||||
double pair_norm_eps = 1e-15;
|
||||
// Компенсация геометрического расхождения 1/(Rtx*Rrx), нормированная на
|
||||
// точку под виртуальным центром пары на глубине comp_ref_depth НА ПЛОСКОСТИ
|
||||
// ЗЕМЛИ (высота (-ground_depth + d*sin p)/cos p).
|
||||
double comp_range_power = 0.3;
|
||||
double comp_weight_max = 5.0;
|
||||
double comp_ref_depth = 5.0;
|
||||
};
|
||||
|
||||
struct MotionConfig {
|
||||
double speed_m_s = 1.45;
|
||||
double look_angle_deg = 17.5;
|
||||
// Наклон рамы для ground-координат и смещений кадров; в ноутбуке
|
||||
// BP_GROUND_PITCH_DEG=None означает «равен look_angle».
|
||||
double pitch_deg = 17.5;
|
||||
double direction_sign = +1.0;
|
||||
// Внутрисвиповая (по частоте) фазовая коррекция движения.
|
||||
bool apply_freq_phase_correction = true;
|
||||
// Грубая межсегментная коррекция (dtau сегмента относительно центра кадра),
|
||||
// вносится той же фазовой моделью до IFFT (режим 'frequency_domain').
|
||||
bool apply_pair_motion_correction = true;
|
||||
double motion_phase_sign = -1.0;
|
||||
// Fallback-разбиение кадра на сегменты, когда per-trace меток захвата нет.
|
||||
int tx_per_segment = 2;
|
||||
};
|
||||
|
||||
struct SARConfig {
|
||||
int frame_radius = 0; // 0 = один снимок (физическая вертикальная база)
|
||||
double edge_frame_weight = 0.8; // вес крайнего кадра апертуры
|
||||
bool coord_mode_ground = true; // 'ground' vs 'radar_local'
|
||||
bool use_compensation = true;
|
||||
// Высота НИЖНЕГО ряда антенн над землёй; Y=0 сцены — поверхность земли.
|
||||
double ground_depth_m = 1.25;
|
||||
// Явные границы сцены [м]: X — поперёк, Y — высота над землёй, Z — вдаль.
|
||||
// Воксели с радарной глубиной вне [min_depth, max_depth] всё равно
|
||||
// обнуляются маской, так что Z-окно шире полосы глубин безопасно.
|
||||
double x_min_m = -2.0;
|
||||
double x_max_m = 2.0;
|
||||
double y_min_m = -0.5;
|
||||
double y_max_m = 2.0;
|
||||
double z_min_m = 0.0;
|
||||
double z_max_m = 8.0;
|
||||
std::array<double, 3> voxel_m{0.025, 0.02, 0.02}; // шаг (X, Y, Z)
|
||||
// Финализация объёма: нормировка без верхних/нижних norm_edge_margin_m по Y
|
||||
// (там ложный гребень постоянной дальности), сглаживание по (Z,X) в каждом
|
||||
// Y-плане.
|
||||
double norm_edge_margin_m = 0.3;
|
||||
double smooth_sigma = 1.5;
|
||||
};
|
||||
|
||||
} // namespace gpr
|
||||
@@ -1,52 +0,0 @@
|
||||
// CUDA Driver API RAII: контекст, поток, буфер устройства.
|
||||
#pragma once
|
||||
#include <cuda.h>
|
||||
|
||||
#include <cstddef>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
|
||||
namespace gpr {
|
||||
|
||||
void cu_check(CUresult r, const char* what);
|
||||
|
||||
// Инициализация драйвера + primary context на device 0. Возвращает (major,minor) CC.
|
||||
struct CudaContext {
|
||||
CUdevice dev = 0;
|
||||
CUcontext ctx = nullptr;
|
||||
int cc_major = 0, cc_minor = 0;
|
||||
CudaContext();
|
||||
~CudaContext();
|
||||
std::string arch_flag() const; // "--gpu-architecture=sm_XY"
|
||||
};
|
||||
|
||||
template <class T>
|
||||
class DeviceBuffer {
|
||||
CUdeviceptr ptr_ = 0;
|
||||
size_t n_ = 0;
|
||||
|
||||
public:
|
||||
DeviceBuffer() = default;
|
||||
explicit DeviceBuffer(size_t n) { alloc(n); }
|
||||
~DeviceBuffer() { free(); }
|
||||
DeviceBuffer(const DeviceBuffer&) = delete;
|
||||
DeviceBuffer& operator=(const DeviceBuffer&) = delete;
|
||||
DeviceBuffer(DeviceBuffer&& o) noexcept : ptr_(o.ptr_), n_(o.n_) { o.ptr_ = 0; o.n_ = 0; }
|
||||
|
||||
void alloc(size_t n) {
|
||||
free();
|
||||
if (n) cu_check(cuMemAlloc(&ptr_, n * sizeof(T)), "cuMemAlloc");
|
||||
n_ = n;
|
||||
}
|
||||
void free() {
|
||||
if (ptr_) cuMemFree(ptr_);
|
||||
ptr_ = 0; n_ = 0;
|
||||
}
|
||||
void upload(const T* host, size_t n) { cu_check(cuMemcpyHtoD(ptr_, host, n * sizeof(T)), "HtoD"); }
|
||||
void download(T* host, size_t n) const { cu_check(cuMemcpyDtoH(host, ptr_, n * sizeof(T)), "DtoH"); }
|
||||
CUdeviceptr ptr() const { return ptr_; }
|
||||
CUdeviceptr* addr() { return &ptr_; }
|
||||
size_t size() const { return n_; }
|
||||
};
|
||||
|
||||
} // namespace gpr
|
||||
@@ -1,41 +0,0 @@
|
||||
// Геометрия: опорное расхождение пары, смещения/веса кадров, построение сетки.
|
||||
#pragma once
|
||||
#include <vector>
|
||||
|
||||
#include "gpr/config.hpp"
|
||||
#include "gpr/types.hpp"
|
||||
|
||||
namespace gpr {
|
||||
|
||||
// 1/geo_ref пары (порт pair_geo_ref, скаляр float64).
|
||||
double pair_geo_ref(const GeometryConfig&, const BPConfig&, int i_tx, int i_rx, double yy);
|
||||
|
||||
// Смещения кадров относительно опорного (порт sar_frame_offsets).
|
||||
// frame_times[k] — время кадра (сек, от ns-таймстемпа); t_ref — время опорного.
|
||||
std::vector<FrameOffset> frame_offsets(const std::vector<double>& frame_times, double t_ref,
|
||||
const MotionConfig&);
|
||||
|
||||
// Косинусные веса апертуры (порт sar_frame_weights); dt берётся относительно центра.
|
||||
std::vector<double> frame_weights(const std::vector<FrameOffset>& offsets, double edge_weight);
|
||||
|
||||
// Высота плоскости земли (радарные оси) на радарной глубине depth:
|
||||
// y = (-ground_depth + depth*sin(pitch)) / cos(pitch) (порт bp_plane_y).
|
||||
double ground_plane_y(double depth, double ground_depth_m, double pitch_rad);
|
||||
|
||||
// Сетка сцены по явным границам SARConfig (x/y/z_min..max, шаг voxel_m);
|
||||
// оси — linspace c числом точек round(span/step)+1, как в ноутбуке.
|
||||
Grid build_full_region_grid(const SARConfig&);
|
||||
|
||||
// Ноутбучные границы (sar_build_full_region_grid): X — размах антенн ±margin,
|
||||
// Z — полоса, покрывающая радарные глубины [min_depth, max_depth] на всех
|
||||
// высотах Y. Используются parity-инструментом для сверки с эталонными npz.
|
||||
void notebook_grid_bounds(const GeometryConfig&, const BPConfig&, const SARConfig&,
|
||||
double pitch_deg, double x_margin_m,
|
||||
double& x_min, double& x_max, double& z_min, double& z_max);
|
||||
|
||||
// point_to_scene: оси радара → сцена (поворот на pitch + подъём на ground_depth,
|
||||
// если ground; Y=0 сцены — земля).
|
||||
std::array<double, 3> point_to_scene(double x, double y, double z, const MotionConfig&,
|
||||
const SARConfig&);
|
||||
|
||||
} // namespace gpr
|
||||
@@ -1,25 +0,0 @@
|
||||
// Компиляция строки CUDA-ядра через NVRTC → cubin под арх устройства → CUfunction.
|
||||
#pragma once
|
||||
#include <cuda.h>
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace gpr {
|
||||
|
||||
class NvrtcKernel {
|
||||
CUmodule module_ = nullptr;
|
||||
CUfunction func_ = nullptr;
|
||||
|
||||
public:
|
||||
NvrtcKernel() = default;
|
||||
~NvrtcKernel();
|
||||
NvrtcKernel(const NvrtcKernel&) = delete;
|
||||
NvrtcKernel& operator=(const NvrtcKernel&) = delete;
|
||||
|
||||
// Компилирует src в cubin под arch_flag ("--gpu-architecture=sm_XY"),
|
||||
// загружает модуль и берёт функцию func_name.
|
||||
void compile(const char* src, const std::string& arch_flag, const char* func_name);
|
||||
CUfunction func() const { return func_; }
|
||||
};
|
||||
|
||||
} // namespace gpr
|
||||
@@ -1,55 +0,0 @@
|
||||
// 3D SAR: сборка взвешенного объёма апертуры за один запуск ядра.
|
||||
#pragma once
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
|
||||
#include "gpr/ascan.hpp"
|
||||
#include "gpr/config.hpp"
|
||||
#include "gpr/device.hpp"
|
||||
#include "gpr/geometry.hpp"
|
||||
#include "gpr/nvrtc_kernel.hpp"
|
||||
#include "gpr/types.hpp"
|
||||
|
||||
namespace gpr {
|
||||
|
||||
// Один кадр апертуры: A-сканы + смещение + вес.
|
||||
struct ApertureFrame {
|
||||
const AScanSet* ascans;
|
||||
FrameOffset offset;
|
||||
double weight;
|
||||
};
|
||||
|
||||
// Сырой результат объёма (до finish_volume).
|
||||
struct VolumeRaw {
|
||||
std::vector<c64> acc; // [NY*NZ*NX]
|
||||
std::vector<float> cnt; // [NY*NZ*NX]
|
||||
int NX = 0, NY = 0, NZ = 0;
|
||||
};
|
||||
|
||||
// Финальный объём (порт sar_finish_volume): |acc/cnt| → маска глубины →
|
||||
// нормировка по Y-планам без краёв → гаусс по (Z,X) → нормировка → clip.
|
||||
struct FinishedVolume {
|
||||
std::vector<float> volume; // [NY*NZ*NX], нормирован на ~[0,1]
|
||||
double level = 0.0; // масштаб, на который поделён объём
|
||||
int NX = 0, NY = 0, NZ = 0;
|
||||
};
|
||||
|
||||
FinishedVolume finish_volume(const VolumeRaw&, const Grid&, double pitch_deg,
|
||||
const BPConfig&, const SARConfig&);
|
||||
|
||||
class SarEngine {
|
||||
public:
|
||||
SarEngine(const GeometryConfig&, const BPConfig&, const MotionConfig&, const SARConfig&);
|
||||
// Весь объём апертуры за один запуск ядра; возвращает сырые acc/cnt (host).
|
||||
VolumeRaw aperture_volume(const std::vector<ApertureFrame>& frames, const Grid&);
|
||||
|
||||
private:
|
||||
GeometryConfig geom_;
|
||||
BPConfig bp_;
|
||||
MotionConfig mot_;
|
||||
SARConfig sar_;
|
||||
std::unique_ptr<CudaContext> ctx_;
|
||||
std::unique_ptr<NvrtcKernel> kernel_;
|
||||
};
|
||||
|
||||
} // namespace gpr
|
||||
@@ -1,25 +0,0 @@
|
||||
// Базовые типы счёт-ядра.
|
||||
#pragma once
|
||||
#include <complex>
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
namespace gpr {
|
||||
|
||||
using c64 = std::complex<float>; // complex64, layout == cuComplex (2×float)
|
||||
using c128 = std::complex<double>; // float64 для препроцессинга/IFFT
|
||||
|
||||
// Ground-scene сетка (порт dict из geometry.py: x,y,z оси в метрах).
|
||||
struct Grid {
|
||||
std::vector<double> x, y, z;
|
||||
int NX() const { return (int)x.size(); }
|
||||
int NY() const { return (int)y.size(); }
|
||||
int NZ() const { return (int)z.size(); }
|
||||
};
|
||||
|
||||
// Смещение антенн кадра апертуры (порт sar_frame_offsets).
|
||||
struct FrameOffset {
|
||||
double dx_m = 0.0, dy_m = 0.0, dz_m = 0.0, dt_s = 0.0, horizontal_m = 0.0;
|
||||
};
|
||||
|
||||
} // namespace gpr
|
||||
@@ -1,250 +0,0 @@
|
||||
#include "gpr/ascan.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <numeric>
|
||||
#include <stdexcept>
|
||||
|
||||
#include "pocketfft_hdronly.h"
|
||||
|
||||
namespace gpr {
|
||||
|
||||
TxWindows uniform_tx_windows(const std::vector<int>& tx_order, double acq_s, int tx_per_segment) {
|
||||
TxWindows windows;
|
||||
if (tx_order.empty()) return windows;
|
||||
int per_seg = std::max(1, tx_per_segment);
|
||||
int n_seg = std::max(1, (int)((tx_order.size() + per_seg - 1) / per_seg));
|
||||
double t_seg = acq_s / n_seg;
|
||||
for (size_t k = 0; k < tx_order.size(); ++k) {
|
||||
int seg = (int)k / per_seg;
|
||||
windows[tx_order[k]] = {seg * t_seg, seg * t_seg + t_seg};
|
||||
}
|
||||
return windows;
|
||||
}
|
||||
|
||||
PairTimestamps compute_pair_timestamps(const TxWindows& tx_windows_s, double acq_s,
|
||||
const std::vector<int>& tx_order,
|
||||
const GeometryConfig& geom, const MotionConfig& mot) {
|
||||
// Аппаратный порядок: сперва tx_order (run.combos), затем прочие ключи окон.
|
||||
std::vector<int> hw_order;
|
||||
for (int tx : tx_order)
|
||||
if (tx_windows_s.count(tx) && std::find(hw_order.begin(), hw_order.end(), tx) == hw_order.end())
|
||||
hw_order.push_back(tx);
|
||||
for (auto& [tx, _] : tx_windows_s)
|
||||
if (std::find(hw_order.begin(), hw_order.end(), tx) == hw_order.end())
|
||||
hw_order.push_back(tx);
|
||||
|
||||
// Tx с одинаковым окном — один сегмент; сегменты по возрастанию окна,
|
||||
// слот — порядок появления в hw_order (порт группировки ноутбука).
|
||||
std::vector<std::pair<std::pair<double, double>, std::vector<int>>> groups;
|
||||
for (int tx : hw_order) {
|
||||
auto win = tx_windows_s.at(tx);
|
||||
auto found = std::find_if(groups.begin(), groups.end(),
|
||||
[&](const auto& g) { return g.first == win; });
|
||||
if (found == groups.end())
|
||||
groups.push_back({win, {tx}});
|
||||
else
|
||||
found->second.push_back(tx);
|
||||
}
|
||||
std::sort(groups.begin(), groups.end(),
|
||||
[](const auto& a, const auto& b) { return a.first < b.first; });
|
||||
|
||||
struct SegSlot { double t0, t1; int slot, n_slots; };
|
||||
std::map<int, SegSlot> seg_of;
|
||||
for (auto& [win, txs] : groups)
|
||||
for (size_t slot = 0; slot < txs.size(); ++slot)
|
||||
seg_of[txs[slot]] = {win.first, win.second, (int)slot, (int)txs.size()};
|
||||
|
||||
double cos_theta = std::cos(mot.look_angle_deg * M_PI / 180.0);
|
||||
double t_ref = 0.5 * acq_s;
|
||||
PairTimestamps ts;
|
||||
for (int i = 0; i < geom.n_tx; ++i) {
|
||||
auto it = seg_of.find(geom.output_idx[i]);
|
||||
if (it == seg_of.end())
|
||||
throw std::runtime_error("pair timestamps: нет окна для Tx");
|
||||
const SegSlot& s = it->second;
|
||||
PairTiming timing;
|
||||
timing.tx_interleave_slot = s.slot;
|
||||
timing.tx_interleave_slots = s.n_slots;
|
||||
timing.segment_start_s = s.t0;
|
||||
timing.segment_time_s = s.t1 - s.t0;
|
||||
timing.t_center_s = 0.5 * (s.t0 + s.t1);
|
||||
double dz = mot.direction_sign * mot.speed_m_s * (timing.t_center_s - t_ref) * cos_theta;
|
||||
timing.dtau_pair_s = 2.0 * dz / geom.v;
|
||||
for (int j = 0; j < geom.n_rx; ++j) ts[{i, j}] = timing;
|
||||
}
|
||||
return ts;
|
||||
}
|
||||
|
||||
static double median_of_diff(const std::vector<double>& f) {
|
||||
std::vector<double> d(f.size() - 1);
|
||||
for (size_t i = 0; i + 1 < f.size(); ++i) d[i] = f[i + 1] - f[i];
|
||||
std::sort(d.begin(), d.end());
|
||||
size_t n = d.size();
|
||||
return (n % 2) ? d[n / 2] : 0.5 * (d[n / 2 - 1] + d[n / 2]);
|
||||
}
|
||||
|
||||
std::vector<c128> apply_intra_sweep_phase_correction(
|
||||
const std::vector<c128>& s21, const std::vector<double>& freq_full,
|
||||
const PairTiming& info, const MotionConfig& cfg, double wave_speed,
|
||||
double f_start, double f_stop) {
|
||||
std::vector<c128> out = s21;
|
||||
int n_full = (int)freq_full.size();
|
||||
// индексы в полосе
|
||||
std::vector<int> idx;
|
||||
for (int i = 0; i < n_full; ++i)
|
||||
if (freq_full[i] >= f_start && freq_full[i] <= f_stop) idx.push_back(i);
|
||||
if (idx.empty()) throw std::runtime_error("phase corr: пустая полоса");
|
||||
if (n_full < 2) return out;
|
||||
|
||||
// Сегмент проходит все n_full частот за своё окно; на каждой частоте по
|
||||
// очереди работают его n_slots передатчиков, поэтому событие частоты i для
|
||||
// слота slot имеет номер (n_slots*i + slot) из (n_slots*n_full).
|
||||
int n_slots = std::max(1, info.tx_interleave_slots);
|
||||
int slot = info.tx_interleave_slot;
|
||||
double dt_event = info.segment_time_s / ((double)n_slots * n_full);
|
||||
double cos_theta = std::cos(cfg.look_angle_deg * M_PI / 180.0);
|
||||
double pair_dtau = cfg.apply_pair_motion_correction ? info.dtau_pair_s : 0.0;
|
||||
if (!cfg.apply_freq_phase_correction && pair_dtau == 0.0) return out;
|
||||
for (int i : idx) {
|
||||
double dtau_intra = 0.0;
|
||||
if (cfg.apply_freq_phase_correction) {
|
||||
double t_abs = info.segment_start_s + (double)(n_slots * i + slot) * dt_event;
|
||||
double dt_intra = t_abs - info.t_center_s;
|
||||
dtau_intra = 2.0 * cfg.direction_sign * cfg.speed_m_s * cos_theta * dt_intra / wave_speed;
|
||||
}
|
||||
double phi = 2.0 * M_PI * freq_full[i] * (dtau_intra + pair_dtau);
|
||||
double ang = cfg.motion_phase_sign * phi;
|
||||
out[i] *= c128(std::cos(ang), std::sin(ang));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
static int next_pow2(int x) {
|
||||
int p = 1;
|
||||
while (p < x) p <<= 1;
|
||||
return p;
|
||||
}
|
||||
|
||||
void compute_ascan_bp(const std::vector<c128>& s21, const std::vector<double>& freq,
|
||||
double f_start, double f_stop, bool window, int oversample,
|
||||
std::vector<double>& t_out, std::vector<c128>& h_out) {
|
||||
std::vector<double> freq_cut;
|
||||
std::vector<c128> s_cut;
|
||||
for (size_t i = 0; i < freq.size(); ++i)
|
||||
if (freq[i] >= f_start && freq[i] <= f_stop) {
|
||||
freq_cut.push_back(freq[i]);
|
||||
s_cut.push_back(s21[i]);
|
||||
}
|
||||
if (freq_cut.size() < 2) throw std::runtime_error("ascan: <2 точек в полосе");
|
||||
|
||||
double df = median_of_diff(freq_cut);
|
||||
int n = (int)freq_cut.size();
|
||||
int k0 = (int)std::nearbyint(freq_cut[0] / df); // round-half-to-even как python round
|
||||
int min_len = 2 * (k0 + n - 1);
|
||||
int n_fft_base = next_pow2(min_len);
|
||||
int n_fft = n_fft_base * oversample;
|
||||
double dt = 1.0 / ((double)n_fft * df);
|
||||
|
||||
// окно Ханна (numpy np.hanning: 0.5 - 0.5*cos(2*pi*i/(N-1)))
|
||||
std::vector<c128> H(n_fft, c128(0.0, 0.0));
|
||||
for (int i = 0; i < n; ++i) {
|
||||
c128 v = s_cut[i];
|
||||
if (window) {
|
||||
double w = 0.5 - 0.5 * std::cos(2.0 * M_PI * i / (n - 1));
|
||||
v *= w;
|
||||
}
|
||||
H[k0 + i] = v;
|
||||
}
|
||||
|
||||
// IFFT через pocketfft (backward + 1/n_fft == numpy ifft)
|
||||
h_out.assign(n_fft, c128(0.0, 0.0));
|
||||
pocketfft::shape_t shape{(size_t)n_fft};
|
||||
pocketfft::stride_t stride{(ptrdiff_t)sizeof(c128)};
|
||||
pocketfft::c2c(shape, stride, stride, {0}, pocketfft::BACKWARD,
|
||||
H.data(), h_out.data(), (double)(1.0 / n_fft));
|
||||
|
||||
t_out.resize(n_fft);
|
||||
for (int i = 0; i < n_fft; ++i) t_out[i] = i * dt;
|
||||
}
|
||||
|
||||
double pair_norm_scale(const std::vector<c128>& h, const std::vector<double>& t,
|
||||
double wave_speed, const BPConfig& bp) {
|
||||
std::vector<double> vals;
|
||||
vals.reserve(h.size());
|
||||
for (size_t i = 0; i < h.size(); ++i) {
|
||||
double z = t[i] * wave_speed / 2.0;
|
||||
if (z >= bp.pair_norm_depth_min && z <= bp.pair_norm_depth_max)
|
||||
vals.push_back(std::abs(h[i]));
|
||||
}
|
||||
if (vals.empty())
|
||||
for (auto& c : h) vals.push_back(std::abs(c));
|
||||
std::sort(vals.begin(), vals.end());
|
||||
// numpy.percentile linear interpolation
|
||||
double q = bp.pair_norm_percentile / 100.0;
|
||||
double pos = q * (vals.size() - 1);
|
||||
int lo = (int)std::floor(pos);
|
||||
int hi = std::min(lo + 1, (int)vals.size() - 1);
|
||||
double scale = vals[lo] + (pos - lo) * (vals[hi] - vals[lo]);
|
||||
if (!std::isfinite(scale) || scale <= bp.pair_norm_eps) scale = 1.0;
|
||||
return scale;
|
||||
}
|
||||
|
||||
AScanSet preprocess_snapshot(
|
||||
const std::map<PairKey, std::vector<c128>>& s21,
|
||||
const std::map<PairKey, std::vector<double>>& freq,
|
||||
const std::map<PairKey, std::vector<c128>>* background,
|
||||
const MotionConfig& mot, const GeometryConfig& geom, const BPConfig& bp,
|
||||
const PairTimestamps& ts, bool normalize) {
|
||||
AScanSet out;
|
||||
out.pairs = geom.pair_keys();
|
||||
out.n_pairs = (int)out.pairs.size();
|
||||
|
||||
std::vector<std::vector<c128>> Hs;
|
||||
std::vector<std::vector<double>> Ts;
|
||||
for (auto& key : out.pairs) {
|
||||
auto its = s21.find(key);
|
||||
if (its == s21.end()) throw std::runtime_error("preprocess: нет пары в s21");
|
||||
std::vector<c128> s = its->second; // копия
|
||||
if (background) {
|
||||
auto ib = background->find(key);
|
||||
if (ib != background->end())
|
||||
for (size_t i = 0; i < s.size(); ++i) s[i] -= ib->second[i];
|
||||
}
|
||||
const std::vector<double>& fr = freq.at(key);
|
||||
auto itt = ts.find(key);
|
||||
if (itt != ts.end())
|
||||
s = apply_intra_sweep_phase_correction(s, fr, itt->second, mot, geom.v,
|
||||
bp.f_start, bp.f_stop);
|
||||
std::vector<double> t;
|
||||
std::vector<c128> h;
|
||||
compute_ascan_bp(s, fr, bp.f_start, bp.f_stop, bp.window, bp.oversample, t, h);
|
||||
Hs.push_back(std::move(h));
|
||||
Ts.push_back(std::move(t));
|
||||
}
|
||||
|
||||
// общая ось времени — проверка
|
||||
int n_fft = (int)Ts[0].size();
|
||||
double t0 = Ts[0][0];
|
||||
double dt = (Ts[0].back() - t0) / (n_fft - 1);
|
||||
for (auto& t : Ts)
|
||||
if ((int)t.size() != n_fft || std::abs(t[0] - t0) > 1e-18)
|
||||
throw std::runtime_error("A-сканы: разная ось времени у пар");
|
||||
|
||||
if (normalize) {
|
||||
for (int p = 0; p < out.n_pairs; ++p) {
|
||||
double scale = pair_norm_scale(Hs[p], Ts[p], geom.v, bp);
|
||||
for (auto& c : Hs[p]) c /= (scale + bp.pair_norm_eps);
|
||||
}
|
||||
}
|
||||
|
||||
out.n_fft = n_fft;
|
||||
out.t0 = t0;
|
||||
out.dt = dt;
|
||||
out.H.resize((size_t)out.n_pairs * n_fft);
|
||||
for (int p = 0; p < out.n_pairs; ++p)
|
||||
std::copy(Hs[p].begin(), Hs[p].end(), out.H.begin() + (size_t)p * n_fft);
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace gpr
|
||||
@@ -1,34 +0,0 @@
|
||||
#include "gpr/device.hpp"
|
||||
|
||||
#include <cstdio>
|
||||
|
||||
namespace gpr {
|
||||
|
||||
void cu_check(CUresult r, const char* what) {
|
||||
if (r != CUDA_SUCCESS) {
|
||||
const char* s = nullptr;
|
||||
cuGetErrorString(r, &s);
|
||||
throw std::runtime_error(std::string("CUDA error in ") + what + ": " + (s ? s : "?"));
|
||||
}
|
||||
}
|
||||
|
||||
CudaContext::CudaContext() {
|
||||
cu_check(cuInit(0), "cuInit");
|
||||
cu_check(cuDeviceGet(&dev, 0), "cuDeviceGet");
|
||||
cu_check(cuDeviceGetAttribute(&cc_major, CU_DEVICE_ATTRIBUTE_COMPUTE_CAPABILITY_MAJOR, dev),
|
||||
"cc_major");
|
||||
cu_check(cuDeviceGetAttribute(&cc_minor, CU_DEVICE_ATTRIBUTE_COMPUTE_CAPABILITY_MINOR, dev),
|
||||
"cc_minor");
|
||||
cu_check(cuDevicePrimaryCtxRetain(&ctx, dev), "primaryCtxRetain");
|
||||
cu_check(cuCtxSetCurrent(ctx), "ctxSetCurrent");
|
||||
}
|
||||
|
||||
CudaContext::~CudaContext() {
|
||||
if (ctx) cuDevicePrimaryCtxRelease(dev);
|
||||
}
|
||||
|
||||
std::string CudaContext::arch_flag() const {
|
||||
return "--gpu-architecture=sm_" + std::to_string(cc_major) + std::to_string(cc_minor);
|
||||
}
|
||||
|
||||
} // namespace gpr
|
||||
@@ -1,104 +0,0 @@
|
||||
#include "gpr/geometry.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
namespace gpr {
|
||||
|
||||
double pair_geo_ref(const GeometryConfig& g, const BPConfig& bp, int i_tx, int i_rx, double yy) {
|
||||
double xc = (g.x_tx[i_tx] + g.x_rx[i_rx]) / 2.0;
|
||||
double Rtx = std::sqrt(std::pow(xc - g.x_tx[i_tx], 2) + std::pow(yy - g.y_tx[i_tx], 2) +
|
||||
std::pow(bp.comp_ref_depth - g.z_tx[i_tx], 2));
|
||||
double Rrx = std::sqrt(std::pow(xc - g.x_rx[i_rx], 2) + std::pow(yy - g.y_rx[i_rx], 2) +
|
||||
std::pow(bp.comp_ref_depth - g.z_rx[i_rx], 2));
|
||||
return 1.0 / (Rtx * Rrx + 1e-12) + 1e-30;
|
||||
}
|
||||
|
||||
std::vector<FrameOffset> frame_offsets(const std::vector<double>& frame_times, double t_ref,
|
||||
const MotionConfig& cfg) {
|
||||
std::vector<FrameOffset> out(frame_times.size());
|
||||
double pitch = cfg.pitch_deg * M_PI / 180.0;
|
||||
for (size_t k = 0; k < frame_times.size(); ++k) {
|
||||
double t = frame_times[k] - t_ref;
|
||||
double h = cfg.direction_sign * cfg.speed_m_s * t;
|
||||
out[k].dx_m = 0.0;
|
||||
out[k].dy_m = h * std::sin(pitch);
|
||||
out[k].dz_m = h * std::cos(pitch);
|
||||
out[k].dt_s = t;
|
||||
out[k].horizontal_m = h;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::vector<double> frame_weights(const std::vector<FrameOffset>& offs, double edge_weight) {
|
||||
size_t n = offs.size();
|
||||
std::vector<double> w(n, 1.0);
|
||||
double edge = std::min(std::max(edge_weight, 0.0), 1.0);
|
||||
if (n == 0 || edge >= 1.0) return w;
|
||||
double dmax = 0.0;
|
||||
for (auto& o : offs) dmax = std::max(dmax, std::abs(o.dt_s));
|
||||
if (dmax <= 1e-12) return w;
|
||||
for (size_t i = 0; i < n; ++i) {
|
||||
double r = std::min(std::abs(offs[i].dt_s) / dmax, 1.0);
|
||||
w[i] = edge + (1.0 - edge) * 0.5 * (1.0 + std::cos(M_PI * r));
|
||||
}
|
||||
return w;
|
||||
}
|
||||
|
||||
std::array<double, 3> point_to_scene(double x, double y, double z, const MotionConfig& mot,
|
||||
const SARConfig& sar) {
|
||||
if (!sar.coord_mode_ground) return {x, y, z};
|
||||
double p = mot.pitch_deg * M_PI / 180.0;
|
||||
double ys = y * std::cos(p) - z * std::sin(p) + sar.ground_depth_m;
|
||||
double zs = y * std::sin(p) + z * std::cos(p);
|
||||
return {x, ys, zs};
|
||||
}
|
||||
|
||||
double ground_plane_y(double depth, double ground_depth_m, double pitch_rad) {
|
||||
return (-ground_depth_m + depth * std::sin(pitch_rad)) / std::cos(pitch_rad);
|
||||
}
|
||||
|
||||
Grid build_full_region_grid(const SARConfig& sar) {
|
||||
// numpy: linspace(lo, hi, max(round(span/step)+1, 2)); nearbyint = банковское
|
||||
// округление python round()
|
||||
auto axis = [](double lo, double hi, double step, std::vector<double>& out) {
|
||||
int n = std::max((int)std::nearbyint((hi - lo) / step) + 1, 2);
|
||||
out.resize(n);
|
||||
for (int i = 0; i < n; ++i) out[i] = lo + (hi - lo) * i / (double)(n - 1);
|
||||
};
|
||||
Grid g;
|
||||
axis(sar.x_min_m, sar.x_max_m, sar.voxel_m[0], g.x);
|
||||
axis(sar.y_min_m, sar.y_max_m, sar.voxel_m[1], g.y);
|
||||
axis(sar.z_min_m, sar.z_max_m, sar.voxel_m[2], g.z);
|
||||
return g;
|
||||
}
|
||||
|
||||
void notebook_grid_bounds(const GeometryConfig& geom, const BPConfig& bp, const SARConfig& sar,
|
||||
double pitch_deg, double x_margin_m,
|
||||
double& x_min, double& x_max, double& z_min, double& z_max) {
|
||||
double x_lo = geom.x_tx[0], x_hi = geom.x_tx[0];
|
||||
for (double x : geom.x_tx) { x_lo = std::min(x_lo, x); x_hi = std::max(x_hi, x); }
|
||||
for (double x : geom.x_rx) { x_lo = std::min(x_lo, x); x_hi = std::max(x_hi, x); }
|
||||
x_min = x_lo - x_margin_m;
|
||||
x_max = x_hi + x_margin_m;
|
||||
|
||||
// Радарная z линейна по Z сцены при фиксированной высоте:
|
||||
// z_rad(Y, Z=0) = -(Y - ground_depth)*sin(p), z = (depth - z_rad)/cos(p).
|
||||
double p = pitch_deg * M_PI / 180.0;
|
||||
double cos_p = std::cos(p), sin_p = std::sin(p);
|
||||
double z_lo = 0.0, z_hi = 0.0;
|
||||
bool first = true;
|
||||
for (double y : {sar.y_min_m, sar.y_max_m}) {
|
||||
for (double depth : {bp.min_depth, bp.max_depth}) {
|
||||
double z_rad0 = -(y - sar.ground_depth_m) * sin_p;
|
||||
double z = (depth - z_rad0) / cos_p;
|
||||
if (first) { z_lo = z_hi = z; first = false; }
|
||||
z_lo = std::min(z_lo, z);
|
||||
z_hi = std::max(z_hi, z);
|
||||
}
|
||||
}
|
||||
z_min = z_lo;
|
||||
z_max = z_hi;
|
||||
}
|
||||
|
||||
} // namespace gpr
|
||||
@@ -1,83 +0,0 @@
|
||||
// Исходник CUDA-ядра для NVRTC (порт sar_frame_contribution из
|
||||
// Multi_antenna_3D_clean_v1.ipynb). Self-contained complex<float>.
|
||||
//
|
||||
// Геометрия (координаты, дальности, tau, вес компенсации) считается в double:
|
||||
// эталон ноутбука — float64, а в float32 позиция выборки A-скана (тысячи
|
||||
// отсчётов) теряет ~3e-4 отсчёта и расходится с эталоном на ~1e-4. A-сканы
|
||||
// остаются float32 — их значения O(1) и линейная интерполяция не теряет.
|
||||
#pragma once
|
||||
|
||||
namespace gpr {
|
||||
|
||||
inline const char* APERTURE_KERNEL_SRC = R"CUDA(
|
||||
struct cf { float x, y; };
|
||||
|
||||
// Взвешенный объём апертуры: один поток на воксель, внутренний цикл по
|
||||
// n_contrib = n_frames*n_pairs. Обрабатывается диапазон вокселей
|
||||
// [gid_begin, gid_end) — хост запускает ядро чанками, переиспользуя небольшие
|
||||
// acc/cnt-буферы (полный объём в VRAM не обязан помещаться: на десктопе с
|
||||
// занятой другими процессами картой свободных может быть <200 МБ).
|
||||
extern "C" __global__ void bp_aperture_contribution(
|
||||
const cf* __restrict__ H,
|
||||
const double t0, const double inv_dt, const int n_fft, const double inv_v,
|
||||
const double* __restrict__ xt, const double* __restrict__ yt, const double* __restrict__ zt,
|
||||
const double* __restrict__ xr, const double* __restrict__ yr, const double* __restrict__ zr,
|
||||
const double* __restrict__ inv_geo_ref, const float* __restrict__ wgt,
|
||||
const double* __restrict__ gx, const double* __restrict__ gy, const double* __restrict__ gz,
|
||||
const int NX, const int NY, const int NZ, const int n_contrib,
|
||||
const long gid_begin, const long gid_end,
|
||||
const double cos_p, const double sin_p, const double ground_depth,
|
||||
const double min_depth, const double max_depth,
|
||||
const double comp_pow, const double w_max, const int use_comp,
|
||||
cf* __restrict__ acc, float* __restrict__ cnt)
|
||||
{
|
||||
for (long gid = gid_begin + blockIdx.x * blockDim.x + threadIdx.x;
|
||||
gid < gid_end; gid += (long)gridDim.x * blockDim.x) {
|
||||
int ix = gid % NX;
|
||||
int iz = (gid / NX) % NZ;
|
||||
int iy = gid / ((long)NX * NZ);
|
||||
double x = gx[ix];
|
||||
double zs = gz[iz];
|
||||
// Сцена → оси радара: Y=0 сцены — земля, ground_depth ниже нижнего ряда
|
||||
// антенн (порт sar_scene_to_radar_yz).
|
||||
double y0 = gy[iy] - ground_depth;
|
||||
double y_rad = y0 * cos_p + zs * sin_p;
|
||||
double z_rad = -y0 * sin_p + zs * cos_p;
|
||||
bool depth_ok = (z_rad >= min_depth) && (z_rad <= max_depth);
|
||||
|
||||
double ax = 0.0, ay = 0.0;
|
||||
float c = 0.f;
|
||||
for (int i = 0; i < n_contrib; ++i) {
|
||||
double dxt = x - xt[i], dyt = y_rad - yt[i], dzt = z_rad - zt[i];
|
||||
double dxr = x - xr[i], dyr = y_rad - yr[i], dzr = z_rad - zr[i];
|
||||
double Rt = sqrt(dxt*dxt + dyt*dyt + dzt*dzt);
|
||||
double Rr = sqrt(dxr*dxr + dyr*dyr + dzr*dzr);
|
||||
double pos = ((Rt + Rr) * inv_v - t0) * inv_dt;
|
||||
bool inside = (pos >= 0.0) && (pos <= (double)(n_fft - 1));
|
||||
if (!inside || !depth_ok) continue;
|
||||
int k = (int)floor(pos);
|
||||
if (k < 0) k = 0;
|
||||
if (k > n_fft - 2) k = n_fft - 2;
|
||||
float f = (float)(pos - (double)k);
|
||||
const cf* h = H + (long)i * n_fft;
|
||||
float hx = h[k].x + f * (h[k+1].x - h[k].x);
|
||||
float hy = h[k].y + f * (h[k+1].y - h[k].y);
|
||||
double w = (double)wgt[i];
|
||||
if (use_comp) {
|
||||
double ratio = (1.0/(Rt*Rr + 1e-12) + 1e-30) * inv_geo_ref[i];
|
||||
double ww = 1.0/(pow(ratio, comp_pow) + 1e-12);
|
||||
ww = fmin(fmax(ww, 0.0), w_max);
|
||||
w *= ww;
|
||||
}
|
||||
ax += w * (double)hx;
|
||||
ay += w * (double)hy;
|
||||
c += wgt[i];
|
||||
}
|
||||
acc[gid - gid_begin].x = (float)ax;
|
||||
acc[gid - gid_begin].y = (float)ay;
|
||||
cnt[gid - gid_begin] = c;
|
||||
}
|
||||
}
|
||||
)CUDA";
|
||||
|
||||
} // namespace gpr
|
||||
@@ -1,48 +0,0 @@
|
||||
#include "gpr/nvrtc_kernel.hpp"
|
||||
|
||||
#include <nvrtc.h>
|
||||
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "gpr/device.hpp"
|
||||
|
||||
namespace gpr {
|
||||
|
||||
static void nvrtc_check(nvrtcResult r, const char* what) {
|
||||
if (r != NVRTC_SUCCESS)
|
||||
throw std::runtime_error(std::string("NVRTC error in ") + what + ": " +
|
||||
nvrtcGetErrorString(r));
|
||||
}
|
||||
|
||||
NvrtcKernel::~NvrtcKernel() {
|
||||
if (module_) cuModuleUnload(module_);
|
||||
}
|
||||
|
||||
void NvrtcKernel::compile(const char* src, const std::string& arch_flag, const char* func_name) {
|
||||
nvrtcProgram prog;
|
||||
nvrtc_check(nvrtcCreateProgram(&prog, src, "gpr_kernel.cu", 0, nullptr, nullptr),
|
||||
"createProgram");
|
||||
std::vector<const char*> opts{arch_flag.c_str()};
|
||||
nvrtcResult cr = nvrtcCompileProgram(prog, (int)opts.size(), opts.data());
|
||||
if (cr != NVRTC_SUCCESS) {
|
||||
size_t logsz = 0;
|
||||
nvrtcGetProgramLogSize(prog, &logsz);
|
||||
std::string log(logsz, '\0');
|
||||
if (logsz) nvrtcGetProgramLog(prog, &log[0]);
|
||||
nvrtcDestroyProgram(&prog);
|
||||
throw std::runtime_error("NVRTC compile failed:\n" + log);
|
||||
}
|
||||
// cubin под точную арх (без JIT PTX — драйвер грузит SASS напрямую)
|
||||
size_t cubsz = 0;
|
||||
nvrtc_check(nvrtcGetCUBINSize(prog, &cubsz), "getCUBINSize");
|
||||
std::string cubin(cubsz, '\0');
|
||||
nvrtc_check(nvrtcGetCUBIN(prog, &cubin[0]), "getCUBIN");
|
||||
nvrtcDestroyProgram(&prog);
|
||||
|
||||
cu_check(cuModuleLoadDataEx(&module_, cubin.data(), 0, nullptr, nullptr), "moduleLoad");
|
||||
cu_check(cuModuleGetFunction(&func_, module_, func_name), "getFunction");
|
||||
}
|
||||
|
||||
} // namespace gpr
|
||||
@@ -1,233 +0,0 @@
|
||||
#include "gpr/sar3d.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <stdexcept>
|
||||
|
||||
#include "kernel_src.hpp"
|
||||
|
||||
namespace gpr {
|
||||
|
||||
SarEngine::SarEngine(const GeometryConfig& g, const BPConfig& bp, const MotionConfig& mot,
|
||||
const SARConfig& sar)
|
||||
: geom_(g), bp_(bp), mot_(mot), sar_(sar) {
|
||||
geom_.finalize();
|
||||
ctx_ = std::make_unique<CudaContext>();
|
||||
kernel_ = std::make_unique<NvrtcKernel>();
|
||||
kernel_->compile(APERTURE_KERNEL_SRC, ctx_->arch_flag(), "bp_aperture_contribution");
|
||||
}
|
||||
|
||||
VolumeRaw SarEngine::aperture_volume(const std::vector<ApertureFrame>& frames, const Grid& grid) {
|
||||
if (frames.empty()) throw std::runtime_error("aperture_volume: нет кадров");
|
||||
auto pairs = geom_.pair_keys();
|
||||
int n_pairs = (int)pairs.size();
|
||||
int n_frames = (int)frames.size();
|
||||
int n_contrib = n_frames * n_pairs;
|
||||
|
||||
const AScanSet& a0 = *frames[0].ascans;
|
||||
int n_fft = a0.n_fft;
|
||||
double t0 = a0.t0, dt = a0.dt;
|
||||
|
||||
int NX = grid.NX(), NY = grid.NY(), NZ = grid.NZ();
|
||||
size_t nvox = (size_t)NX * NY * NZ;
|
||||
|
||||
double pitch = mot_.pitch_deg * M_PI / 180.0;
|
||||
// Опорное расхождение пары — на плоскости земли на глубине comp_ref_depth.
|
||||
double geo_ref_y = sar_.coord_mode_ground
|
||||
? ground_plane_y(bp_.comp_ref_depth, sar_.ground_depth_m, pitch)
|
||||
: 0.0;
|
||||
|
||||
// Плоские массивы по (кадр, пара)
|
||||
std::vector<c64> H_stack((size_t)n_contrib * n_fft);
|
||||
std::vector<double> xt(n_contrib), yt(n_contrib), zt(n_contrib);
|
||||
std::vector<double> xr(n_contrib), yr(n_contrib), zr(n_contrib);
|
||||
std::vector<double> igr(n_contrib);
|
||||
std::vector<float> wgt(n_contrib);
|
||||
int idx = 0;
|
||||
for (int f = 0; f < n_frames; ++f) {
|
||||
const AScanSet& a = *frames[f].ascans;
|
||||
if (a.n_fft != n_fft || std::abs(a.t0 - t0) > 1e-18)
|
||||
throw std::runtime_error("кадры апертуры: разная ось времени");
|
||||
double dx = frames[f].offset.dx_m, dy = frames[f].offset.dy_m, dz = frames[f].offset.dz_m;
|
||||
float w = (float)frames[f].weight;
|
||||
for (int p = 0; p < n_pairs; ++p) {
|
||||
int i = pairs[p].first, j = pairs[p].second;
|
||||
// A-скан пары p → в стек (каст c128→c64)
|
||||
const c128* src = a.pair_ptr(p);
|
||||
c64* dst = H_stack.data() + (size_t)idx * n_fft;
|
||||
for (int k = 0; k < n_fft; ++k) dst[k] = c64((float)src[k].real(), (float)src[k].imag());
|
||||
xt[idx] = geom_.x_tx[i] + dx; yt[idx] = geom_.y_tx[i] + dy;
|
||||
zt[idx] = geom_.z_tx[i] + dz;
|
||||
xr[idx] = geom_.x_rx[j] + dx; yr[idx] = geom_.y_rx[j] + dy;
|
||||
zr[idx] = geom_.z_rx[j] + dz;
|
||||
igr[idx] = 1.0 / pair_geo_ref(geom_, bp_, i, j, geo_ref_y);
|
||||
wgt[idx] = w;
|
||||
++idx;
|
||||
}
|
||||
}
|
||||
|
||||
double cos_p = 1.0, sin_p = 0.0, ground_depth = 0.0;
|
||||
if (sar_.coord_mode_ground) {
|
||||
cos_p = std::cos(pitch);
|
||||
sin_p = std::sin(pitch);
|
||||
ground_depth = sar_.ground_depth_m;
|
||||
}
|
||||
|
||||
// Заливка на устройство
|
||||
DeviceBuffer<c64> dH(H_stack.size()); dH.upload(H_stack.data(), H_stack.size());
|
||||
DeviceBuffer<double> dxt(n_contrib), dyt(n_contrib), dzt(n_contrib);
|
||||
DeviceBuffer<double> dxr(n_contrib), dyr(n_contrib), dzr(n_contrib);
|
||||
DeviceBuffer<double> digr(n_contrib);
|
||||
DeviceBuffer<float> dwgt(n_contrib);
|
||||
dxt.upload(xt.data(), n_contrib); dyt.upload(yt.data(), n_contrib); dzt.upload(zt.data(), n_contrib);
|
||||
dxr.upload(xr.data(), n_contrib); dyr.upload(yr.data(), n_contrib); dzr.upload(zr.data(), n_contrib);
|
||||
digr.upload(igr.data(), n_contrib); dwgt.upload(wgt.data(), n_contrib);
|
||||
DeviceBuffer<double> dgx(NX), dgy(NY), dgz(NZ);
|
||||
dgx.upload(grid.x.data(), NX); dgy.upload(grid.y.data(), NY); dgz.upload(grid.z.data(), NZ);
|
||||
|
||||
// Объём считается чанками с переиспользуемыми acc/cnt-буферами: полные
|
||||
// буферы (~115 МБ на сетку 9.6 млн вокселей) могут не влезать в остаток
|
||||
// VRAM, когда карту делят десктоп-процессы.
|
||||
const size_t chunk_vox = std::min<size_t>(nvox, 2'000'000);
|
||||
DeviceBuffer<c64> dacc(chunk_vox);
|
||||
DeviceBuffer<float> dcnt(chunk_vox);
|
||||
|
||||
double inv_dt = 1.0 / dt, inv_v = 1.0 / geom_.v;
|
||||
double min_d = bp_.min_depth, max_d = bp_.max_depth;
|
||||
double comp_pow = bp_.comp_range_power, w_max = bp_.comp_weight_max;
|
||||
int use_comp = sar_.use_compensation ? 1 : 0;
|
||||
|
||||
VolumeRaw out;
|
||||
out.NX = NX; out.NY = NY; out.NZ = NZ;
|
||||
out.acc.resize(nvox); out.cnt.resize(nvox);
|
||||
int block = 256;
|
||||
for (size_t begin = 0; begin < nvox; begin += chunk_vox) {
|
||||
const size_t end = std::min(nvox, begin + chunk_vox);
|
||||
long gid_begin = (long)begin, gid_end = (long)end;
|
||||
void* args[] = {
|
||||
dH.addr(), &t0, &inv_dt, &n_fft, &inv_v,
|
||||
dxt.addr(), dyt.addr(), dzt.addr(), dxr.addr(), dyr.addr(), dzr.addr(),
|
||||
digr.addr(), dwgt.addr(), dgx.addr(), dgy.addr(), dgz.addr(),
|
||||
&NX, &NY, &NZ, &n_contrib, &gid_begin, &gid_end,
|
||||
&cos_p, &sin_p, &ground_depth, &min_d, &max_d,
|
||||
&comp_pow, &w_max, &use_comp, dacc.addr(), dcnt.addr()};
|
||||
int gridDim = (int)std::min<long>(65535, ((long)(end - begin) + block - 1) / block);
|
||||
cu_check(cuLaunchKernel(kernel_->func(), gridDim, 1, 1, block, 1, 1, 0, 0, args, nullptr),
|
||||
"launch aperture");
|
||||
cu_check(cuCtxSynchronize(), "sync");
|
||||
dacc.download(out.acc.data() + begin, end - begin);
|
||||
dcnt.download(out.cnt.data() + begin, end - begin);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
// scipy.ndimage.gaussian_filter: ядро радиуса int(truncate*sigma+0.5), reflect.
|
||||
std::vector<double> gaussian_kernel_1d(double sigma, int radius) {
|
||||
std::vector<double> w(2 * radius + 1);
|
||||
double sum = 0.0;
|
||||
for (int i = -radius; i <= radius; ++i) {
|
||||
w[i + radius] = std::exp(-0.5 * i * i / (sigma * sigma));
|
||||
sum += w[i + radius];
|
||||
}
|
||||
for (auto& v : w) v /= sum;
|
||||
return w;
|
||||
}
|
||||
|
||||
// Корреляция вдоль оси с reflect-границами ('d c b a | a b c d').
|
||||
inline int reflect_index(int i, int n) {
|
||||
while (i < 0 || i >= n) {
|
||||
if (i < 0) i = -i - 1;
|
||||
if (i >= n) i = 2 * n - i - 1;
|
||||
}
|
||||
return i;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
FinishedVolume finish_volume(const VolumeRaw& raw, const Grid& grid, double pitch_deg,
|
||||
const BPConfig& bp, const SARConfig& sar) {
|
||||
int NX = raw.NX, NY = raw.NY, NZ = raw.NZ;
|
||||
size_t nvox = (size_t)NX * NY * NZ;
|
||||
FinishedVolume out;
|
||||
out.NX = NX; out.NY = NY; out.NZ = NZ;
|
||||
|
||||
std::vector<double> vol(nvox);
|
||||
for (size_t i = 0; i < nvox; ++i)
|
||||
vol[i] = std::abs(c128(raw.acc[i].real(), raw.acc[i].imag())) / ((double)raw.cnt[i] + 1e-12);
|
||||
|
||||
// Маска глубины в радарных осях (в ядре невалидные вкладов не получали,
|
||||
// но |0/(0+eps)| уже 0; обнуление здесь повторяет ноутбук буквально).
|
||||
if (sar.coord_mode_ground) {
|
||||
double p = pitch_deg * M_PI / 180.0;
|
||||
double cos_p = std::cos(p), sin_p = std::sin(p);
|
||||
for (int iy = 0; iy < NY; ++iy) {
|
||||
double y0 = grid.y[iy] - sar.ground_depth_m;
|
||||
for (int iz = 0; iz < NZ; ++iz) {
|
||||
double z_rad = -y0 * sin_p + grid.z[iz] * cos_p;
|
||||
if (z_rad >= bp.min_depth && z_rad <= bp.max_depth) continue;
|
||||
double* row = vol.data() + ((size_t)iy * NZ + iz) * NX;
|
||||
std::fill(row, row + NX, 0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Плоскости Y для нормировки: без верхних/нижних norm_edge_margin_m.
|
||||
std::vector<bool> ref_plane(NY, true);
|
||||
double margin = sar.norm_edge_margin_m;
|
||||
double y_span = grid.y.back() - grid.y.front();
|
||||
if (margin > 0.0 && y_span > 2.5 * margin) {
|
||||
for (int iy = 0; iy < NY; ++iy)
|
||||
ref_plane[iy] = grid.y[iy] >= grid.y.front() + margin &&
|
||||
grid.y[iy] <= grid.y.back() - margin;
|
||||
}
|
||||
auto ref_max = [&](const std::vector<double>& v) {
|
||||
double m = 0.0;
|
||||
for (int iy = 0; iy < NY; ++iy) {
|
||||
if (!ref_plane[iy]) continue;
|
||||
const double* plane = v.data() + (size_t)iy * NZ * NX;
|
||||
for (size_t i = 0; i < (size_t)NZ * NX; ++i) m = std::max(m, plane[i]);
|
||||
}
|
||||
return m + 1e-12;
|
||||
};
|
||||
|
||||
double level_raw = ref_max(vol);
|
||||
for (auto& v : vol) v /= level_raw;
|
||||
|
||||
// Гаусс sigma=(0, s, s): сглаживание по Z и X внутри каждого Y-плана
|
||||
// (порядок осей как у scipy: сначала ось 1, затем ось 2).
|
||||
if (sar.smooth_sigma > 0.0) {
|
||||
int radius = (int)(4.0 * sar.smooth_sigma + 0.5);
|
||||
const auto kernel = gaussian_kernel_1d(sar.smooth_sigma, radius);
|
||||
std::vector<double> tmp((size_t)NZ * NX);
|
||||
for (int iy = 0; iy < NY; ++iy) {
|
||||
double* plane = vol.data() + (size_t)iy * NZ * NX;
|
||||
// вдоль Z (ось 1)
|
||||
for (int iz = 0; iz < NZ; ++iz)
|
||||
for (int ix = 0; ix < NX; ++ix) {
|
||||
double s = 0.0;
|
||||
for (int k = -radius; k <= radius; ++k)
|
||||
s += kernel[k + radius] * plane[(size_t)reflect_index(iz + k, NZ) * NX + ix];
|
||||
tmp[(size_t)iz * NX + ix] = s;
|
||||
}
|
||||
// вдоль X (ось 2)
|
||||
for (int iz = 0; iz < NZ; ++iz)
|
||||
for (int ix = 0; ix < NX; ++ix) {
|
||||
double s = 0.0;
|
||||
for (int k = -radius; k <= radius; ++k)
|
||||
s += kernel[k + radius] * tmp[(size_t)iz * NX + reflect_index(ix + k, NX)];
|
||||
plane[(size_t)iz * NX + ix] = s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
double level_smooth = ref_max(vol);
|
||||
out.volume.resize(nvox);
|
||||
for (size_t i = 0; i < nvox; ++i)
|
||||
out.volume[i] = (float)std::max(vol[i] / level_smooth, 0.0);
|
||||
out.level = level_raw * level_smooth;
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace gpr
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@
|
||||
|
||||
#include <atomic>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <span>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
@@ -40,11 +41,10 @@ class DataProcessor {
|
||||
[[nodiscard]] auto resolve_processor(const ProcessingLiveConfig& live_config) -> ProcessorInterface&;
|
||||
[[nodiscard]] auto should_replay_entire_history(const ProcessingLiveConfig& live_config) const -> bool;
|
||||
|
||||
// Merge live config with the latest socket-supplied speed (if any and if
|
||||
// not suppressed by `ignore_socket_speed`). The returned config is what
|
||||
// actually drives processing for this tick.
|
||||
[[nodiscard]] auto resolve_effective_live_config(const ProcessingLiveConfig& live_config) const
|
||||
-> ProcessingLiveConfig;
|
||||
// Speed streamed by a locator client (`vlc`), which then replaces
|
||||
// `gpr_speed_m_s`; nullopt when `ignore_socket_speed` is set or no fresh
|
||||
// value exists, so the speed from the live config stays in force.
|
||||
[[nodiscard]] auto socket_speed(const ProcessingLiveConfig& live_config) const -> std::optional<double>;
|
||||
|
||||
// Convert one processed collection into a locator filter spec, derived
|
||||
// from the live config and current processor mode.
|
||||
|
||||
+8
-42
@@ -90,51 +90,17 @@ struct ProcessingLiveConfig {
|
||||
float gpr_visible_x_max_m = 2.0F;
|
||||
float gpr_visible_z_min_m = 0.0F;
|
||||
float gpr_visible_z_max_m = 14.0F;
|
||||
// 3D SAR (sar3d processor) parameters; defaults mirror the reference
|
||||
// notebook Multi_antenna_3D_clean_v1.ipynb (cells 0/4). Frequency band,
|
||||
// depth gate, antenna selection and motion (speed, look angle, direction,
|
||||
// phase correction) are shared with the gpr_* fields.
|
||||
// 3D SAR (sar3d processor) parameters, named after the reference notebook
|
||||
// Multi_antenna_3D_clean_v1.ipynb (SAR_FRAME_RADIUS, BP_GROUND_DEPTH_M, ...).
|
||||
// The band, depth gate, antenna selection, speed and look angle are the
|
||||
// shared gpr_* fields above.
|
||||
std::uint32_t sar3d_frame_radius = 0U;
|
||||
float sar3d_edge_frame_weight = 0.8F;
|
||||
// Frame pitch for ground coordinates and inter-frame offsets. Override
|
||||
// disabled -> the pitch follows gpr_look_angle_deg (notebook
|
||||
// BP_GROUND_PITCH_DEG = None).
|
||||
bool sar3d_pitch_override_enabled = false;
|
||||
float sar3d_pitch_deg = 17.5F;
|
||||
// Height of the LOWER antenna row above the ground; scene Y=0 is the ground.
|
||||
float sar3d_ground_depth_m = 1.25F;
|
||||
// Scene grid bounds (metres): X across, Y height above the ground, Z along
|
||||
// the ground away from the radar. Voxels whose radar depth falls outside
|
||||
// [gpr_min_depth, gpr_max_depth] are zeroed by the mask regardless.
|
||||
// Doubles, not floats: the parity tool feeds the notebook-derived bounds
|
||||
// through here and a float roundtrip shifts the grid by ~1e-7 m, flipping
|
||||
// knife-edge voxels of the depth mask against the reference volumes.
|
||||
double sar3d_x_min_m = -2.0;
|
||||
double sar3d_x_max_m = 2.0;
|
||||
double sar3d_y_min_m = -0.5;
|
||||
double sar3d_y_max_m = 2.0;
|
||||
double sar3d_z_min_m = 0.0;
|
||||
double sar3d_z_max_m = 8.0;
|
||||
float sar3d_voxel_x_m = 0.025F;
|
||||
float sar3d_voxel_y_m = 0.02F;
|
||||
float sar3d_voxel_z_m = 0.02F;
|
||||
// Volume finalization: normalization ignores this much of the grid's Y
|
||||
// edges (constant-range ridge), then Gaussian smoothing over (Z, X).
|
||||
float sar3d_norm_edge_margin_m = 0.3F;
|
||||
float sar3d_smooth_sigma = 1.5F;
|
||||
// Geometric spreading compensation 1/(Rtx*Rrx)^power, capped.
|
||||
// Edge of the cubic voxel: the notebook's SAR_VOXEL_M with one value for X, Y, Z.
|
||||
float sar3d_voxel_m = 0.02F;
|
||||
float sar3d_bp_ground_depth_m = 1.25F;
|
||||
float sar3d_comp_range_power = 0.3F;
|
||||
// Fallback frame segmentation when per-trace capture stamps are absent.
|
||||
std::uint32_t sar3d_tx_per_segment = 2U;
|
||||
bool sar3d_use_compensation = true;
|
||||
bool sar3d_normalize_pairs = true;
|
||||
// Debug: when non-empty, the processor dumps every finished volume to this
|
||||
// directory (raw float32 + json header). Used by the parity tool only.
|
||||
std::string sar3d_volume_dump_dir{};
|
||||
// Bottom-view projection angle around the vertical Y axis, degrees:
|
||||
// 0 = front view (X horizontal), 90 = side view (Z horizontal),
|
||||
// intermediate values project along the rotated horizontal direction.
|
||||
float sar3d_bottom_view_deg = 0.0F;
|
||||
bool sar3d_normalize_each_frame = true;
|
||||
// When true, the data_processor ignores socket-supplied `vlc` updates and
|
||||
// keeps using `gpr_speed_m_s` from this file. Mirrored from the GUI's
|
||||
// "ignore socket speed" checkbox.
|
||||
|
||||
@@ -73,19 +73,16 @@ void DataProcessor::run(const std::atomic<bool>& stop_requested) {
|
||||
|
||||
while (!stop_requested.load(std::memory_order_relaxed)) {
|
||||
try {
|
||||
const auto live_config_raw = live_config_loader_.refresh_if_needed();
|
||||
const auto live_config = resolve_effective_live_config(live_config_raw);
|
||||
// The socket speed is read once per tick: it both drives this tick's
|
||||
// processing and, when it changed, triggers a reprocess on its own,
|
||||
// even when the live revision is unchanged.
|
||||
auto live_config = live_config_loader_.refresh_if_needed();
|
||||
const std::optional<double> current_socket_speed = socket_speed(live_config);
|
||||
if (current_socket_speed.has_value()) {
|
||||
live_config.gpr_speed_m_s = static_cast<float>(*current_socket_speed);
|
||||
}
|
||||
const auto live_revision = live_config_loader_.revision();
|
||||
auto& processor = resolve_processor(live_config);
|
||||
|
||||
// The effective config folds in latest_socket_speed(); a change there is
|
||||
// a reprocess trigger on its own, even when the live revision is unchanged.
|
||||
// Mirror the gating in resolve_effective_live_config(): only consider the
|
||||
// socket speed when it is actually applied to the effective config.
|
||||
const std::optional<double> current_socket_speed =
|
||||
(live_config_raw.ignore_socket_speed || locator_server_ == nullptr)
|
||||
? std::nullopt
|
||||
: locator_server_->latest_socket_speed();
|
||||
const bool socket_speed_dirty = current_socket_speed != last_reprocessed_socket_speed;
|
||||
|
||||
if (live_revision != last_replayed_revision || socket_speed_dirty) {
|
||||
@@ -213,18 +210,11 @@ auto DataProcessor::should_replay_entire_history(const ProcessingLiveConfig& liv
|
||||
return requested_mode == "bscan";
|
||||
}
|
||||
|
||||
auto DataProcessor::resolve_effective_live_config(const ProcessingLiveConfig& live_config) const
|
||||
-> ProcessingLiveConfig {
|
||||
auto DataProcessor::socket_speed(const ProcessingLiveConfig& live_config) const -> std::optional<double> {
|
||||
if (live_config.ignore_socket_speed || locator_server_ == nullptr) {
|
||||
return live_config;
|
||||
return std::nullopt;
|
||||
}
|
||||
const auto socket_speed = locator_server_->latest_socket_speed();
|
||||
if (!socket_speed.has_value()) {
|
||||
return live_config;
|
||||
}
|
||||
ProcessingLiveConfig effective = live_config;
|
||||
effective.gpr_speed_m_s = static_cast<float>(*socket_speed);
|
||||
return effective;
|
||||
return locator_server_->latest_socket_speed();
|
||||
}
|
||||
|
||||
auto DataProcessor::build_locator_filter(const ProcessingLiveConfig& live_config) const
|
||||
|
||||
@@ -390,16 +390,9 @@ void apply_legacy_gpr_algorithm_alias(ProcessingLiveConfig& config, const std::s
|
||||
config.sar3d_frame_radius = parse_u32_number(*found, "processing.sar3d_frame_radius");
|
||||
}
|
||||
for (const auto& [key, target] : {
|
||||
std::pair{"sar3d_edge_frame_weight", &config.sar3d_edge_frame_weight},
|
||||
std::pair{"sar3d_pitch_deg", &config.sar3d_pitch_deg},
|
||||
std::pair{"sar3d_ground_depth_m", &config.sar3d_ground_depth_m},
|
||||
std::pair{"sar3d_voxel_x_m", &config.sar3d_voxel_x_m},
|
||||
std::pair{"sar3d_voxel_y_m", &config.sar3d_voxel_y_m},
|
||||
std::pair{"sar3d_voxel_z_m", &config.sar3d_voxel_z_m},
|
||||
std::pair{"sar3d_norm_edge_margin_m", &config.sar3d_norm_edge_margin_m},
|
||||
std::pair{"sar3d_smooth_sigma", &config.sar3d_smooth_sigma},
|
||||
std::pair{"sar3d_voxel_m", &config.sar3d_voxel_m},
|
||||
std::pair{"sar3d_bp_ground_depth_m", &config.sar3d_bp_ground_depth_m},
|
||||
std::pair{"sar3d_comp_range_power", &config.sar3d_comp_range_power},
|
||||
std::pair{"sar3d_bottom_view_deg", &config.sar3d_bottom_view_deg},
|
||||
}) {
|
||||
if (const auto found = root.find(key); found != root.end()) {
|
||||
if (!found->is_number()) {
|
||||
@@ -409,30 +402,8 @@ void apply_legacy_gpr_algorithm_alias(ProcessingLiveConfig& config, const std::s
|
||||
}
|
||||
}
|
||||
for (const auto& [key, target] : {
|
||||
std::pair{"sar3d_x_min_m", &config.sar3d_x_min_m},
|
||||
std::pair{"sar3d_x_max_m", &config.sar3d_x_max_m},
|
||||
std::pair{"sar3d_y_min_m", &config.sar3d_y_min_m},
|
||||
std::pair{"sar3d_y_max_m", &config.sar3d_y_max_m},
|
||||
std::pair{"sar3d_z_min_m", &config.sar3d_z_min_m},
|
||||
std::pair{"sar3d_z_max_m", &config.sar3d_z_max_m},
|
||||
}) {
|
||||
if (const auto found = root.find(key); found != root.end()) {
|
||||
if (!found->is_number()) {
|
||||
throw std::runtime_error(std::string("processing.") + key + " must be number");
|
||||
}
|
||||
*target = found->get<double>();
|
||||
}
|
||||
}
|
||||
if (const auto found = root.find("sar3d_tx_per_segment"); found != root.end()) {
|
||||
config.sar3d_tx_per_segment = parse_u32_number(*found, "processing.sar3d_tx_per_segment");
|
||||
if (config.sar3d_tx_per_segment == 0U) {
|
||||
throw std::runtime_error("processing.sar3d_tx_per_segment must be >= 1");
|
||||
}
|
||||
}
|
||||
for (const auto& [key, target] : {
|
||||
std::pair{"sar3d_pitch_override_enabled", &config.sar3d_pitch_override_enabled},
|
||||
std::pair{"sar3d_use_compensation", &config.sar3d_use_compensation},
|
||||
std::pair{"sar3d_normalize_pairs", &config.sar3d_normalize_pairs},
|
||||
std::pair{"sar3d_normalize_each_frame", &config.sar3d_normalize_each_frame},
|
||||
}) {
|
||||
if (const auto found = root.find(key); found != root.end()) {
|
||||
if (!found->is_boolean()) {
|
||||
|
||||
@@ -8,9 +8,11 @@
|
||||
|
||||
namespace radar::processing {
|
||||
|
||||
// 3D SAR over a synthesized aperture, computed by the external gpr_cpp
|
||||
// CUDA/NVRTC library (VNA_AlGo). Emits 2D max-projections of the volume
|
||||
// (side XZ and top XY views) as ImageF32 collection payloads.
|
||||
// 3D SAR of the reference notebook Multi_antenna_3D_clean_v1.ipynb on an NVIDIA
|
||||
// GPU. Publishes max-projections of the normalized volume as ImageF32 payloads:
|
||||
// sar3d_front (X-Y), sar3d_side (Z-Y), sar3d_top (X-Z) and sar3d_oblique (the
|
||||
// notebook's 3D scene view, axes in the plane of sar3d_oblique_basis), plus
|
||||
// sar3d_peak = [level, X, Y, Z] of the volume maximum.
|
||||
class Sar3dProcessor final : public ProcessorInterface {
|
||||
public:
|
||||
Sar3dProcessor();
|
||||
@@ -25,8 +27,6 @@ class Sar3dProcessor final : public ProcessorInterface {
|
||||
) -> ipc::ResultCollection override;
|
||||
|
||||
private:
|
||||
// Pimpl keeps gpr/CUDA headers (cuda.h, nvrtc.h) out of the shared
|
||||
// processor include path; only sar3d_processor.cpp needs them.
|
||||
struct State;
|
||||
std::unique_ptr<State> state_;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,471 @@
|
||||
#include "sar3d_gpu.hpp"
|
||||
|
||||
#include <stdexcept>
|
||||
|
||||
#ifdef SAR3D_WITH_CUDA
|
||||
#include <cuda.h>
|
||||
#include <nvrtc.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <climits>
|
||||
#include <cmath>
|
||||
#include <string>
|
||||
#include <type_traits>
|
||||
#endif
|
||||
|
||||
namespace radar::processing::sar3d {
|
||||
|
||||
#ifdef SAR3D_WITH_CUDA
|
||||
namespace {
|
||||
|
||||
// Compiled at run time by NVRTC for the installed GPU, so the build needs no
|
||||
// nvcc. Arithmetic is float32 apart from a few per-voxel terms: FP64 runs at
|
||||
// 1/32-1/64 of the FP32 rate on consumer GPUs, and float32 already keeps the
|
||||
// A-scan sample position to ~1e-3 of a sample of the 8x oversampled A-scan.
|
||||
// Volumes are [y][z][x] with x fastest; their sizes stay below 2^31 (checked on
|
||||
// the host), so the hot index arithmetic is 32-bit.
|
||||
constexpr const char* kKernelSource = R"CUDA(
|
||||
struct Contribution {
|
||||
float tx[3];
|
||||
float rx[3];
|
||||
float weight;
|
||||
float inv_reference_ranges;
|
||||
};
|
||||
|
||||
// numpy.fft.ifft of a band spectrum placed at bins first_bin.. of a size-N
|
||||
// spectrum (N a power of two), summed directly: the band is a few hundred
|
||||
// bins of thousands. One thread per (pair, sample); the phase index is exact.
|
||||
extern "C" __global__ void inverse_dft(
|
||||
const float2* __restrict__ bands, const int points, const int first_bin,
|
||||
const int size, const int items, float2* __restrict__ ascans)
|
||||
{
|
||||
const int item = blockIdx.x * blockDim.x + threadIdx.x;
|
||||
if (item >= items) return;
|
||||
const unsigned k = (unsigned)(item % size);
|
||||
const unsigned mask = (unsigned)size - 1u;
|
||||
const float2* band = bands + (item / size) * points;
|
||||
unsigned phase = (unsigned)(((unsigned long long)first_bin * k) & mask);
|
||||
const float turns_per_index = 2.0f / (float)size;
|
||||
float re = 0.0f, im = 0.0f;
|
||||
for (int n = 0; n < points; ++n) {
|
||||
float s, c;
|
||||
sincospif((float)phase * turns_per_index, &s, &c);
|
||||
const float2 b = band[n];
|
||||
re += b.x * c - b.y * s;
|
||||
im += b.x * s + b.y * c;
|
||||
phase = (phase + k) & mask;
|
||||
}
|
||||
ascans[item] = make_float2(re / (float)size, im / (float)size);
|
||||
}
|
||||
|
||||
// Coherent backprojection, one thread per voxel: |sum w*comp*h| / sum w over the
|
||||
// valid (frame, pair) terms (notebook sar_frame_contribution and the first line
|
||||
// of sar_finish_volume).
|
||||
extern "C" __global__ void backproject(
|
||||
const float2* __restrict__ ascans, const int samples,
|
||||
const Contribution* __restrict__ contributions, const int count,
|
||||
const double* __restrict__ gx, const double* __restrict__ gy, const double* __restrict__ gz,
|
||||
const int nx, const int nz, const int voxels,
|
||||
const double cos_p, const double sin_p, const double ground_depth,
|
||||
const double min_depth, const double max_depth,
|
||||
const float samples_per_metre, const int use_compensation,
|
||||
const float comp_power, const float comp_max,
|
||||
float* __restrict__ volume)
|
||||
{
|
||||
const int voxel = blockIdx.x * blockDim.x + threadIdx.x;
|
||||
if (voxel >= voxels) return;
|
||||
const int row = voxel / nx;
|
||||
// Scene -> radar axes (sar_scene_to_radar_yz). The depth gate is decided in
|
||||
// double so voxels on its edge are classified like in the reference.
|
||||
const double y0 = gy[row / nz] - ground_depth;
|
||||
const double zs = gz[row % nz];
|
||||
const double depth = -y0 * sin_p + zs * cos_p;
|
||||
if (depth < min_depth || depth > max_depth) {
|
||||
volume[voxel] = 0.0f;
|
||||
return;
|
||||
}
|
||||
const float px = (float)gx[voxel - row * nx];
|
||||
const float py = (float)(y0 * cos_p + zs * sin_p);
|
||||
const float pz = (float)depth;
|
||||
const float last = (float)(samples - 1);
|
||||
float re = 0.0f, im = 0.0f, weights = 0.0f;
|
||||
for (int c = 0; c < count; ++c) {
|
||||
const Contribution k = contributions[c];
|
||||
const float dxt = px - k.tx[0], dyt = py - k.tx[1], dzt = pz - k.tx[2];
|
||||
const float dxr = px - k.rx[0], dyr = py - k.rx[1], dzr = pz - k.rx[2];
|
||||
const float rt = sqrtf(dxt * dxt + dyt * dyt + dzt * dzt);
|
||||
const float rr = sqrtf(dxr * dxr + dyr * dyr + dzr * dzr);
|
||||
const float position = (rt + rr) * samples_per_metre;
|
||||
if (position > last) continue;
|
||||
const int i = min((int)position, samples - 2);
|
||||
const float frac = position - (float)i;
|
||||
const float2* row_ascan = ascans + (long long)c * samples;
|
||||
const float2 a = row_ascan[i];
|
||||
const float2 b = row_ascan[i + 1];
|
||||
float w = k.weight;
|
||||
if (use_compensation) {
|
||||
// sar_compensation_weight: 1 / (geo / geo_ref)^power, clipped.
|
||||
w *= fminf(__powf(rt * rr * k.inv_reference_ranges, comp_power), comp_max);
|
||||
}
|
||||
re += w * (a.x + frac * (b.x - a.x));
|
||||
im += w * (a.y + frac * (b.y - a.y));
|
||||
weights += k.weight;
|
||||
}
|
||||
volume[voxel] = sqrtf(re * re + im * im) / (weights + 1e-12f);
|
||||
}
|
||||
|
||||
// One pass of scipy.ndimage.gaussian_filter1d (mode 'reflect') along Z or X.
|
||||
extern "C" __global__ void smooth(
|
||||
const float* __restrict__ src, float* __restrict__ dst,
|
||||
const float* __restrict__ taps, const int radius,
|
||||
const int nx, const int nz, const int voxels, const int along_z)
|
||||
{
|
||||
const int voxel = blockIdx.x * blockDim.x + threadIdx.x;
|
||||
if (voxel >= voxels) return;
|
||||
const int length = along_z ? nz : nx;
|
||||
const int index = along_z ? (voxel / nx) % nz : voxel % nx;
|
||||
const int stride = along_z ? nx : 1;
|
||||
float sum = 0.0f;
|
||||
for (int k = -radius; k <= radius; ++k) {
|
||||
int j = index + k;
|
||||
while (j < 0 || j >= length) j = j < 0 ? -j - 1 : 2 * length - j - 1; // d c b a | a b c d | d c b a
|
||||
sum += taps[k + radius] * src[voxel + (j - index) * stride];
|
||||
}
|
||||
dst[voxel] = sum;
|
||||
}
|
||||
|
||||
// dst[o] = max over r of src[(o / inner) * outer_stride + (o % inner) * inner_stride + r * reduce_stride].
|
||||
extern "C" __global__ void max_project(
|
||||
const float* __restrict__ src, float* __restrict__ dst, const int outputs, const int inner,
|
||||
const int outer_stride, const int inner_stride, const int reduce, const int reduce_stride)
|
||||
{
|
||||
const int o = blockIdx.x * blockDim.x + threadIdx.x;
|
||||
if (o >= outputs) return;
|
||||
const float* p = src + (o / inner) * outer_stride + (o % inner) * inner_stride;
|
||||
float m = 0.0f;
|
||||
for (int r = 0; r < reduce; ++r) m = fmaxf(m, p[r * reduce_stride]);
|
||||
dst[o] = m;
|
||||
}
|
||||
|
||||
// Orthographic max projection. The volume is non-negative, so float bit
|
||||
// patterns order like the values and an integer atomicMax does the job. The
|
||||
// pixel is found in double, so it does not hinge on float rounding.
|
||||
extern "C" __global__ void project_oblique(
|
||||
const float* __restrict__ volume,
|
||||
const double* __restrict__ gx, const double* __restrict__ gy, const double* __restrict__ gz,
|
||||
const int nx, const int nz, const int voxels,
|
||||
const double ux, const double uy, const double uz, const double vx, const double vy, const double vz,
|
||||
const double u0, const double v0, const double inv_pixel, const int nu, const int nv,
|
||||
unsigned int* __restrict__ image)
|
||||
{
|
||||
const int voxel = blockIdx.x * blockDim.x + threadIdx.x;
|
||||
if (voxel >= voxels) return;
|
||||
const float value = volume[voxel];
|
||||
if (!(value > 0.0f)) return;
|
||||
const int row = voxel / nx;
|
||||
const double x = gx[voxel - row * nx];
|
||||
const double y = gy[row / nz];
|
||||
const double z = gz[row % nz];
|
||||
const int pu = __double2int_rn((ux * x + uy * y + uz * z - u0) * inv_pixel);
|
||||
const int pv = __double2int_rn((vx * x + vy * y + vz * z - v0) * inv_pixel);
|
||||
if (pu < 0 || pu >= nu || pv < 0 || pv >= nv) return;
|
||||
atomicMax(image + pv * nu + pu, __float_as_uint(value));
|
||||
}
|
||||
)CUDA";
|
||||
|
||||
constexpr int kBlockSize = 256;
|
||||
|
||||
void check(CUresult result, const char* what) {
|
||||
if (result != CUDA_SUCCESS) {
|
||||
const char* text = nullptr;
|
||||
cuGetErrorString(result, &text);
|
||||
throw std::runtime_error(std::string{"CUDA "} + what + ": " + (text != nullptr ? text : "unknown error"));
|
||||
}
|
||||
}
|
||||
|
||||
void check(nvrtcResult result, const char* what) {
|
||||
if (result != NVRTC_SUCCESS) {
|
||||
throw std::runtime_error(std::string{"NVRTC "} + what + ": " + nvrtcGetErrorString(result));
|
||||
}
|
||||
}
|
||||
|
||||
[[nodiscard]] auto checked_int(std::size_t value, const char* what) -> int {
|
||||
if (value > static_cast<std::size_t>(INT_MAX)) {
|
||||
throw std::runtime_error(std::string{"sar3d "} + what + " exceeds the 32-bit kernel indexing");
|
||||
}
|
||||
return static_cast<int>(value);
|
||||
}
|
||||
|
||||
// Native code when this NVRTC knows the GPU's architecture, otherwise PTX for
|
||||
// the newest architecture it knows, which the driver compiles on load.
|
||||
[[nodiscard]] auto compile_kernels(CUdevice device) -> std::string {
|
||||
int major = 0;
|
||||
int minor = 0;
|
||||
check(cuDeviceGetAttribute(&major, CU_DEVICE_ATTRIBUTE_COMPUTE_CAPABILITY_MAJOR, device), "compute capability");
|
||||
check(cuDeviceGetAttribute(&minor, CU_DEVICE_ATTRIBUTE_COMPUTE_CAPABILITY_MINOR, device), "compute capability");
|
||||
const int device_arch = 10 * major + minor;
|
||||
int arch_count = 0;
|
||||
check(nvrtcGetNumSupportedArchs(&arch_count), "supported architectures");
|
||||
std::vector<int> archs(static_cast<std::size_t>(arch_count));
|
||||
check(nvrtcGetSupportedArchs(archs.data()), "supported architectures");
|
||||
int target = 0;
|
||||
for (const int arch : archs) {
|
||||
if (arch <= device_arch) {
|
||||
target = std::max(target, arch);
|
||||
}
|
||||
}
|
||||
if (target == 0) {
|
||||
throw std::runtime_error(
|
||||
"GPU sm_" + std::to_string(device_arch) + " is older than every architecture this NVRTC supports"
|
||||
);
|
||||
}
|
||||
const bool native = target == device_arch;
|
||||
const std::string arch_option =
|
||||
std::string{native ? "--gpu-architecture=sm_" : "--gpu-architecture=compute_"} + std::to_string(target);
|
||||
|
||||
nvrtcProgram raw_program = nullptr;
|
||||
check(nvrtcCreateProgram(&raw_program, kKernelSource, "sar3d.cu", 0, nullptr, nullptr), "create program");
|
||||
const auto destroy = [](nvrtcProgram program) { nvrtcDestroyProgram(&program); };
|
||||
const std::unique_ptr<std::remove_pointer_t<nvrtcProgram>, decltype(destroy)> program(raw_program, destroy);
|
||||
|
||||
const char* options[] = {arch_option.c_str()};
|
||||
if (nvrtcCompileProgram(program.get(), 1, options) != NVRTC_SUCCESS) {
|
||||
std::size_t log_size = 0U;
|
||||
nvrtcGetProgramLogSize(program.get(), &log_size);
|
||||
std::string log(log_size, '\0');
|
||||
nvrtcGetProgramLog(program.get(), log.data());
|
||||
throw std::runtime_error("NVRTC failed to compile the sar3d kernels:\n" + log);
|
||||
}
|
||||
const auto image_size = native ? nvrtcGetCUBINSize : nvrtcGetPTXSize;
|
||||
const auto image_data = native ? nvrtcGetCUBIN : nvrtcGetPTX;
|
||||
std::size_t size = 0U;
|
||||
check(image_size(program.get(), &size), "kernel image size");
|
||||
std::string image(size, '\0');
|
||||
check(image_data(program.get(), image.data()), "kernel image");
|
||||
return image;
|
||||
}
|
||||
|
||||
// Device allocation that only grows, so steady-state frames never reallocate.
|
||||
class DeviceBuffer {
|
||||
public:
|
||||
DeviceBuffer() = default;
|
||||
DeviceBuffer(const DeviceBuffer&) = delete;
|
||||
auto operator=(const DeviceBuffer&) -> DeviceBuffer& = delete;
|
||||
~DeviceBuffer() {
|
||||
if (pointer_ != 0U) {
|
||||
cuMemFree(pointer_);
|
||||
}
|
||||
}
|
||||
|
||||
auto reserve(std::size_t bytes) -> CUdeviceptr {
|
||||
if (bytes > capacity_) {
|
||||
if (pointer_ != 0U) {
|
||||
check(cuMemFree(pointer_), "free");
|
||||
pointer_ = 0U;
|
||||
capacity_ = 0U;
|
||||
}
|
||||
check(cuMemAlloc(&pointer_, bytes), "allocate");
|
||||
capacity_ = bytes;
|
||||
}
|
||||
return pointer_;
|
||||
}
|
||||
|
||||
template <class T>
|
||||
auto upload(std::span<const T> values) -> CUdeviceptr {
|
||||
reserve(values.size_bytes());
|
||||
if (!values.empty()) {
|
||||
check(cuMemcpyHtoD(pointer_, values.data(), values.size_bytes()), "upload");
|
||||
}
|
||||
return pointer_;
|
||||
}
|
||||
|
||||
[[nodiscard]] auto pointer() const -> CUdeviceptr {
|
||||
return pointer_;
|
||||
}
|
||||
|
||||
private:
|
||||
CUdeviceptr pointer_ = 0U;
|
||||
std::size_t capacity_ = 0U;
|
||||
};
|
||||
|
||||
template <class T>
|
||||
[[nodiscard]] auto download(CUdeviceptr source, std::size_t count) -> std::vector<T> {
|
||||
std::vector<T> values(count);
|
||||
check(cuMemcpyDtoH(values.data(), source, sizeof(T) * count), "download");
|
||||
return values;
|
||||
}
|
||||
|
||||
// One thread per item; argument types must match the kernel signature exactly.
|
||||
template <class... Args>
|
||||
void launch(CUfunction kernel, int items, Args... args) {
|
||||
void* parameters[] = {static_cast<void*>(&args)...};
|
||||
const auto blocks = static_cast<unsigned>((items + kBlockSize - 1) / kBlockSize);
|
||||
check(cuLaunchKernel(kernel, blocks, 1U, 1U, kBlockSize, 1U, 1U, 0U, nullptr, parameters, nullptr), "launch");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
struct Gpu::Impl {
|
||||
// Declared first, destroyed last: buffers and the module need the context.
|
||||
struct Context {
|
||||
CUdevice device = 0;
|
||||
CUcontext handle = nullptr;
|
||||
Context() {
|
||||
check(cuInit(0U), "init");
|
||||
check(cuDeviceGet(&device, 0), "device");
|
||||
check(cuDevicePrimaryCtxRetain(&handle, device), "context");
|
||||
if (const CUresult result = cuCtxSetCurrent(handle); result != CUDA_SUCCESS) {
|
||||
cuDevicePrimaryCtxRelease(device);
|
||||
check(result, "context");
|
||||
}
|
||||
}
|
||||
~Context() { cuDevicePrimaryCtxRelease(device); }
|
||||
} context;
|
||||
struct Module {
|
||||
CUmodule handle = nullptr;
|
||||
explicit Module(const std::string& image) { check(cuModuleLoadData(&handle, image.data()), "load kernels"); }
|
||||
~Module() { cuModuleUnload(handle); }
|
||||
[[nodiscard]] auto function(const char* name) const -> CUfunction {
|
||||
CUfunction kernel = nullptr;
|
||||
check(cuModuleGetFunction(&kernel, handle, name), name);
|
||||
return kernel;
|
||||
}
|
||||
} module{compile_kernels(context.device)};
|
||||
CUfunction inverse_dft = module.function("inverse_dft");
|
||||
CUfunction backproject = module.function("backproject");
|
||||
CUfunction smooth = module.function("smooth");
|
||||
CUfunction max_project = module.function("max_project");
|
||||
CUfunction project_oblique = module.function("project_oblique");
|
||||
int smooth_radius = 0;
|
||||
DeviceBuffer taps;
|
||||
DeviceBuffer bands;
|
||||
DeviceBuffer axes;
|
||||
DeviceBuffer contributions;
|
||||
DeviceBuffer ascans;
|
||||
DeviceBuffer volume;
|
||||
DeviceBuffer scratch;
|
||||
DeviceBuffer images;
|
||||
|
||||
~Impl() { cuCtxSetCurrent(context.handle); }
|
||||
};
|
||||
|
||||
Gpu::Gpu(double smooth_sigma_voxels) : impl_(std::make_unique<Impl>()) {
|
||||
// scipy.ndimage.gaussian_filter1d taps: radius int(4 sigma + 0.5), normalized.
|
||||
const int radius = static_cast<int>(4.0 * smooth_sigma_voxels + 0.5);
|
||||
std::vector<double> weights{};
|
||||
for (int k = -radius; k <= radius; ++k) {
|
||||
weights.push_back(std::exp(-0.5 * k * k / (smooth_sigma_voxels * smooth_sigma_voxels)));
|
||||
}
|
||||
double total = 0.0;
|
||||
for (const double weight : weights) {
|
||||
total += weight;
|
||||
}
|
||||
std::vector<float> taps{};
|
||||
for (const double weight : weights) {
|
||||
taps.push_back(static_cast<float>(weight / total));
|
||||
}
|
||||
impl_->smooth_radius = radius;
|
||||
impl_->taps.upload(std::span<const float>(taps));
|
||||
}
|
||||
|
||||
Gpu::~Gpu() = default;
|
||||
|
||||
auto Gpu::inverse_dft(
|
||||
std::span<const std::complex<float>> bands,
|
||||
std::size_t points,
|
||||
std::size_t first_bin,
|
||||
std::size_t size
|
||||
) -> std::vector<std::complex<float>> {
|
||||
Impl& gpu = *impl_;
|
||||
check(cuCtxSetCurrent(gpu.context.handle), "context");
|
||||
const std::size_t samples = bands.size() / points * size;
|
||||
const int items = checked_int(samples, "A-scan stack");
|
||||
const CUdeviceptr input = gpu.bands.upload(bands);
|
||||
const CUdeviceptr output = gpu.ascans.reserve(sizeof(std::complex<float>) * samples);
|
||||
launch(gpu.inverse_dft, items, input, checked_int(points, "band"), checked_int(first_bin, "first bin"),
|
||||
checked_int(size, "A-scan"), items, output);
|
||||
return download<std::complex<float>>(output, samples);
|
||||
}
|
||||
|
||||
auto Gpu::project(const VolumeRequest& request) -> Projections {
|
||||
Impl& gpu = *impl_;
|
||||
check(cuCtxSetCurrent(gpu.context.handle), "context");
|
||||
|
||||
const int nx = checked_int(request.x.size(), "grid");
|
||||
const int ny = checked_int(request.y.size(), "grid");
|
||||
const int nz = checked_int(request.z.size(), "grid");
|
||||
const int voxels = checked_int(request.x.size() * request.y.size() * request.z.size(), "grid");
|
||||
const int samples = checked_int(request.samples_per_ascan, "A-scan");
|
||||
|
||||
std::vector<double> axes(request.x.begin(), request.x.end());
|
||||
axes.insert(axes.end(), request.y.begin(), request.y.end());
|
||||
axes.insert(axes.end(), request.z.begin(), request.z.end());
|
||||
const CUdeviceptr gx = gpu.axes.upload(std::span<const double>(axes));
|
||||
const CUdeviceptr gy = gx + sizeof(double) * request.x.size();
|
||||
const CUdeviceptr gz = gy + sizeof(double) * request.y.size();
|
||||
const CUdeviceptr contributions = gpu.contributions.upload(request.contributions);
|
||||
const CUdeviceptr ascans = gpu.ascans.upload(request.ascans);
|
||||
const CUdeviceptr volume = gpu.volume.reserve(sizeof(float) * static_cast<std::size_t>(voxels));
|
||||
const CUdeviceptr scratch = gpu.scratch.reserve(sizeof(float) * static_cast<std::size_t>(voxels));
|
||||
|
||||
launch(gpu.backproject, voxels, ascans, samples, contributions,
|
||||
checked_int(request.contributions.size(), "aperture"), gx, gy, gz, nx, nz, voxels,
|
||||
std::cos(request.pitch_rad), std::sin(request.pitch_rad), request.ground_depth_m,
|
||||
request.min_depth_m, request.max_depth_m, static_cast<float>(request.samples_per_metre),
|
||||
static_cast<int>(request.use_compensation), request.comp_range_power, request.comp_weight_max, volume);
|
||||
// Separable Gaussian with sigma (0, s, s) over [y][z][x]: Z first, then X, like scipy.
|
||||
const CUdeviceptr taps = gpu.taps.pointer();
|
||||
launch(gpu.smooth, voxels, volume, scratch, taps, gpu.smooth_radius, nx, nz, voxels, 1);
|
||||
launch(gpu.smooth, voxels, scratch, volume, taps, gpu.smooth_radius, nx, nz, voxels, 0);
|
||||
|
||||
const ObliqueView& view = request.oblique;
|
||||
const int front_size = ny * nx;
|
||||
const int side_size = ny * nz;
|
||||
const int top_size = nz * nx;
|
||||
const int oblique_size = checked_int(static_cast<std::size_t>(view.nv) * static_cast<std::size_t>(view.nu), "view");
|
||||
const auto image_floats = static_cast<std::size_t>(front_size + side_size + top_size + oblique_size);
|
||||
const CUdeviceptr front = gpu.images.reserve(sizeof(float) * image_floats);
|
||||
const CUdeviceptr side = front + sizeof(float) * static_cast<std::size_t>(front_size);
|
||||
const CUdeviceptr top = side + sizeof(float) * static_cast<std::size_t>(side_size);
|
||||
const CUdeviceptr oblique = top + sizeof(float) * static_cast<std::size_t>(top_size);
|
||||
const int plane = nz * nx;
|
||||
launch(gpu.max_project, front_size, volume, front, front_size, nx, plane, 1, nz, nx);
|
||||
launch(gpu.max_project, side_size, volume, side, side_size, nz, plane, nx, nx, 1);
|
||||
launch(gpu.max_project, top_size, volume, top, top_size, nx, nx, 1, ny, plane);
|
||||
check(cuMemsetD32(oblique, 0U, static_cast<std::size_t>(oblique_size)), "clear");
|
||||
launch(gpu.project_oblique, voxels, volume, gx, gy, gz, nx, nz, voxels, view.u[0], view.u[1], view.u[2],
|
||||
view.v[0], view.v[1], view.v[2], view.u0, view.v0, 1.0 / view.pixel_m, view.nu, view.nv, oblique);
|
||||
|
||||
return Projections{
|
||||
.front = download<float>(front, static_cast<std::size_t>(front_size)),
|
||||
.side = download<float>(side, static_cast<std::size_t>(side_size)),
|
||||
.top = download<float>(top, static_cast<std::size_t>(top_size)),
|
||||
.oblique = download<float>(oblique, static_cast<std::size_t>(oblique_size)),
|
||||
};
|
||||
}
|
||||
|
||||
#else // !SAR3D_WITH_CUDA
|
||||
|
||||
struct Gpu::Impl {};
|
||||
|
||||
Gpu::Gpu(double /*smooth_sigma_voxels*/) {
|
||||
throw std::runtime_error(
|
||||
"data_processor was built without CUDA (nvrtc.h, cuda.h, libnvrtc or libcuda not found), "
|
||||
"so sar3d cannot run; install the CUDA requirements and rebuild"
|
||||
);
|
||||
}
|
||||
|
||||
Gpu::~Gpu() = default;
|
||||
|
||||
auto Gpu::inverse_dft(std::span<const std::complex<float>>, std::size_t, std::size_t, std::size_t)
|
||||
-> std::vector<std::complex<float>> {
|
||||
return {};
|
||||
}
|
||||
|
||||
auto Gpu::project(const VolumeRequest& /*request*/) -> Projections {
|
||||
return {};
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
} // namespace radar::processing::sar3d
|
||||
@@ -0,0 +1,91 @@
|
||||
#pragma once
|
||||
|
||||
#include <array>
|
||||
#include <complex>
|
||||
#include <cstddef>
|
||||
#include <memory>
|
||||
#include <span>
|
||||
#include <vector>
|
||||
|
||||
// GPU half of the sar3d processor: A-scans of the band spectra, and the
|
||||
// backprojection of an aperture into the 3D volume, smoothed and projected.
|
||||
// Only the projections of the volume leave the GPU.
|
||||
namespace radar::processing::sar3d {
|
||||
|
||||
// One (aperture frame, Tx/Rx pair) term of the backprojection sum. The layout
|
||||
// is shared with the CUDA kernel.
|
||||
struct Contribution {
|
||||
std::array<float, 3> tx{}; // Tx in radar axes, shifted by the frame offset [m]
|
||||
std::array<float, 3> rx{}; // Rx in radar axes, shifted by the frame offset [m]
|
||||
float weight = 0.0F; // aperture frame weight
|
||||
float inv_reference_ranges = 0.0F; // 1 / (Rtx * Rrx) at the compensation reference point
|
||||
};
|
||||
static_assert(sizeof(Contribution) == 8U * sizeof(float));
|
||||
|
||||
// Orthographic view of the volume: a voxel at scene point p lands in pixel
|
||||
// (round((u.p - u0) / pixel), round((v.p - v0) / pixel)).
|
||||
struct ObliqueView {
|
||||
std::array<double, 3> u{}; // screen right, scene axes (X, Y, Z)
|
||||
std::array<double, 3> v{}; // screen up
|
||||
double u0 = 0.0;
|
||||
double v0 = 0.0;
|
||||
double pixel_m = 0.0;
|
||||
int nu = 0;
|
||||
int nv = 0;
|
||||
};
|
||||
|
||||
struct VolumeRequest {
|
||||
// Scene axes [m]: X across, Y height above the ground, Z along the ground.
|
||||
std::span<const double> x;
|
||||
std::span<const double> y;
|
||||
std::span<const double> z;
|
||||
double pitch_rad = 0.0; // radar tilt that maps scene axes onto radar axes
|
||||
double ground_depth_m = 0.0; // height of the radar axes origin above the ground
|
||||
double min_depth_m = 0.0; // radar-depth gate; voxels outside it stay empty
|
||||
double max_depth_m = 0.0;
|
||||
std::span<const Contribution> contributions;
|
||||
// One A-scan per contribution, back to back; sample k is the Tx+Rx path k / samples_per_metre.
|
||||
std::span<const std::complex<float>> ascans;
|
||||
std::size_t samples_per_ascan = 0U;
|
||||
double samples_per_metre = 0.0;
|
||||
bool use_compensation = true;
|
||||
float comp_range_power = 0.0F;
|
||||
float comp_weight_max = 0.0F;
|
||||
ObliqueView oblique{};
|
||||
};
|
||||
|
||||
// Max-projections of the smoothed, not yet normalized volume (row-major).
|
||||
struct Projections {
|
||||
std::vector<float> front; // [y][x], maximum along Z
|
||||
std::vector<float> side; // [y][z], maximum along X
|
||||
std::vector<float> top; // [z][x], maximum along Y
|
||||
std::vector<float> oblique; // [v][u]
|
||||
};
|
||||
|
||||
// CUDA context with the kernels compiled for the installed GPU. Construction
|
||||
// throws when no usable GPU is present or the processor was built without CUDA.
|
||||
class Gpu {
|
||||
public:
|
||||
explicit Gpu(double smooth_sigma_voxels);
|
||||
~Gpu();
|
||||
Gpu(const Gpu&) = delete;
|
||||
auto operator=(const Gpu&) -> Gpu& = delete;
|
||||
|
||||
// numpy.fft.ifft of each band spectrum placed at bins [first_bin, first_bin +
|
||||
// points) of a `size`-point spectrum; `bands` holds the bands back to back.
|
||||
[[nodiscard]] auto inverse_dft(
|
||||
std::span<const std::complex<float>> bands,
|
||||
std::size_t points,
|
||||
std::size_t first_bin,
|
||||
std::size_t size
|
||||
) -> std::vector<std::complex<float>>;
|
||||
|
||||
// Backprojects, applies the Gaussian smoothing over (Z, X) and projects.
|
||||
[[nodiscard]] auto project(const VolumeRequest& request) -> Projections;
|
||||
|
||||
private:
|
||||
struct Impl;
|
||||
std::unique_ptr<Impl> impl_;
|
||||
};
|
||||
|
||||
} // namespace radar::processing::sar3d
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,349 +0,0 @@
|
||||
// Parity check of the sar3d processor against the reference notebook
|
||||
// Multi_antenna_3D_clean_v1.ipynb.
|
||||
//
|
||||
// Feeds a preprocessed snapshot of an extracted replay dataset through
|
||||
// Sar3dProcessor exactly the way the live pipeline would (geometry and combo
|
||||
// order from the dataset's config_profile.json, per-trace capture windows from
|
||||
// the snapshot's meta.json) and dumps the finished volume for comparison with
|
||||
// the notebook's *_volume.npz export:
|
||||
//
|
||||
// sar3d_parity <dataset_root> <center_snapshot> [--dump-dir <dir>]
|
||||
// python_app/scripts/sar3d_volume_compare.py <dir>/sar3d_volume_<id> <ref.npz>
|
||||
//
|
||||
// <dataset_root> contains config_profile.json and preprocessed/<snapshot>/.
|
||||
// Defaults mirror the parameters the reference volumes were built with
|
||||
// (f 2..6 GHz, depth 2.5..8 m, speed 1.45 m/s, look angle 17.5 deg).
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <complex>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <dirent.h>
|
||||
#include <fstream>
|
||||
#include <span>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include "gpr/config.hpp"
|
||||
#include "gpr/geometry.hpp"
|
||||
#include "processing_live_config.hpp"
|
||||
#include "run_config.hpp"
|
||||
#include "sar3d_processor.hpp"
|
||||
#include "shared_types.hpp"
|
||||
|
||||
namespace {
|
||||
|
||||
// Minimal numpy .npy loader (v1.0, 1-D complex128/complex64/float64/float32).
|
||||
struct NpyArray {
|
||||
std::vector<double> f64{};
|
||||
std::vector<std::complex<double>> c128{};
|
||||
};
|
||||
|
||||
NpyArray load_npy(const std::string& path) {
|
||||
std::ifstream in(path, std::ios::binary);
|
||||
if (!in) {
|
||||
throw std::runtime_error("cannot open " + path);
|
||||
}
|
||||
char magic[6];
|
||||
in.read(magic, 6);
|
||||
std::uint8_t version[2];
|
||||
in.read(reinterpret_cast<char*>(version), 2);
|
||||
std::uint16_t header_len = 0;
|
||||
in.read(reinterpret_cast<char*>(&header_len), 2);
|
||||
std::string header(header_len, '\0');
|
||||
in.read(header.data(), header_len);
|
||||
const auto shape_pos = header.find("'shape':");
|
||||
const auto left = header.find('(', shape_pos);
|
||||
const auto right = header.find(')', shape_pos);
|
||||
const long count = std::stol(header.substr(left + 1, right - left - 1));
|
||||
|
||||
NpyArray array{};
|
||||
const auto has = [&header](const char* token) { return header.find(token) != std::string::npos; };
|
||||
if (has("c8")) {
|
||||
array.c128.resize(count);
|
||||
std::vector<std::complex<float>> tmp(count);
|
||||
in.read(reinterpret_cast<char*>(tmp.data()), count * sizeof(std::complex<float>));
|
||||
for (long i = 0; i < count; ++i) {
|
||||
array.c128[i] = std::complex<double>(tmp[i].real(), tmp[i].imag());
|
||||
}
|
||||
} else if (has("c16")) {
|
||||
array.c128.resize(count);
|
||||
in.read(reinterpret_cast<char*>(array.c128.data()), count * sizeof(std::complex<double>));
|
||||
} else if (has("f4")) {
|
||||
array.f64.resize(count);
|
||||
std::vector<float> tmp(count);
|
||||
in.read(reinterpret_cast<char*>(tmp.data()), count * sizeof(float));
|
||||
for (long i = 0; i < count; ++i) {
|
||||
array.f64[i] = tmp[i];
|
||||
}
|
||||
} else {
|
||||
array.f64.resize(count);
|
||||
in.read(reinterpret_cast<char*>(array.f64.data()), count * sizeof(double));
|
||||
}
|
||||
return array;
|
||||
}
|
||||
|
||||
// Live config matching the reference notebook run (npz meta parameters).
|
||||
// The default program grid is a fixed window (X -2..2, Z 0..8), while the
|
||||
// notebook derives its bounds from the antenna span and the depth gate — so
|
||||
// the notebook bounds are recomputed here and passed explicitly.
|
||||
auto build_live_config(const radar::config::RunConfig& run_config)
|
||||
-> radar::processing::ProcessingLiveConfig {
|
||||
radar::processing::ProcessingLiveConfig live{};
|
||||
live.processor_mode = "sar3d";
|
||||
live.gpr_start_freq_mhz = 2000.0F;
|
||||
live.gpr_stop_freq_mhz = 6000.0F;
|
||||
live.gpr_min_depth_m = 2.5F;
|
||||
live.gpr_max_depth_m = 8.0F;
|
||||
live.gpr_speed_m_s = 1.45F;
|
||||
live.gpr_look_angle_deg = 17.5F;
|
||||
live.gpr_direction_sign = 1.0F;
|
||||
live.gpr_apply_freq_phase_correction = true;
|
||||
|
||||
gpr::GeometryConfig geom{};
|
||||
geom.input_idx.clear();
|
||||
geom.output_idx.clear();
|
||||
geom.rx_positions.clear();
|
||||
geom.tx_positions.clear();
|
||||
for (const auto& entry : run_config.gpr.rx_geometry) {
|
||||
geom.input_idx.push_back(static_cast<int>(entry.input_pos));
|
||||
geom.rx_positions.push_back({entry.x_m, entry.y_m, entry.z_m});
|
||||
}
|
||||
for (const auto& entry : run_config.gpr.tx_geometry) {
|
||||
geom.output_idx.push_back(static_cast<int>(entry.output_pos));
|
||||
geom.tx_positions.push_back({entry.x_m, entry.y_m, entry.z_m});
|
||||
}
|
||||
geom.finalize();
|
||||
gpr::BPConfig bp{};
|
||||
bp.min_depth = live.gpr_min_depth_m;
|
||||
bp.max_depth = live.gpr_max_depth_m;
|
||||
gpr::SARConfig sar{};
|
||||
double x_min = 0.0, x_max = 0.0, z_min = 0.0, z_max = 0.0;
|
||||
gpr::notebook_grid_bounds(geom, bp, sar, live.gpr_look_angle_deg, /*x_margin_m=*/2.0,
|
||||
x_min, x_max, z_min, z_max);
|
||||
live.sar3d_x_min_m = x_min;
|
||||
live.sar3d_x_max_m = x_max;
|
||||
live.sar3d_z_min_m = z_min;
|
||||
live.sar3d_z_max_m = z_max;
|
||||
return live;
|
||||
}
|
||||
|
||||
auto load_collection(
|
||||
const radar::config::RunConfig& run_config,
|
||||
const std::string& root,
|
||||
const std::string& snapshot,
|
||||
std::uint64_t collection_id
|
||||
) -> radar::ipc::PreprocessedCollection {
|
||||
radar::ipc::PreprocessedCollection collection{};
|
||||
collection.collection_id = collection_id;
|
||||
|
||||
// Per-trace capture windows from the snapshot's meta.json; the segmented
|
||||
// timing model is reconstructed from them exactly like in the notebook.
|
||||
std::map<std::pair<std::uint32_t, std::uint32_t>, std::pair<std::uint64_t, std::uint64_t>> windows{};
|
||||
{
|
||||
std::ifstream in(root + "/preprocessed/" + snapshot + "/meta.json");
|
||||
if (in) {
|
||||
nlohmann::json meta{};
|
||||
in >> meta;
|
||||
for (const auto& trace : meta.value("traces", nlohmann::json::array())) {
|
||||
if (!trace.contains("capture_start_ns") || !trace.contains("capture_end_ns")) {
|
||||
continue;
|
||||
}
|
||||
windows[{trace.at("input").get<std::uint32_t>(), trace.at("output").get<std::uint32_t>()}] = {
|
||||
trace.at("capture_start_ns").get<std::uint64_t>(),
|
||||
trace.at("capture_end_ns").get<std::uint64_t>(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::uint64_t first = 0U;
|
||||
std::uint64_t last = 0U;
|
||||
for (const auto& [key, window] : windows) {
|
||||
if (first == 0U || window.first < first) {
|
||||
first = window.first;
|
||||
}
|
||||
last = std::max(last, window.second);
|
||||
}
|
||||
if (first == 0U) {
|
||||
// No meta labels: fall back to the `_ns<...>` folder suffix.
|
||||
const auto ns_pos = snapshot.rfind("_ns");
|
||||
if (ns_pos == std::string::npos) {
|
||||
throw std::runtime_error("snapshot has no meta.json and no _ns suffix: " + snapshot);
|
||||
}
|
||||
last = static_cast<std::uint64_t>(std::stoull(snapshot.substr(ns_pos + 3)));
|
||||
first = last;
|
||||
}
|
||||
collection.capture_start_ns = first;
|
||||
collection.capture_end_ns = last;
|
||||
collection.monotonic_ns = (first + last) / 2U;
|
||||
|
||||
for (const auto& combo : run_config.run_combos) {
|
||||
char path[512];
|
||||
std::snprintf(path, sizeof(path), "%s/preprocessed/%s/i%u_o%u_s21.npy",
|
||||
root.c_str(), snapshot.c_str(), combo.input_pos, combo.output_pos);
|
||||
const auto s21 = load_npy(path);
|
||||
std::snprintf(path, sizeof(path), "%s/preprocessed/%s/i%u_o%u_freq.npy",
|
||||
root.c_str(), snapshot.c_str(), combo.input_pos, combo.output_pos);
|
||||
const auto freq = load_npy(path);
|
||||
|
||||
radar::ipc::SweepTraceBlock trace{};
|
||||
trace.combo = combo;
|
||||
trace.frequency_hz.assign(freq.f64.begin(), freq.f64.end());
|
||||
trace.s21.reserve(s21.c128.size());
|
||||
for (const auto& sample : s21.c128) {
|
||||
trace.s21.push_back(radar::ipc::Complex32{
|
||||
.re = static_cast<float>(sample.real()),
|
||||
.im = static_cast<float>(sample.imag()),
|
||||
});
|
||||
}
|
||||
trace.s11.resize(trace.s21.size());
|
||||
if (const auto found = windows.find({combo.input_pos, combo.output_pos}); found != windows.end()) {
|
||||
trace.capture_start_ns = found->second.first;
|
||||
trace.capture_end_ns = found->second.second;
|
||||
}
|
||||
collection.traces.push_back(std::move(trace));
|
||||
}
|
||||
return collection;
|
||||
}
|
||||
|
||||
void print_projection_argmax(
|
||||
const radar::ipc::ResultPayload& payload,
|
||||
const char* col_axis_name,
|
||||
const char* row_axis_name
|
||||
) {
|
||||
std::size_t argmax = 0U;
|
||||
float best = -1.0F;
|
||||
for (std::size_t index = 0U; index < payload.image_values.size(); ++index) {
|
||||
if (payload.image_values[index] > best) {
|
||||
best = payload.image_values[index];
|
||||
argmax = index;
|
||||
}
|
||||
}
|
||||
const auto nx = payload.image_x_axis.size();
|
||||
const auto col = argmax % nx;
|
||||
const auto row = argmax / nx;
|
||||
std::printf(
|
||||
"%s: max=%.6f at %s=%.1f cm, %s=%.1f cm\n",
|
||||
payload.processing_name.c_str(),
|
||||
static_cast<double>(best),
|
||||
col_axis_name,
|
||||
static_cast<double>(payload.image_x_axis[col]) * 100.0,
|
||||
row_axis_name,
|
||||
static_cast<double>(payload.image_y_axis[row]) * 100.0
|
||||
);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
if (argc < 3) {
|
||||
std::printf("usage: %s <dataset_root> <center_snapshot> [--dump-dir <dir>]\n", argv[0]);
|
||||
return 1;
|
||||
}
|
||||
const std::string root = argv[1];
|
||||
const std::string center = argv[2];
|
||||
std::string dump_dir{};
|
||||
for (int index = 3; index + 1 < argc; ++index) {
|
||||
if (std::string{argv[index]} == "--dump-dir") {
|
||||
dump_dir = argv[index + 1];
|
||||
}
|
||||
}
|
||||
|
||||
const std::string preprocessed = root + "/preprocessed";
|
||||
std::vector<std::string> snapshots{};
|
||||
if (DIR* dir = opendir(preprocessed.c_str())) {
|
||||
for (dirent* entry = nullptr; (entry = readdir(dir)) != nullptr;) {
|
||||
const std::string name = entry->d_name;
|
||||
if (!name.empty() && (std::isdigit(name[0]) != 0)) {
|
||||
snapshots.push_back(name);
|
||||
}
|
||||
}
|
||||
closedir(dir);
|
||||
}
|
||||
std::sort(snapshots.begin(), snapshots.end());
|
||||
const auto center_it = std::find(snapshots.begin(), snapshots.end(), center);
|
||||
if (center_it == snapshots.end()) {
|
||||
std::printf("center %s not found in %s\n", center.c_str(), preprocessed.c_str());
|
||||
return 1;
|
||||
}
|
||||
const auto center_index = static_cast<std::size_t>(center_it - snapshots.begin());
|
||||
|
||||
const auto run_config = radar::config::load_run_config(root + "/config_profile.json");
|
||||
if (run_config.gpr.tx_geometry.empty() || run_config.gpr.rx_geometry.empty()) {
|
||||
std::printf("FAIL: config_profile.json has no gpr geometry\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
auto live = build_live_config(run_config);
|
||||
live.sar3d_volume_dump_dir = dump_dir;
|
||||
const auto radius = static_cast<std::size_t>(live.sar3d_frame_radius);
|
||||
if (center_index < radius || center_index + radius >= snapshots.size()) {
|
||||
std::printf("not enough snapshots around center for radius %zu\n", radius);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// The processor centers the aperture `radius` frames before the newest
|
||||
// collection, so feeding [center-r .. center+r] in order reproduces the
|
||||
// symmetric aperture around `center`.
|
||||
std::vector<radar::ipc::PreprocessedCollection> history{};
|
||||
for (std::size_t index = center_index - radius; index <= center_index + radius; ++index) {
|
||||
history.push_back(load_collection(run_config, root, snapshots[index], index));
|
||||
}
|
||||
std::printf("aperture: %zu frames around %s\n", history.size(), center.c_str());
|
||||
|
||||
radar::processing::Sar3dProcessor processor{};
|
||||
radar::ipc::ResultCollection result{};
|
||||
for (std::size_t index = 0U; index < history.size(); ++index) {
|
||||
result = processor.process_collection(
|
||||
run_config,
|
||||
history[index],
|
||||
std::span<const radar::ipc::PreprocessedCollection>(history.data(), index),
|
||||
live
|
||||
);
|
||||
}
|
||||
|
||||
if (result.collection_payloads.empty()) {
|
||||
std::printf("FAIL: sar3d produced no payloads\n");
|
||||
return 1;
|
||||
}
|
||||
for (const auto& payload : result.collection_payloads) {
|
||||
if (payload.kind == radar::ipc::ResultKind::ScalarF32) {
|
||||
std::printf("%s (volume level): %.9e\n",
|
||||
payload.processing_name.c_str(), static_cast<double>(payload.scalar_value));
|
||||
}
|
||||
}
|
||||
for (const auto& payload : result.collection_payloads) {
|
||||
if (payload.kind == radar::ipc::ResultKind::ImageF32 && payload.processing_name == "sar3d_proj_xz") {
|
||||
print_projection_argmax(payload, "X", "Z");
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom view across the selectable angles: the target's u coordinate must
|
||||
// move smoothly from its X (front, 0 deg) to its Z (side, 90 deg).
|
||||
const auto last_previous =
|
||||
std::span<const radar::ipc::PreprocessedCollection>(history.data(), history.size() - 1U);
|
||||
for (const float angle_deg : {0.0F, 30.0F, 60.0F, 90.0F}) {
|
||||
auto live_angle = live;
|
||||
live_angle.sar3d_volume_dump_dir.clear();
|
||||
live_angle.sar3d_bottom_view_deg = angle_deg;
|
||||
const auto angle_result =
|
||||
processor.process_collection(run_config, history.back(), last_previous, live_angle);
|
||||
for (const auto& payload : angle_result.collection_payloads) {
|
||||
if (payload.kind == radar::ipc::ResultKind::ImageF32 && payload.processing_name == "sar3d_proj_bottom") {
|
||||
std::printf("bottom @ %2.0f deg -> ", static_cast<double>(angle_deg));
|
||||
print_projection_argmax(payload, "u", "Y");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!dump_dir.empty()) {
|
||||
std::printf("volume dumped to %s/sar3d_volume_%llu.{f32,json}\n",
|
||||
dump_dir.c_str(),
|
||||
static_cast<unsigned long long>(history.back().collection_id));
|
||||
}
|
||||
std::printf("OK: sar3d processor path completed\n");
|
||||
return 0;
|
||||
}
|
||||
@@ -371,9 +371,6 @@ class AppWindow(
|
||||
self._gpr_region_contours = []
|
||||
self._gpr_geometry_signature = None
|
||||
self._gpr_selected_geometry = None
|
||||
self._sar3d_lookup_table = None
|
||||
self._sar3d_xz_image_item = None
|
||||
self._sar3d_xy_image_item = None
|
||||
self._phase_viewbox = None
|
||||
self._history_run_signature = None
|
||||
self._processor_run_signature = None
|
||||
|
||||
@@ -13,7 +13,6 @@ from PyQt6.QtWidgets import (
|
||||
)
|
||||
|
||||
from python_app.gui.runtime.constraints import validate_processing_mode_constraints
|
||||
from python_app.gui.runtime.sar3d_views import sar3d_bottom_view_deg_from_label
|
||||
from python_app.orchestration.live_processing_config import ProcessingLiveConfig
|
||||
|
||||
_GPR_MODES = ("gpr", "legacy_gpr")
|
||||
@@ -83,32 +82,17 @@ _WEB_LIVE_SCHEMA = [
|
||||
("gpr_visible_z_max_m", "Visible window", _GPR_MODES, _dual("_gpr_visible_z_max_m", {"legacy_gpr": "_legacy_gpr_visible_z_max_m"})),
|
||||
("gpr_motion_mode", "Motion", ("gpr",), _attr("_gpr_motion_mode")),
|
||||
("gpr_look_angle_deg", "Motion", _GPR_LIKE_MODES, _dual("_gpr_look_angle_deg", {"legacy_gpr": "_legacy_gpr_look_angle_deg", "sar3d": "_sar3d_look_angle_deg"})),
|
||||
("gpr_direction_sign", "Motion", ("gpr", "sar3d"), _dual("_gpr_direction_sign", {"sar3d": "_sar3d_direction_sign"})),
|
||||
("gpr_apply_freq_phase_correction", "Motion", ("legacy_gpr", "sar3d"), _dual("_legacy_gpr_apply_freq_phase_correction", {"sar3d": "_sar3d_apply_freq_phase_correction"})),
|
||||
("gpr_direction_sign", "Motion", ("gpr",), _attr("_gpr_direction_sign")),
|
||||
("gpr_apply_freq_phase_correction", "Motion", ("legacy_gpr",), _attr("_legacy_gpr_apply_freq_phase_correction")),
|
||||
("gpr_reference_mode", "Motion", ("legacy_gpr",), _attr("_legacy_gpr_reference_mode")),
|
||||
("ignore_socket_speed", "Motion", _GPR_LIKE_MODES, _dual("_gpr_ignore_socket_speed_enabled", {"legacy_gpr": "_legacy_gpr_ignore_socket_speed_enabled", "sar3d": "_sar3d_ignore_socket_speed_enabled"})),
|
||||
("gpr_speed_m_s", "Motion", _GPR_LIKE_MODES, _dual("_gpr_speed_m_s", {"legacy_gpr": "_legacy_gpr_speed_m_s", "sar3d": "_sar3d_speed_m_s"})),
|
||||
("sar3d_frame_radius", "3D SAR", ("sar3d",), _attr("_sar3d_frame_radius")),
|
||||
("sar3d_edge_frame_weight", "3D SAR", ("sar3d",), _attr("_sar3d_edge_frame_weight")),
|
||||
("sar3d_pitch_override_enabled", "3D SAR", ("sar3d",), _attr("_sar3d_pitch_override_enabled")),
|
||||
("sar3d_pitch_deg", "3D SAR", ("sar3d",), _attr("_sar3d_pitch_deg")),
|
||||
("sar3d_ground_depth_m", "3D SAR", ("sar3d",), _attr("_sar3d_ground_depth_m")),
|
||||
("sar3d_x_min_m", "3D SAR", ("sar3d",), _attr("_sar3d_x_min_m")),
|
||||
("sar3d_x_max_m", "3D SAR", ("sar3d",), _attr("_sar3d_x_max_m")),
|
||||
("sar3d_y_min_m", "3D SAR", ("sar3d",), _attr("_sar3d_y_min_m")),
|
||||
("sar3d_y_max_m", "3D SAR", ("sar3d",), _attr("_sar3d_y_max_m")),
|
||||
("sar3d_z_min_m", "3D SAR", ("sar3d",), _attr("_sar3d_z_min_m")),
|
||||
("sar3d_z_max_m", "3D SAR", ("sar3d",), _attr("_sar3d_z_max_m")),
|
||||
("sar3d_voxel_x_m", "3D SAR", ("sar3d",), _attr("_sar3d_voxel_x_m")),
|
||||
("sar3d_voxel_y_m", "3D SAR", ("sar3d",), _attr("_sar3d_voxel_y_m")),
|
||||
("sar3d_voxel_z_m", "3D SAR", ("sar3d",), _attr("_sar3d_voxel_z_m")),
|
||||
("sar3d_norm_edge_margin_m", "3D SAR", ("sar3d",), _attr("_sar3d_norm_edge_margin_m")),
|
||||
("sar3d_smooth_sigma", "3D SAR", ("sar3d",), _attr("_sar3d_smooth_sigma")),
|
||||
("sar3d_voxel_m", "3D SAR", ("sar3d",), _attr("_sar3d_voxel_m")),
|
||||
("sar3d_bp_ground_depth_m", "3D SAR", ("sar3d",), _attr("_sar3d_bp_ground_depth_m")),
|
||||
("sar3d_comp_range_power", "3D SAR", ("sar3d",), _attr("_sar3d_comp_range_power")),
|
||||
("sar3d_tx_per_segment", "3D SAR", ("sar3d",), _attr("_sar3d_tx_per_segment")),
|
||||
("sar3d_use_compensation", "3D SAR", ("sar3d",), _attr("_sar3d_use_compensation")),
|
||||
("sar3d_normalize_pairs", "3D SAR", ("sar3d",), _attr("_sar3d_normalize_pairs")),
|
||||
("sar3d_bottom_view_deg", "3D SAR", ("sar3d",), _attr("_sar3d_bottom_view")),
|
||||
("sar3d_normalize_each_frame", "3D SAR", ("sar3d",), _attr("_sar3d_normalize_each_frame")),
|
||||
]
|
||||
_WEB_LIVE_GETTERS = {field: getter for field, _group, _modes, getter in _WEB_LIVE_SCHEMA}
|
||||
|
||||
@@ -259,7 +243,7 @@ class AppWindowLiveProcessingMixin:
|
||||
:meth:`_is_gpr_processing_mode`; this predicate gates only the shared
|
||||
behaviors (result drain on settings changes, geometry page visibility).
|
||||
"""
|
||||
return mode in set(_GPR_LIKE_MODES)
|
||||
return mode in _GPR_LIKE_MODES
|
||||
|
||||
def _validate_processing_mode_selection(self, mode: str) -> None:
|
||||
"""Validate requested processing mode against current stable/live GUI state."""
|
||||
@@ -295,10 +279,6 @@ class AppWindowLiveProcessingMixin:
|
||||
# Switch-position CSV text -> integer lists.
|
||||
values["gpr_input_positions"] = self._parse_csv_int_list(values["gpr_input_positions"])
|
||||
values["gpr_output_positions"] = self._parse_csv_int_list(values["gpr_output_positions"])
|
||||
# Bottom-view combo label -> projection angle in degrees.
|
||||
values["sar3d_bottom_view_deg"] = sar3d_bottom_view_deg_from_label(
|
||||
str(values["sar3d_bottom_view_deg"])
|
||||
)
|
||||
# Order-normalize the pass-through Y range so a reversed entry still works.
|
||||
y_min_db, y_max_db = values["pass_through_y_min_db"], values["pass_through_y_max_db"]
|
||||
values["pass_through_y_min_db"] = min(y_min_db, y_max_db)
|
||||
@@ -424,13 +404,23 @@ class AppWindowLiveProcessingMixin:
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._show_exception("Failed to update live processing settings", exc)
|
||||
|
||||
def _on_legacy_gpr_ignore_socket_speed_toggled(self, ignore_socket_speed: bool) -> None:
|
||||
"""Reflect socket/manual speed authority in the GUI and live config."""
|
||||
try:
|
||||
self._legacy_gpr_speed_m_s.setEnabled(bool(ignore_socket_speed))
|
||||
self._write_live_processing_config()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._show_exception("Failed to update socket-speed mode", exc)
|
||||
def _sync_socket_speed_controls(self) -> None:
|
||||
"""Enable each GPR-like speed field only while its page ignores the socket speed.
|
||||
|
||||
Unchecked, the speed a locator client streams drives the processing and the
|
||||
field only stands in while no client sends one, so it must not look editable.
|
||||
"""
|
||||
for checkbox, speed in (
|
||||
(self._gpr_ignore_socket_speed_enabled, self._gpr_speed_m_s),
|
||||
(self._legacy_gpr_ignore_socket_speed_enabled, self._legacy_gpr_speed_m_s),
|
||||
(self._sar3d_ignore_socket_speed_enabled, self._sar3d_speed_m_s),
|
||||
):
|
||||
speed.setEnabled(checkbox.isChecked())
|
||||
|
||||
def _on_ignore_socket_speed_toggled(self, *_args) -> None:
|
||||
"""Switch the speed authority between the socket client and the GUI field."""
|
||||
self._sync_socket_speed_controls()
|
||||
self._on_processing_live_settings_changed()
|
||||
|
||||
def _on_gpr_visual_settings_changed(self, *_args) -> None:
|
||||
"""Redraw current GPR result using updated GUI-only render settings."""
|
||||
@@ -556,13 +546,13 @@ class AppWindowLiveProcessingMixin:
|
||||
elif mode == "sar3d":
|
||||
self._log(
|
||||
"Processing mode selected: sar3d "
|
||||
f"(inputs={self._sar3d_input_positions_input.text().strip() or '<all>'}, "
|
||||
f"outputs={self._sar3d_output_positions_input.text().strip() or '<all>'}, "
|
||||
f"depth={self._sar3d_min_depth_m.value():g}..{self._sar3d_max_depth_m.value():g} m, "
|
||||
f"freq={self._sar3d_start_freq_mhz.value():g}..{self._sar3d_stop_freq_mhz.value():g} MHz, "
|
||||
f"frame_radius={self._sar3d_frame_radius.value()}, "
|
||||
f"voxel={self._sar3d_voxel_x_m.value():g}x{self._sar3d_voxel_y_m.value():g}x{self._sar3d_voxel_z_m.value():g} m, "
|
||||
f"ground_depth={self._sar3d_ground_depth_m.value():g} m)"
|
||||
f"(INPUT_IDX={self._sar3d_input_positions_input.text().strip() or '<all>'}, "
|
||||
f"OUTPUT_IDX={self._sar3d_output_positions_input.text().strip() or '<all>'}, "
|
||||
f"F={self._sar3d_start_freq_mhz.value():g}..{self._sar3d_stop_freq_mhz.value():g} MHz, "
|
||||
f"DEPTH={self._sar3d_min_depth_m.value():g}..{self._sar3d_max_depth_m.value():g} m, "
|
||||
f"SAR_VOXEL_M={self._sar3d_voxel_m.value():g}, "
|
||||
f"LOOK_ANGLE_DEG={self._sar3d_look_angle_deg.value():g}, "
|
||||
f"SAR_FRAME_RADIUS={self._sar3d_frame_radius.value()})"
|
||||
)
|
||||
|
||||
def _clear_history_mode_caches(self) -> None:
|
||||
@@ -574,7 +564,7 @@ class AppWindowLiveProcessingMixin:
|
||||
self._clear_trace_plots()
|
||||
if hasattr(self, "_gpr_plot"):
|
||||
self._clear_gpr_plot()
|
||||
if hasattr(self, "_sar3d_xz_plot"):
|
||||
if hasattr(self, "_sar3d_plots"):
|
||||
self._clear_sar3d_plot()
|
||||
|
||||
def _redraw_after_history_deletion(self) -> None:
|
||||
|
||||
@@ -10,7 +10,6 @@ from pathlib import Path
|
||||
from PyQt6.QtCore import QSignalBlocker
|
||||
from PyQt6.QtWidgets import QFileDialog
|
||||
|
||||
from python_app.gui.runtime.sar3d_views import sar3d_bottom_view_label_from_deg
|
||||
from python_app.models.gui_profile_model import GuiProfileModel
|
||||
from python_app.models.run_config_model import RunConfigModel
|
||||
from python_app.orchestration.preprocess_assets import (
|
||||
@@ -346,28 +345,12 @@ class AppWindowConfigProfileIOMixin:
|
||||
self._sar3d_speed_m_s,
|
||||
self._sar3d_ignore_socket_speed_enabled,
|
||||
self._sar3d_look_angle_deg,
|
||||
self._sar3d_direction_sign,
|
||||
self._sar3d_apply_freq_phase_correction,
|
||||
self._sar3d_frame_radius,
|
||||
self._sar3d_edge_frame_weight,
|
||||
self._sar3d_pitch_override_enabled,
|
||||
self._sar3d_pitch_deg,
|
||||
self._sar3d_ground_depth_m,
|
||||
self._sar3d_x_min_m,
|
||||
self._sar3d_x_max_m,
|
||||
self._sar3d_y_min_m,
|
||||
self._sar3d_y_max_m,
|
||||
self._sar3d_z_min_m,
|
||||
self._sar3d_z_max_m,
|
||||
self._sar3d_voxel_x_m,
|
||||
self._sar3d_voxel_y_m,
|
||||
self._sar3d_voxel_z_m,
|
||||
self._sar3d_norm_edge_margin_m,
|
||||
self._sar3d_smooth_sigma,
|
||||
self._sar3d_voxel_m,
|
||||
self._sar3d_bp_ground_depth_m,
|
||||
self._sar3d_comp_range_power,
|
||||
self._sar3d_tx_per_segment,
|
||||
self._sar3d_use_compensation,
|
||||
self._sar3d_normalize_pairs,
|
||||
self._sar3d_normalize_each_frame,
|
||||
self._save_count,
|
||||
self._record_count,
|
||||
self._save_path_input,
|
||||
@@ -532,11 +515,9 @@ class AppWindowConfigProfileIOMixin:
|
||||
self._legacy_gpr_start_freq_mhz.setValue(float(gui_state.processing.legacy_gpr.start_freq_mhz))
|
||||
self._legacy_gpr_stop_freq_mhz.setValue(float(gui_state.processing.legacy_gpr.stop_freq_mhz))
|
||||
self._legacy_gpr_speed_m_s.setValue(float(gui_state.processing.legacy_gpr.speed_m_s))
|
||||
ignore_socket_speed_enabled = bool(
|
||||
gui_state.processing.legacy_gpr.ignore_socket_speed_enabled
|
||||
self._legacy_gpr_ignore_socket_speed_enabled.setChecked(
|
||||
bool(gui_state.processing.legacy_gpr.ignore_socket_speed_enabled)
|
||||
)
|
||||
self._legacy_gpr_ignore_socket_speed_enabled.setChecked(ignore_socket_speed_enabled)
|
||||
self._legacy_gpr_speed_m_s.setEnabled(ignore_socket_speed_enabled)
|
||||
self._legacy_gpr_look_angle_deg.setValue(float(gui_state.processing.legacy_gpr.look_angle_deg))
|
||||
self._legacy_gpr_apply_freq_phase_correction.setChecked(
|
||||
bool(gui_state.processing.legacy_gpr.apply_freq_phase_correction)
|
||||
@@ -566,32 +547,13 @@ class AppWindowConfigProfileIOMixin:
|
||||
self._sar3d_speed_m_s.setValue(float(sar3d_state.speed_m_s))
|
||||
self._sar3d_ignore_socket_speed_enabled.setChecked(bool(sar3d_state.ignore_socket_speed_enabled))
|
||||
self._sar3d_look_angle_deg.setValue(float(sar3d_state.look_angle_deg))
|
||||
self._sar3d_direction_sign.setValue(float(sar3d_state.direction_sign))
|
||||
self._sar3d_apply_freq_phase_correction.setChecked(bool(sar3d_state.apply_freq_phase_correction))
|
||||
self._sar3d_frame_radius.setValue(int(sar3d_state.frame_radius))
|
||||
self._sar3d_edge_frame_weight.setValue(float(sar3d_state.edge_frame_weight))
|
||||
self._sar3d_pitch_override_enabled.setChecked(bool(sar3d_state.pitch_override_enabled))
|
||||
self._sar3d_pitch_deg.setValue(float(sar3d_state.pitch_deg))
|
||||
self._sar3d_ground_depth_m.setValue(float(sar3d_state.ground_depth_m))
|
||||
self._sar3d_x_min_m.setValue(float(sar3d_state.x_min_m))
|
||||
self._sar3d_x_max_m.setValue(float(sar3d_state.x_max_m))
|
||||
self._sar3d_y_min_m.setValue(float(sar3d_state.y_min_m))
|
||||
self._sar3d_y_max_m.setValue(float(sar3d_state.y_max_m))
|
||||
self._sar3d_z_min_m.setValue(float(sar3d_state.z_min_m))
|
||||
self._sar3d_z_max_m.setValue(float(sar3d_state.z_max_m))
|
||||
self._sar3d_voxel_x_m.setValue(float(sar3d_state.voxel_x_m))
|
||||
self._sar3d_voxel_y_m.setValue(float(sar3d_state.voxel_y_m))
|
||||
self._sar3d_voxel_z_m.setValue(float(sar3d_state.voxel_z_m))
|
||||
self._sar3d_norm_edge_margin_m.setValue(float(sar3d_state.norm_edge_margin_m))
|
||||
self._sar3d_smooth_sigma.setValue(float(sar3d_state.smooth_sigma))
|
||||
self._sar3d_voxel_m.setValue(float(sar3d_state.voxel_m))
|
||||
self._sar3d_bp_ground_depth_m.setValue(float(sar3d_state.bp_ground_depth_m))
|
||||
self._sar3d_comp_range_power.setValue(float(sar3d_state.comp_range_power))
|
||||
self._sar3d_tx_per_segment.setValue(int(sar3d_state.tx_per_segment))
|
||||
self._sar3d_use_compensation.setChecked(bool(sar3d_state.use_compensation))
|
||||
self._sar3d_normalize_pairs.setChecked(bool(sar3d_state.normalize_pairs))
|
||||
self._set_combo_current_text(
|
||||
self._sar3d_bottom_view,
|
||||
sar3d_bottom_view_label_from_deg(float(sar3d_state.bottom_view_deg)),
|
||||
)
|
||||
self._sar3d_normalize_each_frame.setChecked(bool(sar3d_state.normalize_each_frame))
|
||||
self._sync_socket_speed_controls()
|
||||
|
||||
self._save_count.setValue(int(gui_state.data_actions.save_count))
|
||||
self._record_count.setValue(int(gui_state.data_actions.record_count))
|
||||
|
||||
@@ -25,7 +25,6 @@ from python_app.orchestration.preprocess_assets import (
|
||||
VISIBLE_PREPROCESS_ASSET_KEYS,
|
||||
preprocess_asset_model,
|
||||
)
|
||||
from python_app.gui.runtime.sar3d_views import sar3d_bottom_view_deg_from_label
|
||||
from python_app.storage.npz_store import radar_key_from_config
|
||||
|
||||
|
||||
@@ -434,31 +433,12 @@ class AppWindowConfigStateBuildersMixin:
|
||||
speed_m_s=float(self._sar3d_speed_m_s.value()),
|
||||
ignore_socket_speed_enabled=bool(self._sar3d_ignore_socket_speed_enabled.isChecked()),
|
||||
look_angle_deg=float(self._sar3d_look_angle_deg.value()),
|
||||
direction_sign=float(self._sar3d_direction_sign.value()),
|
||||
apply_freq_phase_correction=bool(self._sar3d_apply_freq_phase_correction.isChecked()),
|
||||
frame_radius=int(self._sar3d_frame_radius.value()),
|
||||
edge_frame_weight=float(self._sar3d_edge_frame_weight.value()),
|
||||
pitch_override_enabled=bool(self._sar3d_pitch_override_enabled.isChecked()),
|
||||
pitch_deg=float(self._sar3d_pitch_deg.value()),
|
||||
ground_depth_m=float(self._sar3d_ground_depth_m.value()),
|
||||
x_min_m=float(self._sar3d_x_min_m.value()),
|
||||
x_max_m=float(self._sar3d_x_max_m.value()),
|
||||
y_min_m=float(self._sar3d_y_min_m.value()),
|
||||
y_max_m=float(self._sar3d_y_max_m.value()),
|
||||
z_min_m=float(self._sar3d_z_min_m.value()),
|
||||
z_max_m=float(self._sar3d_z_max_m.value()),
|
||||
voxel_x_m=float(self._sar3d_voxel_x_m.value()),
|
||||
voxel_y_m=float(self._sar3d_voxel_y_m.value()),
|
||||
voxel_z_m=float(self._sar3d_voxel_z_m.value()),
|
||||
norm_edge_margin_m=float(self._sar3d_norm_edge_margin_m.value()),
|
||||
smooth_sigma=float(self._sar3d_smooth_sigma.value()),
|
||||
voxel_m=float(self._sar3d_voxel_m.value()),
|
||||
bp_ground_depth_m=float(self._sar3d_bp_ground_depth_m.value()),
|
||||
comp_range_power=float(self._sar3d_comp_range_power.value()),
|
||||
tx_per_segment=int(self._sar3d_tx_per_segment.value()),
|
||||
use_compensation=bool(self._sar3d_use_compensation.isChecked()),
|
||||
normalize_pairs=bool(self._sar3d_normalize_pairs.isChecked()),
|
||||
bottom_view_deg=sar3d_bottom_view_deg_from_label(
|
||||
self._sar3d_bottom_view.currentText()
|
||||
),
|
||||
normalize_each_frame=bool(self._sar3d_normalize_each_frame.isChecked()),
|
||||
),
|
||||
),
|
||||
data_actions=GuiDataActionsStateModel(
|
||||
|
||||
@@ -1,162 +1,158 @@
|
||||
"""3D SAR projection rendering helpers.
|
||||
"""3D SAR views of the volume published by the sar3d processor.
|
||||
|
||||
The sar3d processor publishes two normalized max-projections of the imaged
|
||||
volume as collection-level ImageF32 payloads, matching the legacy render
|
||||
conventions (X across, Z range/"вдаль", Y up):
|
||||
Scene axes: X across, Y height above the ground (Y = 0 is the ground), Z along
|
||||
the ground away from the radar. Payloads, the volume normalized to a peak of 1:
|
||||
|
||||
- ``sar3d_proj_xz`` — top view: X (m) horizontal, Z/range (m) vertical
|
||||
(max over the height Y), like the classic GPR heatmap;
|
||||
- ``sar3d_proj_bottom`` — switchable bottom view: height Y (m) vertical, the
|
||||
horizontal axis is the ground direction rotated by ``sar3d_bottom_view_deg``
|
||||
around Y (0° = front view X, 90° = side view Z, 30°/60° in between);
|
||||
|
||||
plus ``sar3d_peak`` (ScalarF32), the volume peak before normalization. Both
|
||||
projections are drawn on their own PlotWidgets stacked in one plot-stack page
|
||||
(top view on top), mirroring how the pass-through page stacks its two plots.
|
||||
- ``sar3d_front``, ``sar3d_side``, ``sar3d_top``: maximum projections onto X-Y
|
||||
(looking ahead from the radar), Z-Y (from the side) and X-Z (from above);
|
||||
- ``sar3d_oblique``: orthographic projection along the camera of the notebook's
|
||||
3D scene; ``sar3d_oblique_basis`` (2x3) gives its image axes u and v as scene
|
||||
directions, which places the scene box, the ground and the maximum on it;
|
||||
- ``sar3d_peak``: [level, X, Y, Z] of the volume maximum before normalization.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt6.QtCore import QRectF
|
||||
import itertools
|
||||
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from PyQt6.QtCore import QRectF, Qt
|
||||
|
||||
from python_app.gui.runtime.sar3d_views import sar3d_bottom_view_deg_from_label
|
||||
from python_app.gui.controllers.app_window_plot.bscan_plot_mixin import build_lut
|
||||
from python_app.models.dataset_model import ResultCollection
|
||||
|
||||
# Fraction of the projection maximum below which the heatmap is not highlighted
|
||||
# (same as cloud-threshold=0.75 in the VNA_AlGo 3D SAR animation): the color
|
||||
# scale is stretched over [frac, 1], everything below falls into the darkest
|
||||
# LUT color.
|
||||
HEATMAP_DISPLAY_THRESHOLD_FRAC = 0.75
|
||||
# Colours start at this fraction of the maximum; weaker voxels stay transparent,
|
||||
# like those under the threshold of the notebook's point cloud.
|
||||
DISPLAY_THRESHOLD = 0.75
|
||||
|
||||
# payload -> (title, x label, y label, scene axes (0 = X, 1 = Y, 2 = Z) along the plot axes)
|
||||
SAR3D_VIEWS: dict[str, tuple[str, str, str, tuple[int, int] | None]] = {
|
||||
"sar3d_front": ("Front (X-Y)", "X", "Y", (0, 1)),
|
||||
"sar3d_side": ("Side (Z-Y)", "Z", "Y", (2, 1)),
|
||||
"sar3d_top": ("Top (X-Z)", "X", "Z", (0, 2)),
|
||||
"sar3d_oblique": ("3D", "", "", None),
|
||||
}
|
||||
|
||||
# Corners of the scene box indexed by bits (x, y, z); an edge joins corners one bit apart.
|
||||
_BOX_EDGES = [(a, b) for a, b in itertools.combinations(range(8), 2) if bin(a ^ b).count("1") == 1]
|
||||
# Axis names go on the edges meeting at the bottom corner nearest the camera (X max, Y min, Z min).
|
||||
_AXIS_EDGES = {"X": (0, 1), "Y": (1, 3), "Z": (1, 5)}
|
||||
|
||||
|
||||
def _image_rect(x_axis: np.ndarray, y_axis: np.ndarray) -> QRectF:
|
||||
"""Rectangle covering the pixels centred on the axis samples."""
|
||||
dx = (x_axis[-1] - x_axis[0]) / max(len(x_axis) - 1, 1)
|
||||
dy = (y_axis[-1] - y_axis[0]) / max(len(y_axis) - 1, 1)
|
||||
return QRectF(x_axis[0] - dx / 2, y_axis[0] - dy / 2, x_axis[-1] - x_axis[0] + dx, y_axis[-1] - y_axis[0] + dy)
|
||||
|
||||
|
||||
class AppWindowSar3dPlotMixin:
|
||||
"""Renders 3D SAR volume projections published by the sar3d processor."""
|
||||
"""Draws the 3D SAR views."""
|
||||
|
||||
def _sar3d_bottom_view_labels(self) -> tuple[str, str]:
|
||||
"""Return (plot title, horizontal axis label) for the active bottom view.
|
||||
def _init_sar3d_views(self) -> None:
|
||||
"""Create the persistent items of the four 3D SAR plots."""
|
||||
lut = build_lut(["#081c15", "#1b4332", "#ffd166", "#f94144"]) # the GPR palette
|
||||
lut = np.column_stack([lut, np.full(len(lut), 255, dtype=np.uint8)])
|
||||
lut[0, 3] = 0 # below DISPLAY_THRESHOLD
|
||||
self._sar3d_images = {}
|
||||
self._sar3d_peaks = {}
|
||||
for name, (_title, x_label, y_label, _axes) in SAR3D_VIEWS.items():
|
||||
plot = self._sar3d_plots[name]
|
||||
self._disable_gpr_plot_interaction(plot, plot.getViewBox())
|
||||
plot.getViewBox().enableAutoRange(x=False, y=False)
|
||||
image = pg.ImageItem(axisOrder="row-major", lut=lut)
|
||||
plot.addItem(image)
|
||||
# Hollow, so the response at the maximum stays visible.
|
||||
peak = pg.ScatterPlotItem(symbol="star", size=18, pen=pg.mkPen("w", width=1.5), brush=None)
|
||||
peak.setZValue(3)
|
||||
plot.addItem(peak)
|
||||
self._sar3d_images[name] = image
|
||||
self._sar3d_peaks[name] = peak
|
||||
if x_label:
|
||||
plot.showGrid(x=True, y=True, alpha=0.2)
|
||||
plot.setLabel("bottom", x_label, units="m")
|
||||
plot.setLabel("left", y_label, units="m")
|
||||
if y_label == "Y":
|
||||
ground = pg.InfiniteLine(pos=0.0, angle=0, pen=pg.mkPen("w", width=1, style=Qt.PenStyle.DashLine))
|
||||
ground.setZValue(2)
|
||||
plot.addItem(ground)
|
||||
|
||||
The plot surfaces are created before the processing section builds the
|
||||
bottom-view combo, so fall back to the front view until it exists.
|
||||
"""
|
||||
combo = getattr(self, "_sar3d_bottom_view", None)
|
||||
degrees = sar3d_bottom_view_deg_from_label(combo.currentText()) if combo is not None else 0.0
|
||||
if degrees <= 1e-9:
|
||||
return "3D SAR front view (X-Y)", "X"
|
||||
if degrees >= 90.0 - 1e-9:
|
||||
return "3D SAR side view (Z-Y)", "Range Z"
|
||||
return f"3D SAR view @ {degrees:g}\N{DEGREE SIGN}", f"u({degrees:g}\N{DEGREE SIGN})"
|
||||
|
||||
def _configure_sar3d_plot_axes(self) -> None:
|
||||
"""Apply persistent axis labels and view settings to both projection plots."""
|
||||
_bottom_title, bottom_x_label = self._sar3d_bottom_view_labels()
|
||||
for plot, x_label, y_label in (
|
||||
(self._sar3d_xz_plot, "X", "Range Z"),
|
||||
(self._sar3d_xy_plot, bottom_x_label, "Height Y"),
|
||||
):
|
||||
plot_item = plot.getPlotItem()
|
||||
plot_item.showAxis("left", show=True)
|
||||
plot_item.showAxis("bottom", show=True)
|
||||
plot_item.setClipToView(True)
|
||||
plot.setLabel("bottom", x_label, units="m")
|
||||
plot.setLabel("left", y_label, units="m")
|
||||
view_box = plot.getViewBox()
|
||||
view_box.invertY(False)
|
||||
view_box.enableAutoRange(x=False, y=False)
|
||||
self._disable_gpr_plot_interaction(plot, view_box)
|
||||
|
||||
def _ensure_sar3d_plot_items(self) -> None:
|
||||
"""Create persistent projection image items once and reuse them on redraw."""
|
||||
if self._sar3d_xz_image_item is not None:
|
||||
return
|
||||
|
||||
self._configure_sar3d_plot_axes()
|
||||
if self._sar3d_lookup_table is None:
|
||||
# Same palette as the GPR accumulator so both imagers read alike.
|
||||
self._sar3d_lookup_table = self._build_lut(["#081c15", "#1b4332", "#ffd166", "#f94144"])
|
||||
|
||||
self._sar3d_xz_image_item = pg.ImageItem(axisOrder="row-major")
|
||||
self._sar3d_xz_image_item.setZValue(0)
|
||||
self._sar3d_xz_image_item.hide()
|
||||
self._sar3d_xz_plot.addItem(self._sar3d_xz_image_item)
|
||||
|
||||
self._sar3d_xy_image_item = pg.ImageItem(axisOrder="row-major")
|
||||
self._sar3d_xy_image_item.setZValue(0)
|
||||
self._sar3d_xy_image_item.hide()
|
||||
self._sar3d_xy_plot.addItem(self._sar3d_xy_image_item)
|
||||
oblique = self._sar3d_plots["sar3d_oblique"]
|
||||
oblique.hideAxis("bottom")
|
||||
oblique.hideAxis("left")
|
||||
oblique.setAspectLocked(True)
|
||||
# The notebook scene's ground, under the image so it shows through weak voxels.
|
||||
self._sar3d_ground = pg.PlotCurveItem(
|
||||
pen=pg.mkPen(115, 105, 88), brush=pg.mkBrush(115, 105, 88, 110), fillLevel="enclosed"
|
||||
)
|
||||
self._sar3d_ground.setZValue(-1)
|
||||
oblique.addItem(self._sar3d_ground)
|
||||
self._sar3d_box = pg.PlotCurveItem(pen=pg.mkPen(190, 190, 190, 170), connect="pairs")
|
||||
self._sar3d_box.setZValue(2)
|
||||
oblique.addItem(self._sar3d_box)
|
||||
self._sar3d_axis_labels = {}
|
||||
for axis_name in _AXIS_EDGES:
|
||||
label = pg.TextItem(axis_name, color=(190, 190, 190), anchor=(0.5, 0.5))
|
||||
oblique.addItem(label)
|
||||
self._sar3d_axis_labels[axis_name] = label
|
||||
|
||||
def _clear_sar3d_plot(self) -> None:
|
||||
"""Clear both 3D SAR projection surfaces."""
|
||||
if not hasattr(self, "_sar3d_xz_plot"):
|
||||
return
|
||||
self._configure_sar3d_plot_axes()
|
||||
if self._sar3d_xz_image_item is not None:
|
||||
self._sar3d_xz_image_item.hide()
|
||||
if self._sar3d_xy_image_item is not None:
|
||||
self._sar3d_xy_image_item.hide()
|
||||
bottom_title, _bottom_x_label = self._sar3d_bottom_view_labels()
|
||||
self._sar3d_xz_plot.setTitle("3D SAR top view (X-Z)")
|
||||
self._sar3d_xy_plot.setTitle(bottom_title)
|
||||
|
||||
def _draw_sar3d_projection(self, plot: pg.PlotWidget, image_item: pg.ImageItem, payload) -> bool:
|
||||
"""Draw one normalized projection payload onto its plot; return success."""
|
||||
image = np.asarray(payload.image, dtype=np.float32)
|
||||
x_axis = np.asarray(payload.image_x_axis, dtype=np.float32)
|
||||
y_axis = np.asarray(payload.image_y_axis, dtype=np.float32)
|
||||
if image.ndim != 2 or image.size == 0 or x_axis.size == 0 or y_axis.size == 0:
|
||||
image_item.hide()
|
||||
return False
|
||||
|
||||
x_min = float(x_axis[0])
|
||||
x_max = float(x_axis[-1])
|
||||
y_min = float(y_axis[0])
|
||||
y_max = float(y_axis[-1])
|
||||
rect = QRectF(x_min, y_min, max(x_max - x_min, 1e-6), max(y_max - y_min, 1e-6))
|
||||
|
||||
image_item.setImage(image, autoLevels=False)
|
||||
image_item.setRect(rect)
|
||||
image_item.setLookupTable(self._sar3d_lookup_table)
|
||||
# Projections are normalized to [0, 1] by the processor; stretch the
|
||||
# colors over [threshold, 1] like the 3D animation cloud cutoff.
|
||||
image_item.setLevels((HEATMAP_DISPLAY_THRESHOLD_FRAC, 1.0))
|
||||
image_item.show()
|
||||
plot.setXRange(x_min, x_max, padding=0.0)
|
||||
plot.setYRange(y_min, y_max, padding=0.0)
|
||||
return True
|
||||
"""Clear all four 3D SAR views."""
|
||||
for name, (title, *_rest) in SAR3D_VIEWS.items():
|
||||
self._sar3d_images[name].hide()
|
||||
self._sar3d_peaks[name].setData([], [])
|
||||
self._sar3d_plots[name].setTitle(title)
|
||||
self._sar3d_box.setData([], [])
|
||||
self._sar3d_ground.setData([], [])
|
||||
|
||||
def _draw_sar3d_projections(self, collection: ResultCollection) -> bool:
|
||||
"""Draw the latest collection-level 3D SAR top and bottom projections."""
|
||||
xz_payload = self._collection_payload_by_name(collection, "sar3d_proj_xz", kind=3)
|
||||
bottom_payload = self._collection_payload_by_name(collection, "sar3d_proj_bottom", kind=3)
|
||||
if xz_payload is None and bottom_payload is None:
|
||||
"""Draw the 3D SAR views of a result collection; return success."""
|
||||
payloads = {name: self._collection_payload_by_name(collection, name, kind=3) for name in SAR3D_VIEWS}
|
||||
basis = self._collection_payload_by_name(collection, "sar3d_oblique_basis", kind=4)
|
||||
if basis is None or any(payload is None or payload.image.size == 0 for payload in payloads.values()):
|
||||
self._clear_sar3d_plot()
|
||||
return False
|
||||
peak = self._collection_payload_by_name(collection, "sar3d_peak", kind=4)
|
||||
level, *peak_xyz = peak.table[0] if peak is not None else (None,)
|
||||
|
||||
for plot in (self._sar3d_xz_plot, self._sar3d_xy_plot):
|
||||
plot.setUpdatesEnabled(False)
|
||||
try:
|
||||
self._ensure_sar3d_plot_items()
|
||||
# Axis labels track the bottom-view selector (payload axes change with it).
|
||||
self._configure_sar3d_plot_axes()
|
||||
xz_drawn = xz_payload is not None and self._draw_sar3d_projection(
|
||||
self._sar3d_xz_plot, self._sar3d_xz_image_item, xz_payload
|
||||
)
|
||||
bottom_drawn = bottom_payload is not None and self._draw_sar3d_projection(
|
||||
self._sar3d_xy_plot, self._sar3d_xy_image_item, bottom_payload
|
||||
)
|
||||
if not xz_drawn and not bottom_drawn:
|
||||
self._clear_sar3d_plot()
|
||||
return False
|
||||
for name, payload in payloads.items():
|
||||
title, _x_label, _y_label, axes = SAR3D_VIEWS[name]
|
||||
rect = _image_rect(payload.image_x_axis, payload.image_y_axis)
|
||||
image = self._sar3d_images[name]
|
||||
image.setImage(payload.image, autoLevels=False, levels=(DISPLAY_THRESHOLD, 1.0))
|
||||
image.setRect(rect)
|
||||
image.show()
|
||||
plot = self._sar3d_plots[name]
|
||||
plot.setRange(rect, padding=0.0)
|
||||
if peak_xyz:
|
||||
position = basis.table @ np.asarray(peak_xyz) if axes is None else [peak_xyz[axis] for axis in axes]
|
||||
self._sar3d_peaks[name].setData([position[0]], [position[1]])
|
||||
else:
|
||||
self._sar3d_peaks[name].setData([], [])
|
||||
plot.setTitle(title)
|
||||
|
||||
peak_payload = self._collection_payload_by_name(collection, "sar3d_peak", kind=2)
|
||||
peak_suffix = ""
|
||||
if peak_payload is not None:
|
||||
peak_suffix = f" | peak={float(peak_payload.scalar_value):.3g}"
|
||||
bottom_title, _bottom_x_label = self._sar3d_bottom_view_labels()
|
||||
self._sar3d_xz_plot.setTitle(f"3D SAR top view (X-Z){peak_suffix}")
|
||||
self._sar3d_xy_plot.setTitle(bottom_title)
|
||||
finally:
|
||||
for plot in (self._sar3d_xz_plot, self._sar3d_xy_plot):
|
||||
plot.setUpdatesEnabled(True)
|
||||
# Scene box and ground on the oblique view, from the extents of the other views.
|
||||
front, top = payloads["sar3d_front"], payloads["sar3d_top"]
|
||||
bounds = [
|
||||
(front.image_x_axis[0], front.image_x_axis[-1]),
|
||||
(front.image_y_axis[0], front.image_y_axis[-1]),
|
||||
(top.image_y_axis[0], top.image_y_axis[-1]),
|
||||
]
|
||||
corners = np.array([[bounds[axis][(index >> axis) & 1] for axis in range(3)] for index in range(8)])
|
||||
projected = corners @ basis.table.T
|
||||
box = projected[np.ravel(_BOX_EDGES)]
|
||||
self._sar3d_box.setData(box[:, 0], box[:, 1])
|
||||
centre = projected.mean(axis=0)
|
||||
for axis_name, (start, end) in _AXIS_EDGES.items():
|
||||
middle = (projected[start] + projected[end]) / 2
|
||||
self._sar3d_axis_labels[axis_name].setPos(*(middle + 0.08 * (middle - centre))) # just outside
|
||||
(x0, x1), _, (z0, z1) = bounds
|
||||
ground = (np.array([[x0, 0.0, z0], [x1, 0.0, z0], [x1, 0.0, z1], [x0, 0.0, z1], [x0, 0.0, z0]]) @ basis.table.T).T
|
||||
self._sar3d_ground.setData(ground[0], ground[1])
|
||||
if level is not None:
|
||||
x, y, z = peak_xyz
|
||||
self._sar3d_plots["sar3d_oblique"].setTitle(
|
||||
f"3D | max X {x:+.2f}, Y {y:+.2f}, Z {z:.2f} m | level {level:.3g}"
|
||||
)
|
||||
return True
|
||||
|
||||
@@ -13,6 +13,7 @@ from __future__ import annotations
|
||||
from PyQt6.QtWidgets import (
|
||||
QComboBox,
|
||||
QFrame,
|
||||
QGridLayout,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
@@ -25,6 +26,7 @@ from PyQt6.QtWidgets import (
|
||||
)
|
||||
import pyqtgraph as pg
|
||||
|
||||
from python_app.gui.controllers.app_window_plot.sar3d_plot_mixin import SAR3D_VIEWS
|
||||
from python_app.gui.controllers.sections import (
|
||||
build_data_actions_group,
|
||||
build_primary_actions_group,
|
||||
@@ -141,23 +143,16 @@ class AppWindowUiMixin:
|
||||
self._plot_stack.addWidget(self._gpr_plot)
|
||||
|
||||
def _build_sar3d_plot_page(self) -> None:
|
||||
"""Create 3D SAR page with stacked side (X-Z) and top (X-Y) projection plots."""
|
||||
# 3D SAR surface: container with two projection heatmaps, mirroring the
|
||||
# pass-through page layout (two stacked PlotWidgets on one stack page).
|
||||
"""Create the 3D SAR page: front, side, top and 3D views in a 2x2 grid."""
|
||||
self._sar3d_plots_container = QWidget()
|
||||
sar3d_layout = QVBoxLayout(self._sar3d_plots_container)
|
||||
sar3d_layout.setContentsMargins(0, 0, 0, 0)
|
||||
sar3d_layout.setSpacing(6)
|
||||
|
||||
self._sar3d_xz_plot = self._create_plot_widget(background="#0f141c")
|
||||
self._sar3d_xz_plot.showGrid(x=True, y=True, alpha=0.2)
|
||||
sar3d_layout.addWidget(self._sar3d_xz_plot, stretch=1)
|
||||
|
||||
self._sar3d_xy_plot = self._create_plot_widget(background="#0f141c")
|
||||
self._sar3d_xy_plot.showGrid(x=True, y=True, alpha=0.2)
|
||||
sar3d_layout.addWidget(self._sar3d_xy_plot, stretch=1)
|
||||
|
||||
self._configure_sar3d_plot_axes()
|
||||
layout = QGridLayout(self._sar3d_plots_container)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(6)
|
||||
self._sar3d_plots = {}
|
||||
for index, name in enumerate(SAR3D_VIEWS):
|
||||
self._sar3d_plots[name] = self._create_plot_widget(background="#0f141c")
|
||||
layout.addWidget(self._sar3d_plots[name], index // 2, index % 2)
|
||||
self._init_sar3d_views()
|
||||
self._clear_sar3d_plot()
|
||||
self._plot_stack.addWidget(self._sar3d_plots_container)
|
||||
|
||||
|
||||
@@ -20,9 +20,13 @@ from PyQt6.QtWidgets import (
|
||||
|
||||
from python_app.gui.controllers.app_window_config.state_builders import GUI_SAVE_HISTORY_LIMIT
|
||||
from python_app.gui.controllers.sections.layout_helpers import FormRow, build_two_column_form_widget
|
||||
from python_app.gui.runtime.sar3d_views import (
|
||||
SAR3D_BOTTOM_VIEW_CHOICES,
|
||||
sar3d_bottom_view_label_from_deg,
|
||||
|
||||
|
||||
# Same semantics on every GPR-like page; the data_processor applies them.
|
||||
_IGNORE_SOCKET_SPEED_TOOLTIP = (
|
||||
"Checked: the speed below drives the motion correction. Unchecked: the speed a locator "
|
||||
"socket client streams ('vlc') does, and the field below only stands in while no client "
|
||||
"has sent one in the last 5 s."
|
||||
)
|
||||
|
||||
|
||||
@@ -290,10 +294,7 @@ def build_processing_group(owner) -> QGroupBox:
|
||||
|
||||
owner._gpr_ignore_socket_speed_enabled = QCheckBox("Ignore socket speed")
|
||||
owner._gpr_ignore_socket_speed_enabled.setChecked(bool(gpr_live_defaults.ignore_socket_speed_enabled))
|
||||
owner._gpr_ignore_socket_speed_enabled.setToolTip(
|
||||
"When checked, use the speed below for motion correction instead of the "
|
||||
"value streamed over the locator socket."
|
||||
)
|
||||
owner._gpr_ignore_socket_speed_enabled.setToolTip(_IGNORE_SOCKET_SPEED_TOOLTIP)
|
||||
|
||||
owner._gpr_speed_m_s = QDoubleSpinBox()
|
||||
owner._gpr_speed_m_s.setDecimals(3)
|
||||
@@ -475,12 +476,7 @@ def build_processing_group(owner) -> QGroupBox:
|
||||
owner._legacy_gpr_ignore_socket_speed_enabled.setChecked(
|
||||
bool(legacy_gpr_defaults.ignore_socket_speed_enabled)
|
||||
)
|
||||
# When the box is unchecked, an external TCP client controls the speed
|
||||
# through the C++ locator server; disable the spinner so the GUI value
|
||||
# cannot silently win against the live socket value.
|
||||
owner._legacy_gpr_speed_m_s.setEnabled(
|
||||
bool(legacy_gpr_defaults.ignore_socket_speed_enabled)
|
||||
)
|
||||
owner._legacy_gpr_ignore_socket_speed_enabled.setToolTip(_IGNORE_SOCKET_SPEED_TOOLTIP)
|
||||
|
||||
owner._legacy_gpr_look_angle_deg = QDoubleSpinBox()
|
||||
owner._legacy_gpr_look_angle_deg.setDecimals(2)
|
||||
@@ -575,26 +571,15 @@ def build_processing_group(owner) -> QGroupBox:
|
||||
)
|
||||
owner._processing_mode_pages.addWidget(legacy_gpr_page)
|
||||
|
||||
# 3D SAR page: shared gpr_* knobs get their own widget set (like legacy_gpr)
|
||||
# plus the sar3d-specific aperture/volume knobs consumed by the C++ processor.
|
||||
# 3D SAR page. The labels are the names of the reference notebook
|
||||
# Multi_antenna_3D_clean_v1.ipynb; the shared gpr_* fields get their own
|
||||
# widgets here, like on the legacy page.
|
||||
owner._sar3d_input_positions_input = QLineEdit(str(sar3d_defaults.input_positions))
|
||||
owner._sar3d_input_positions_input.setPlaceholderText("0,1,2")
|
||||
|
||||
owner._sar3d_output_positions_input = QLineEdit(str(sar3d_defaults.output_positions))
|
||||
owner._sar3d_output_positions_input.setPlaceholderText("0,1")
|
||||
|
||||
owner._sar3d_min_depth_m = QDoubleSpinBox()
|
||||
owner._sar3d_min_depth_m.setDecimals(2)
|
||||
owner._sar3d_min_depth_m.setRange(0.0, 50.0)
|
||||
owner._sar3d_min_depth_m.setSingleStep(0.1)
|
||||
owner._sar3d_min_depth_m.setValue(float(sar3d_defaults.min_depth_m))
|
||||
|
||||
owner._sar3d_max_depth_m = QDoubleSpinBox()
|
||||
owner._sar3d_max_depth_m.setDecimals(2)
|
||||
owner._sar3d_max_depth_m.setRange(0.1, 50.0)
|
||||
owner._sar3d_max_depth_m.setSingleStep(0.1)
|
||||
owner._sar3d_max_depth_m.setValue(float(sar3d_defaults.max_depth_m))
|
||||
|
||||
owner._sar3d_start_freq_mhz = QDoubleSpinBox()
|
||||
owner._sar3d_start_freq_mhz.setDecimals(1)
|
||||
owner._sar3d_start_freq_mhz.setRange(100.0, 8800.0)
|
||||
@@ -607,223 +592,98 @@ def build_processing_group(owner) -> QGroupBox:
|
||||
owner._sar3d_stop_freq_mhz.setSingleStep(10.0)
|
||||
owner._sar3d_stop_freq_mhz.setValue(float(sar3d_defaults.stop_freq_mhz))
|
||||
|
||||
owner._sar3d_min_depth_m = QDoubleSpinBox()
|
||||
owner._sar3d_min_depth_m.setDecimals(2)
|
||||
owner._sar3d_min_depth_m.setRange(0.0, 50.0)
|
||||
owner._sar3d_min_depth_m.setSingleStep(0.1)
|
||||
owner._sar3d_min_depth_m.setValue(float(sar3d_defaults.min_depth_m))
|
||||
owner._sar3d_min_depth_m.setToolTip("Radar depth gate start (m); the scene grid along Z follows the gate.")
|
||||
|
||||
owner._sar3d_max_depth_m = QDoubleSpinBox()
|
||||
owner._sar3d_max_depth_m.setDecimals(2)
|
||||
owner._sar3d_max_depth_m.setRange(0.1, 50.0)
|
||||
owner._sar3d_max_depth_m.setSingleStep(0.1)
|
||||
owner._sar3d_max_depth_m.setValue(float(sar3d_defaults.max_depth_m))
|
||||
owner._sar3d_max_depth_m.setToolTip("Radar depth gate end (m); the scene grid along Z follows the gate.")
|
||||
|
||||
owner._sar3d_voxel_m = QDoubleSpinBox()
|
||||
owner._sar3d_voxel_m.setDecimals(3)
|
||||
owner._sar3d_voxel_m.setRange(0.01, 0.1)
|
||||
owner._sar3d_voxel_m.setSingleStep(0.005)
|
||||
owner._sar3d_voxel_m.setValue(float(sar3d_defaults.voxel_m))
|
||||
owner._sar3d_voxel_m.setToolTip(
|
||||
"Edge of the cubic voxel (m), the same along X, Y and Z. Smaller is finer and slower: "
|
||||
"halving it makes 8 times more voxels."
|
||||
)
|
||||
|
||||
owner._sar3d_look_angle_deg = QDoubleSpinBox()
|
||||
owner._sar3d_look_angle_deg.setDecimals(2)
|
||||
owner._sar3d_look_angle_deg.setRange(-90.0, 90.0)
|
||||
owner._sar3d_look_angle_deg.setSingleStep(0.5)
|
||||
owner._sar3d_look_angle_deg.setValue(float(sar3d_defaults.look_angle_deg))
|
||||
owner._sar3d_look_angle_deg.setToolTip(
|
||||
"Radar look angle vs the range axis (deg). Used by intra-sweep motion correction."
|
||||
)
|
||||
|
||||
owner._sar3d_direction_sign = QDoubleSpinBox()
|
||||
owner._sar3d_direction_sign.setDecimals(0)
|
||||
owner._sar3d_direction_sign.setRange(-1.0, 1.0)
|
||||
owner._sar3d_direction_sign.setSingleStep(2.0)
|
||||
owner._sar3d_direction_sign.setValue(float(sar3d_defaults.direction_sign))
|
||||
owner._sar3d_direction_sign.setToolTip(
|
||||
"Motion direction along range: +1 (later frequencies deeper) or -1 (shallower)."
|
||||
"Downward tilt of the radar (deg): motion correction along range and the ground scene axes."
|
||||
)
|
||||
|
||||
owner._sar3d_ignore_socket_speed_enabled = QCheckBox("Ignore socket speed")
|
||||
owner._sar3d_ignore_socket_speed_enabled.setChecked(bool(sar3d_defaults.ignore_socket_speed_enabled))
|
||||
owner._sar3d_ignore_socket_speed_enabled.setToolTip(
|
||||
"When checked, use the speed below for motion correction instead of the "
|
||||
"value streamed over the locator socket."
|
||||
)
|
||||
owner._sar3d_ignore_socket_speed_enabled.setToolTip(_IGNORE_SOCKET_SPEED_TOOLTIP)
|
||||
|
||||
owner._sar3d_speed_m_s = QDoubleSpinBox()
|
||||
owner._sar3d_speed_m_s.setDecimals(3)
|
||||
owner._sar3d_speed_m_s.setRange(-100.0, 100.0)
|
||||
owner._sar3d_speed_m_s.setSingleStep(0.05)
|
||||
owner._sar3d_speed_m_s.setValue(float(sar3d_defaults.speed_m_s))
|
||||
owner._sar3d_speed_m_s.setToolTip(
|
||||
"Radar speed (m/s) used by intra-sweep motion correction when 'Ignore socket speed' is on."
|
||||
)
|
||||
|
||||
owner._sar3d_apply_freq_phase_correction = QCheckBox("Apply intra-sweep phase correction")
|
||||
owner._sar3d_apply_freq_phase_correction.setChecked(bool(sar3d_defaults.apply_freq_phase_correction))
|
||||
|
||||
owner._sar3d_frame_radius = QSpinBox()
|
||||
owner._sar3d_frame_radius.setRange(0, 25)
|
||||
owner._sar3d_frame_radius.setRange(0, 24)
|
||||
owner._sar3d_frame_radius.setValue(int(sar3d_defaults.frame_radius))
|
||||
owner._sar3d_frame_radius.setToolTip(
|
||||
"Synthetic-aperture half width in frames: 2*radius+1 consecutive frames form one aperture."
|
||||
"Aperture of 2*r+1 consecutive frames. 0: the current frame only (needs a vertical antenna "
|
||||
"baseline); >0 adds aperture from motion and needs an accurate speed. The image lags by r frames."
|
||||
)
|
||||
|
||||
owner._sar3d_edge_frame_weight = QDoubleSpinBox()
|
||||
owner._sar3d_edge_frame_weight.setDecimals(2)
|
||||
owner._sar3d_edge_frame_weight.setRange(0.0, 1.0)
|
||||
owner._sar3d_edge_frame_weight.setSingleStep(0.05)
|
||||
owner._sar3d_edge_frame_weight.setValue(float(sar3d_defaults.edge_frame_weight))
|
||||
owner._sar3d_edge_frame_weight.setToolTip(
|
||||
"Weight of the outermost aperture frames (1 = flat window, 0 = strong taper)."
|
||||
)
|
||||
|
||||
owner._sar3d_pitch_override_enabled = QCheckBox("Override pitch")
|
||||
owner._sar3d_pitch_override_enabled.setChecked(bool(sar3d_defaults.pitch_override_enabled))
|
||||
owner._sar3d_pitch_override_enabled.setToolTip(
|
||||
"When unchecked, the frame pitch follows the look angle (notebook "
|
||||
"BP_GROUND_PITCH_DEG = None); check to set it separately below."
|
||||
)
|
||||
|
||||
owner._sar3d_pitch_deg = QDoubleSpinBox()
|
||||
owner._sar3d_pitch_deg.setDecimals(2)
|
||||
owner._sar3d_pitch_deg.setRange(-90.0, 90.0)
|
||||
owner._sar3d_pitch_deg.setSingleStep(0.5)
|
||||
owner._sar3d_pitch_deg.setValue(float(sar3d_defaults.pitch_deg))
|
||||
owner._sar3d_pitch_deg.setToolTip(
|
||||
"Frame pitch (deg) used for ground coordinates and aperture offsets when the override is on."
|
||||
)
|
||||
|
||||
owner._sar3d_ground_depth_m = QDoubleSpinBox()
|
||||
owner._sar3d_ground_depth_m.setDecimals(2)
|
||||
owner._sar3d_ground_depth_m.setRange(0.0, 10.0)
|
||||
owner._sar3d_ground_depth_m.setSingleStep(0.05)
|
||||
owner._sar3d_ground_depth_m.setValue(float(sar3d_defaults.ground_depth_m))
|
||||
owner._sar3d_ground_depth_m.setToolTip(
|
||||
"Height of the lower antenna row above the ground (m); scene Y=0 is the ground."
|
||||
)
|
||||
|
||||
owner._sar3d_x_min_m = QDoubleSpinBox()
|
||||
owner._sar3d_x_min_m.setDecimals(2)
|
||||
owner._sar3d_x_min_m.setRange(-50.0, 50.0)
|
||||
owner._sar3d_x_min_m.setSingleStep(0.1)
|
||||
owner._sar3d_x_min_m.setValue(float(sar3d_defaults.x_min_m))
|
||||
|
||||
owner._sar3d_x_max_m = QDoubleSpinBox()
|
||||
owner._sar3d_x_max_m.setDecimals(2)
|
||||
owner._sar3d_x_max_m.setRange(-50.0, 50.0)
|
||||
owner._sar3d_x_max_m.setSingleStep(0.1)
|
||||
owner._sar3d_x_max_m.setValue(float(sar3d_defaults.x_max_m))
|
||||
|
||||
owner._sar3d_z_min_m = QDoubleSpinBox()
|
||||
owner._sar3d_z_min_m.setDecimals(2)
|
||||
owner._sar3d_z_min_m.setRange(-50.0, 50.0)
|
||||
owner._sar3d_z_min_m.setSingleStep(0.1)
|
||||
owner._sar3d_z_min_m.setValue(float(sar3d_defaults.z_min_m))
|
||||
|
||||
owner._sar3d_z_max_m = QDoubleSpinBox()
|
||||
owner._sar3d_z_max_m.setDecimals(2)
|
||||
owner._sar3d_z_max_m.setRange(-50.0, 50.0)
|
||||
owner._sar3d_z_max_m.setSingleStep(0.1)
|
||||
owner._sar3d_z_max_m.setValue(float(sar3d_defaults.z_max_m))
|
||||
|
||||
owner._sar3d_y_min_m = QDoubleSpinBox()
|
||||
owner._sar3d_y_min_m.setDecimals(2)
|
||||
owner._sar3d_y_min_m.setRange(-10.0, 10.0)
|
||||
owner._sar3d_y_min_m.setSingleStep(0.1)
|
||||
owner._sar3d_y_min_m.setValue(float(sar3d_defaults.y_min_m))
|
||||
|
||||
owner._sar3d_y_max_m = QDoubleSpinBox()
|
||||
owner._sar3d_y_max_m.setDecimals(2)
|
||||
owner._sar3d_y_max_m.setRange(-10.0, 10.0)
|
||||
owner._sar3d_y_max_m.setSingleStep(0.1)
|
||||
owner._sar3d_y_max_m.setValue(float(sar3d_defaults.y_max_m))
|
||||
|
||||
owner._sar3d_voxel_x_m = QDoubleSpinBox()
|
||||
owner._sar3d_voxel_x_m.setDecimals(3)
|
||||
owner._sar3d_voxel_x_m.setRange(0.005, 1.0)
|
||||
owner._sar3d_voxel_x_m.setSingleStep(0.005)
|
||||
owner._sar3d_voxel_x_m.setValue(float(sar3d_defaults.voxel_x_m))
|
||||
|
||||
owner._sar3d_voxel_y_m = QDoubleSpinBox()
|
||||
owner._sar3d_voxel_y_m.setDecimals(3)
|
||||
owner._sar3d_voxel_y_m.setRange(0.005, 1.0)
|
||||
owner._sar3d_voxel_y_m.setSingleStep(0.005)
|
||||
owner._sar3d_voxel_y_m.setValue(float(sar3d_defaults.voxel_y_m))
|
||||
|
||||
owner._sar3d_voxel_z_m = QDoubleSpinBox()
|
||||
owner._sar3d_voxel_z_m.setDecimals(3)
|
||||
owner._sar3d_voxel_z_m.setRange(0.005, 1.0)
|
||||
owner._sar3d_voxel_z_m.setSingleStep(0.005)
|
||||
owner._sar3d_voxel_z_m.setValue(float(sar3d_defaults.voxel_z_m))
|
||||
|
||||
owner._sar3d_norm_edge_margin_m = QDoubleSpinBox()
|
||||
owner._sar3d_norm_edge_margin_m.setDecimals(2)
|
||||
owner._sar3d_norm_edge_margin_m.setRange(0.0, 5.0)
|
||||
owner._sar3d_norm_edge_margin_m.setSingleStep(0.05)
|
||||
owner._sar3d_norm_edge_margin_m.setValue(float(sar3d_defaults.norm_edge_margin_m))
|
||||
owner._sar3d_norm_edge_margin_m.setToolTip(
|
||||
"Grid Y edges excluded from volume normalization (constant-range ridge)."
|
||||
)
|
||||
|
||||
owner._sar3d_smooth_sigma = QDoubleSpinBox()
|
||||
owner._sar3d_smooth_sigma.setDecimals(2)
|
||||
owner._sar3d_smooth_sigma.setRange(0.0, 10.0)
|
||||
owner._sar3d_smooth_sigma.setSingleStep(0.25)
|
||||
owner._sar3d_smooth_sigma.setValue(float(sar3d_defaults.smooth_sigma))
|
||||
owner._sar3d_smooth_sigma.setToolTip("Gaussian smoothing sigma over (Z, X) planes, voxels.")
|
||||
owner._sar3d_bp_ground_depth_m = QDoubleSpinBox()
|
||||
owner._sar3d_bp_ground_depth_m.setDecimals(2)
|
||||
owner._sar3d_bp_ground_depth_m.setRange(0.0, 10.0)
|
||||
owner._sar3d_bp_ground_depth_m.setSingleStep(0.05)
|
||||
owner._sar3d_bp_ground_depth_m.setValue(float(sar3d_defaults.bp_ground_depth_m))
|
||||
owner._sar3d_bp_ground_depth_m.setToolTip("Height of the lower antenna row above the ground (m); scene Y = 0 is the ground.")
|
||||
|
||||
owner._sar3d_comp_range_power = QDoubleSpinBox()
|
||||
owner._sar3d_comp_range_power.setDecimals(2)
|
||||
owner._sar3d_comp_range_power.setRange(0.0, 2.0)
|
||||
owner._sar3d_comp_range_power.setSingleStep(0.05)
|
||||
owner._sar3d_comp_range_power.setValue(float(sar3d_defaults.comp_range_power))
|
||||
owner._sar3d_comp_range_power.setToolTip("Geometric spreading compensation power.")
|
||||
owner._sar3d_comp_range_power.setToolTip("Geometric spreading compensation (Rtx*Rrx)^power, capped at 5.")
|
||||
|
||||
owner._sar3d_tx_per_segment = QSpinBox()
|
||||
owner._sar3d_tx_per_segment.setRange(1, 64)
|
||||
owner._sar3d_tx_per_segment.setValue(int(sar3d_defaults.tx_per_segment))
|
||||
owner._sar3d_tx_per_segment.setToolTip(
|
||||
"Fallback Tx-per-segment split of a frame when per-trace capture stamps are absent."
|
||||
)
|
||||
|
||||
owner._sar3d_use_compensation = QCheckBox("Use per-voxel compensation")
|
||||
owner._sar3d_use_compensation = QCheckBox("SAR_USE_COMPENSATION")
|
||||
owner._sar3d_use_compensation.setChecked(bool(sar3d_defaults.use_compensation))
|
||||
|
||||
owner._sar3d_normalize_pairs = QCheckBox("Normalize Tx/Rx pairs")
|
||||
owner._sar3d_normalize_pairs.setChecked(bool(sar3d_defaults.normalize_pairs))
|
||||
|
||||
owner._sar3d_bottom_view = QComboBox()
|
||||
owner._sar3d_bottom_view.addItems([label for label, _deg in SAR3D_BOTTOM_VIEW_CHOICES])
|
||||
owner._set_combo_current_text(
|
||||
owner._sar3d_bottom_view,
|
||||
sar3d_bottom_view_label_from_deg(float(sar3d_defaults.bottom_view_deg)),
|
||||
)
|
||||
owner._sar3d_bottom_view.setToolTip(
|
||||
"Bottom plot projection: front view (X-Y), side view (Z-Y) or an "
|
||||
"intermediate rotation around the vertical axis. The top plot always "
|
||||
"shows the X-Z top view."
|
||||
)
|
||||
owner._sar3d_normalize_each_frame = QCheckBox("SAR_NORMALIZE_EACH_FRAME")
|
||||
owner._sar3d_normalize_each_frame.setChecked(bool(sar3d_defaults.normalize_each_frame))
|
||||
owner._sar3d_normalize_each_frame.setToolTip("Scale every Tx/Rx A-scan by its median inside the depth gate.")
|
||||
|
||||
sar3d_page = _build_processing_mode_page(
|
||||
owner._processing_mode_pages,
|
||||
[
|
||||
("Input positions", owner._sar3d_input_positions_input),
|
||||
("Output positions", owner._sar3d_output_positions_input),
|
||||
("Min depth m", owner._sar3d_min_depth_m),
|
||||
("Max depth m", owner._sar3d_max_depth_m),
|
||||
("Start MHz", owner._sar3d_start_freq_mhz),
|
||||
("Stop MHz", owner._sar3d_stop_freq_mhz),
|
||||
("Look angle deg", owner._sar3d_look_angle_deg),
|
||||
("Direction sign", owner._sar3d_direction_sign),
|
||||
("INPUT_IDX", owner._sar3d_input_positions_input),
|
||||
("OUTPUT_IDX", owner._sar3d_output_positions_input),
|
||||
("F_START MHz", owner._sar3d_start_freq_mhz),
|
||||
("F_STOP MHz", owner._sar3d_stop_freq_mhz),
|
||||
("MIN_DEPTH", owner._sar3d_min_depth_m),
|
||||
("MAX_DEPTH", owner._sar3d_max_depth_m),
|
||||
("SAR_VOXEL_M", owner._sar3d_voxel_m),
|
||||
("LOOK_ANGLE_DEG", owner._sar3d_look_angle_deg),
|
||||
owner._sar3d_ignore_socket_speed_enabled,
|
||||
("Speed m/s", owner._sar3d_speed_m_s),
|
||||
owner._sar3d_apply_freq_phase_correction,
|
||||
("Frame radius", owner._sar3d_frame_radius),
|
||||
("Edge frame weight", owner._sar3d_edge_frame_weight),
|
||||
owner._sar3d_pitch_override_enabled,
|
||||
("Pitch deg", owner._sar3d_pitch_deg),
|
||||
("Ground depth m", owner._sar3d_ground_depth_m),
|
||||
("X min m", owner._sar3d_x_min_m),
|
||||
("X max m", owner._sar3d_x_max_m),
|
||||
("Y min m", owner._sar3d_y_min_m),
|
||||
("Y max m", owner._sar3d_y_max_m),
|
||||
("Z min m", owner._sar3d_z_min_m),
|
||||
("Z max m", owner._sar3d_z_max_m),
|
||||
("Voxel X m", owner._sar3d_voxel_x_m),
|
||||
("Voxel Y m", owner._sar3d_voxel_y_m),
|
||||
("Voxel Z m", owner._sar3d_voxel_z_m),
|
||||
("Norm edge margin m", owner._sar3d_norm_edge_margin_m),
|
||||
("Smooth sigma", owner._sar3d_smooth_sigma),
|
||||
("Comp range power", owner._sar3d_comp_range_power),
|
||||
("Tx per segment", owner._sar3d_tx_per_segment),
|
||||
("Bottom view", owner._sar3d_bottom_view),
|
||||
("SPEED_M_S", owner._sar3d_speed_m_s),
|
||||
("SAR_FRAME_RADIUS", owner._sar3d_frame_radius),
|
||||
("BP_GROUND_DEPTH_M", owner._sar3d_bp_ground_depth_m),
|
||||
("COMP_RANGE_POWER", owner._sar3d_comp_range_power),
|
||||
owner._sar3d_use_compensation,
|
||||
owner._sar3d_normalize_pairs,
|
||||
owner._sar3d_normalize_each_frame,
|
||||
],
|
||||
split_index=13,
|
||||
split_index=8,
|
||||
)
|
||||
owner._processing_mode_pages.addWidget(sar3d_page)
|
||||
|
||||
@@ -858,7 +718,7 @@ def build_processing_group(owner) -> QGroupBox:
|
||||
owner._gpr_motion_mode.currentTextChanged.connect(owner._on_processing_live_settings_changed)
|
||||
owner._gpr_look_angle_deg.valueChanged.connect(owner._on_processing_live_settings_changed)
|
||||
owner._gpr_direction_sign.valueChanged.connect(owner._on_processing_live_settings_changed)
|
||||
owner._gpr_ignore_socket_speed_enabled.toggled.connect(owner._on_processing_live_settings_changed)
|
||||
owner._gpr_ignore_socket_speed_enabled.toggled.connect(owner._on_ignore_socket_speed_toggled)
|
||||
owner._gpr_speed_m_s.valueChanged.connect(owner._on_processing_live_settings_changed)
|
||||
owner._gpr_start_freq_mhz.valueChanged.connect(owner._on_processing_live_settings_changed)
|
||||
owner._gpr_stop_freq_mhz.valueChanged.connect(owner._on_processing_live_settings_changed)
|
||||
@@ -885,9 +745,7 @@ def build_processing_group(owner) -> QGroupBox:
|
||||
owner._legacy_gpr_start_freq_mhz.valueChanged.connect(owner._on_processing_live_settings_changed)
|
||||
owner._legacy_gpr_stop_freq_mhz.valueChanged.connect(owner._on_processing_live_settings_changed)
|
||||
owner._legacy_gpr_speed_m_s.valueChanged.connect(owner._on_processing_live_settings_changed)
|
||||
owner._legacy_gpr_ignore_socket_speed_enabled.toggled.connect(
|
||||
owner._on_legacy_gpr_ignore_socket_speed_toggled
|
||||
)
|
||||
owner._legacy_gpr_ignore_socket_speed_enabled.toggled.connect(owner._on_ignore_socket_speed_toggled)
|
||||
owner._legacy_gpr_look_angle_deg.valueChanged.connect(owner._on_processing_live_settings_changed)
|
||||
owner._legacy_gpr_apply_freq_phase_correction.toggled.connect(owner._on_processing_live_settings_changed)
|
||||
owner._legacy_gpr_reference_mode.currentTextChanged.connect(owner._on_processing_live_settings_changed)
|
||||
@@ -901,36 +759,23 @@ def build_processing_group(owner) -> QGroupBox:
|
||||
owner._legacy_gpr_visible_z_max_m.valueChanged.connect(owner._on_gpr_locator_window_changed)
|
||||
owner._sar3d_input_positions_input.editingFinished.connect(owner._on_processing_live_settings_changed)
|
||||
owner._sar3d_output_positions_input.editingFinished.connect(owner._on_processing_live_settings_changed)
|
||||
owner._sar3d_min_depth_m.valueChanged.connect(owner._on_processing_live_settings_changed)
|
||||
owner._sar3d_max_depth_m.valueChanged.connect(owner._on_processing_live_settings_changed)
|
||||
owner._sar3d_start_freq_mhz.valueChanged.connect(owner._on_processing_live_settings_changed)
|
||||
owner._sar3d_stop_freq_mhz.valueChanged.connect(owner._on_processing_live_settings_changed)
|
||||
owner._sar3d_look_angle_deg.valueChanged.connect(owner._on_processing_live_settings_changed)
|
||||
owner._sar3d_direction_sign.valueChanged.connect(owner._on_processing_live_settings_changed)
|
||||
owner._sar3d_ignore_socket_speed_enabled.toggled.connect(owner._on_processing_live_settings_changed)
|
||||
owner._sar3d_speed_m_s.valueChanged.connect(owner._on_processing_live_settings_changed)
|
||||
owner._sar3d_apply_freq_phase_correction.toggled.connect(owner._on_processing_live_settings_changed)
|
||||
owner._sar3d_frame_radius.valueChanged.connect(owner._on_processing_live_settings_changed)
|
||||
owner._sar3d_edge_frame_weight.valueChanged.connect(owner._on_processing_live_settings_changed)
|
||||
owner._sar3d_pitch_override_enabled.toggled.connect(owner._on_processing_live_settings_changed)
|
||||
owner._sar3d_pitch_deg.valueChanged.connect(owner._on_processing_live_settings_changed)
|
||||
owner._sar3d_ground_depth_m.valueChanged.connect(owner._on_processing_live_settings_changed)
|
||||
owner._sar3d_x_min_m.valueChanged.connect(owner._on_processing_live_settings_changed)
|
||||
owner._sar3d_x_max_m.valueChanged.connect(owner._on_processing_live_settings_changed)
|
||||
owner._sar3d_z_min_m.valueChanged.connect(owner._on_processing_live_settings_changed)
|
||||
owner._sar3d_z_max_m.valueChanged.connect(owner._on_processing_live_settings_changed)
|
||||
owner._sar3d_y_min_m.valueChanged.connect(owner._on_processing_live_settings_changed)
|
||||
owner._sar3d_y_max_m.valueChanged.connect(owner._on_processing_live_settings_changed)
|
||||
owner._sar3d_voxel_x_m.valueChanged.connect(owner._on_processing_live_settings_changed)
|
||||
owner._sar3d_voxel_y_m.valueChanged.connect(owner._on_processing_live_settings_changed)
|
||||
owner._sar3d_voxel_z_m.valueChanged.connect(owner._on_processing_live_settings_changed)
|
||||
owner._sar3d_norm_edge_margin_m.valueChanged.connect(owner._on_processing_live_settings_changed)
|
||||
owner._sar3d_smooth_sigma.valueChanged.connect(owner._on_processing_live_settings_changed)
|
||||
owner._sar3d_comp_range_power.valueChanged.connect(owner._on_processing_live_settings_changed)
|
||||
owner._sar3d_tx_per_segment.valueChanged.connect(owner._on_processing_live_settings_changed)
|
||||
for widget in (
|
||||
owner._sar3d_start_freq_mhz,
|
||||
owner._sar3d_stop_freq_mhz,
|
||||
owner._sar3d_min_depth_m,
|
||||
owner._sar3d_max_depth_m,
|
||||
owner._sar3d_voxel_m,
|
||||
owner._sar3d_look_angle_deg,
|
||||
owner._sar3d_speed_m_s,
|
||||
owner._sar3d_frame_radius,
|
||||
owner._sar3d_bp_ground_depth_m,
|
||||
owner._sar3d_comp_range_power,
|
||||
):
|
||||
widget.valueChanged.connect(owner._on_processing_live_settings_changed)
|
||||
owner._sar3d_ignore_socket_speed_enabled.toggled.connect(owner._on_ignore_socket_speed_toggled)
|
||||
owner._sar3d_use_compensation.toggled.connect(owner._on_processing_live_settings_changed)
|
||||
owner._sar3d_normalize_pairs.toggled.connect(owner._on_processing_live_settings_changed)
|
||||
owner._sar3d_bottom_view.currentTextChanged.connect(owner._on_processing_live_settings_changed)
|
||||
owner._sar3d_normalize_each_frame.toggled.connect(owner._on_processing_live_settings_changed)
|
||||
|
||||
owner._sync_socket_speed_controls()
|
||||
owner._set_processing_mode_page(owner._processing_mode.currentText())
|
||||
return group
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
"""Shared mapping for the sar3d bottom-view projection choices.
|
||||
|
||||
The bottom plot of the sar3d mode projects the imaged volume along a
|
||||
horizontal direction rotated around the vertical Y axis: 0° = front view
|
||||
(X horizontal), 90° = side view (Z horizontal). One shared mapping keeps the
|
||||
combo box, the live-config build, the profile persistence and the plot labels
|
||||
consistent.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
SAR3D_BOTTOM_VIEW_CHOICES: tuple[tuple[str, float], ...] = (
|
||||
("front (0°)", 0.0),
|
||||
("30°", 30.0),
|
||||
("60°", 60.0),
|
||||
("side (90°)", 90.0),
|
||||
)
|
||||
|
||||
|
||||
def sar3d_bottom_view_deg_from_label(label: str) -> float:
|
||||
"""Map a bottom-view combo label to its projection angle in degrees."""
|
||||
for choice_label, degrees in SAR3D_BOTTOM_VIEW_CHOICES:
|
||||
if choice_label == label:
|
||||
return degrees
|
||||
return 0.0
|
||||
|
||||
|
||||
def sar3d_bottom_view_label_from_deg(degrees: float) -> str:
|
||||
"""Map a projection angle to the nearest bottom-view combo label."""
|
||||
return min(SAR3D_BOTTOM_VIEW_CHOICES, key=lambda choice: abs(choice[1] - float(degrees)))[0]
|
||||
@@ -4,6 +4,7 @@ Encodes commands to wire bytes, decodes device responses, and manages the
|
||||
serial port connection to the laser control board.
|
||||
"""
|
||||
|
||||
import errno
|
||||
import logging
|
||||
import struct
|
||||
from typing import Optional
|
||||
@@ -204,9 +205,9 @@ class Protocol:
|
||||
exclusive=True,
|
||||
)
|
||||
except serial.SerialException as exc:
|
||||
# pyserial raises SerialException with errno EAGAIN/EWOULDBLOCK (11)
|
||||
# when another process already holds the exclusive flock.
|
||||
if getattr(exc, "errno", None) in (11,) or "exclusively lock" in str(exc).lower():
|
||||
# pyserial raises SerialException with errno EWOULDBLOCK when another
|
||||
# process already holds the exclusive flock.
|
||||
if getattr(exc, "errno", None) == errno.EWOULDBLOCK or "exclusively lock" in str(exc).lower():
|
||||
logger.error("Laser serial port '%s' is busy (held by another program)", port)
|
||||
raise PortBusyError(port) from exc
|
||||
logger.error("Cannot open laser serial port '%s': %s", port, exc)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
@@ -172,6 +173,21 @@ def gui_profile_from_dict(payload: dict[str, Any]) -> GuiProfileModel:
|
||||
legacy_gpr_context,
|
||||
)
|
||||
|
||||
sar3d_context = "gui.processing.sar3d"
|
||||
sar3d_defaults = GuiSar3dStateModel()
|
||||
|
||||
def sar3d_string(key: str) -> str:
|
||||
return _optional_string(sar3d_object, key, getattr(sar3d_defaults, key), sar3d_context)
|
||||
|
||||
def sar3d_bool(key: str) -> bool:
|
||||
return _optional_bool(sar3d_object, key, getattr(sar3d_defaults, key), sar3d_context)
|
||||
|
||||
def sar3d_int(key: str) -> int:
|
||||
return _optional_int(sar3d_object, key, getattr(sar3d_defaults, key), sar3d_context)
|
||||
|
||||
def sar3d_float(key: str) -> float:
|
||||
return _optional_float(sar3d_object, key, getattr(sar3d_defaults, key), sar3d_context)
|
||||
|
||||
gui.processing = GuiProcessingStateModel(
|
||||
selected_mode=selected_mode,
|
||||
pass_through=GuiPassThroughStateModel(
|
||||
@@ -455,198 +471,21 @@ def gui_profile_from_dict(payload: dict[str, Any]) -> GuiProfileModel:
|
||||
visible_z_max_m=legacy_float("visible_z_max_m", gui.processing.legacy_gpr.visible_z_max_m),
|
||||
),
|
||||
sar3d=GuiSar3dStateModel(
|
||||
input_positions=_optional_string(
|
||||
sar3d_object,
|
||||
"input_positions",
|
||||
gui.processing.sar3d.input_positions,
|
||||
"gui.processing.sar3d",
|
||||
),
|
||||
output_positions=_optional_string(
|
||||
sar3d_object,
|
||||
"output_positions",
|
||||
gui.processing.sar3d.output_positions,
|
||||
"gui.processing.sar3d",
|
||||
),
|
||||
min_depth_m=_optional_float(
|
||||
sar3d_object,
|
||||
"min_depth_m",
|
||||
gui.processing.sar3d.min_depth_m,
|
||||
"gui.processing.sar3d",
|
||||
),
|
||||
max_depth_m=_optional_float(
|
||||
sar3d_object,
|
||||
"max_depth_m",
|
||||
gui.processing.sar3d.max_depth_m,
|
||||
"gui.processing.sar3d",
|
||||
),
|
||||
start_freq_mhz=_optional_float(
|
||||
sar3d_object,
|
||||
"start_freq_mhz",
|
||||
gui.processing.sar3d.start_freq_mhz,
|
||||
"gui.processing.sar3d",
|
||||
),
|
||||
stop_freq_mhz=_optional_float(
|
||||
sar3d_object,
|
||||
"stop_freq_mhz",
|
||||
gui.processing.sar3d.stop_freq_mhz,
|
||||
"gui.processing.sar3d",
|
||||
),
|
||||
speed_m_s=_optional_float(
|
||||
sar3d_object,
|
||||
"speed_m_s",
|
||||
gui.processing.sar3d.speed_m_s,
|
||||
"gui.processing.sar3d",
|
||||
),
|
||||
ignore_socket_speed_enabled=_optional_bool(
|
||||
sar3d_object,
|
||||
"ignore_socket_speed_enabled",
|
||||
gui.processing.sar3d.ignore_socket_speed_enabled,
|
||||
"gui.processing.sar3d",
|
||||
),
|
||||
look_angle_deg=_optional_float(
|
||||
sar3d_object,
|
||||
"look_angle_deg",
|
||||
gui.processing.sar3d.look_angle_deg,
|
||||
"gui.processing.sar3d",
|
||||
),
|
||||
direction_sign=_optional_float(
|
||||
sar3d_object,
|
||||
"direction_sign",
|
||||
gui.processing.sar3d.direction_sign,
|
||||
"gui.processing.sar3d",
|
||||
),
|
||||
apply_freq_phase_correction=_optional_bool(
|
||||
sar3d_object,
|
||||
"apply_freq_phase_correction",
|
||||
gui.processing.sar3d.apply_freq_phase_correction,
|
||||
"gui.processing.sar3d",
|
||||
),
|
||||
frame_radius=_optional_int(
|
||||
sar3d_object,
|
||||
"frame_radius",
|
||||
gui.processing.sar3d.frame_radius,
|
||||
"gui.processing.sar3d",
|
||||
),
|
||||
edge_frame_weight=_optional_float(
|
||||
sar3d_object,
|
||||
"edge_frame_weight",
|
||||
gui.processing.sar3d.edge_frame_weight,
|
||||
"gui.processing.sar3d",
|
||||
),
|
||||
pitch_override_enabled=_optional_bool(
|
||||
sar3d_object,
|
||||
"pitch_override_enabled",
|
||||
gui.processing.sar3d.pitch_override_enabled,
|
||||
"gui.processing.sar3d",
|
||||
),
|
||||
pitch_deg=_optional_float(
|
||||
sar3d_object,
|
||||
"pitch_deg",
|
||||
gui.processing.sar3d.pitch_deg,
|
||||
"gui.processing.sar3d",
|
||||
),
|
||||
ground_depth_m=_optional_float(
|
||||
sar3d_object,
|
||||
"ground_depth_m",
|
||||
gui.processing.sar3d.ground_depth_m,
|
||||
"gui.processing.sar3d",
|
||||
),
|
||||
x_min_m=_optional_float(
|
||||
sar3d_object,
|
||||
"x_min_m",
|
||||
gui.processing.sar3d.x_min_m,
|
||||
"gui.processing.sar3d",
|
||||
),
|
||||
x_max_m=_optional_float(
|
||||
sar3d_object,
|
||||
"x_max_m",
|
||||
gui.processing.sar3d.x_max_m,
|
||||
"gui.processing.sar3d",
|
||||
),
|
||||
y_min_m=_optional_float(
|
||||
sar3d_object,
|
||||
"y_min_m",
|
||||
gui.processing.sar3d.y_min_m,
|
||||
"gui.processing.sar3d",
|
||||
),
|
||||
y_max_m=_optional_float(
|
||||
sar3d_object,
|
||||
"y_max_m",
|
||||
gui.processing.sar3d.y_max_m,
|
||||
"gui.processing.sar3d",
|
||||
),
|
||||
z_min_m=_optional_float(
|
||||
sar3d_object,
|
||||
"z_min_m",
|
||||
gui.processing.sar3d.z_min_m,
|
||||
"gui.processing.sar3d",
|
||||
),
|
||||
z_max_m=_optional_float(
|
||||
sar3d_object,
|
||||
"z_max_m",
|
||||
gui.processing.sar3d.z_max_m,
|
||||
"gui.processing.sar3d",
|
||||
),
|
||||
voxel_x_m=_optional_float(
|
||||
sar3d_object,
|
||||
"voxel_x_m",
|
||||
gui.processing.sar3d.voxel_x_m,
|
||||
"gui.processing.sar3d",
|
||||
),
|
||||
voxel_y_m=_optional_float(
|
||||
sar3d_object,
|
||||
"voxel_y_m",
|
||||
gui.processing.sar3d.voxel_y_m,
|
||||
"gui.processing.sar3d",
|
||||
),
|
||||
voxel_z_m=_optional_float(
|
||||
sar3d_object,
|
||||
"voxel_z_m",
|
||||
gui.processing.sar3d.voxel_z_m,
|
||||
"gui.processing.sar3d",
|
||||
),
|
||||
norm_edge_margin_m=_optional_float(
|
||||
sar3d_object,
|
||||
"norm_edge_margin_m",
|
||||
gui.processing.sar3d.norm_edge_margin_m,
|
||||
"gui.processing.sar3d",
|
||||
),
|
||||
smooth_sigma=_optional_float(
|
||||
sar3d_object,
|
||||
"smooth_sigma",
|
||||
gui.processing.sar3d.smooth_sigma,
|
||||
"gui.processing.sar3d",
|
||||
),
|
||||
comp_range_power=_optional_float(
|
||||
sar3d_object,
|
||||
"comp_range_power",
|
||||
gui.processing.sar3d.comp_range_power,
|
||||
"gui.processing.sar3d",
|
||||
),
|
||||
tx_per_segment=_optional_int(
|
||||
sar3d_object,
|
||||
"tx_per_segment",
|
||||
gui.processing.sar3d.tx_per_segment,
|
||||
"gui.processing.sar3d",
|
||||
),
|
||||
use_compensation=_optional_bool(
|
||||
sar3d_object,
|
||||
"use_compensation",
|
||||
gui.processing.sar3d.use_compensation,
|
||||
"gui.processing.sar3d",
|
||||
),
|
||||
normalize_pairs=_optional_bool(
|
||||
sar3d_object,
|
||||
"normalize_pairs",
|
||||
gui.processing.sar3d.normalize_pairs,
|
||||
"gui.processing.sar3d",
|
||||
),
|
||||
bottom_view_deg=_optional_float(
|
||||
sar3d_object,
|
||||
"bottom_view_deg",
|
||||
gui.processing.sar3d.bottom_view_deg,
|
||||
"gui.processing.sar3d",
|
||||
),
|
||||
input_positions=sar3d_string("input_positions"),
|
||||
output_positions=sar3d_string("output_positions"),
|
||||
min_depth_m=sar3d_float("min_depth_m"),
|
||||
max_depth_m=sar3d_float("max_depth_m"),
|
||||
start_freq_mhz=sar3d_float("start_freq_mhz"),
|
||||
stop_freq_mhz=sar3d_float("stop_freq_mhz"),
|
||||
speed_m_s=sar3d_float("speed_m_s"),
|
||||
ignore_socket_speed_enabled=sar3d_bool("ignore_socket_speed_enabled"),
|
||||
look_angle_deg=sar3d_float("look_angle_deg"),
|
||||
frame_radius=sar3d_int("frame_radius"),
|
||||
voxel_m=sar3d_float("voxel_m"),
|
||||
bp_ground_depth_m=sar3d_float("bp_ground_depth_m"),
|
||||
comp_range_power=sar3d_float("comp_range_power"),
|
||||
use_compensation=sar3d_bool("use_compensation"),
|
||||
normalize_each_frame=sar3d_bool("normalize_each_frame"),
|
||||
),
|
||||
)
|
||||
if gui.processing.selected_mode not in {"pass_through", "bscan", "gpr", "legacy_gpr", "sar3d"}:
|
||||
@@ -689,20 +528,8 @@ def gui_profile_from_dict(payload: dict[str, Any]) -> GuiProfileModel:
|
||||
raise ValueError("gui.processing.legacy_gpr.min_visible_pair_count must be >= 1")
|
||||
if gui.processing.sar3d.frame_radius < 0:
|
||||
raise ValueError("gui.processing.sar3d.frame_radius must be >= 0")
|
||||
if not 0.0 <= gui.processing.sar3d.edge_frame_weight <= 1.0:
|
||||
raise ValueError("gui.processing.sar3d.edge_frame_weight must be within [0, 1]")
|
||||
if min(gui.processing.sar3d.voxel_x_m, gui.processing.sar3d.voxel_y_m, gui.processing.sar3d.voxel_z_m) <= 0.0:
|
||||
raise ValueError("gui.processing.sar3d.voxel_x/y/z_m must each be > 0")
|
||||
if gui.processing.sar3d.x_max_m <= gui.processing.sar3d.x_min_m:
|
||||
raise ValueError("gui.processing.sar3d.x_max_m must be > x_min_m")
|
||||
if gui.processing.sar3d.y_max_m <= gui.processing.sar3d.y_min_m:
|
||||
raise ValueError("gui.processing.sar3d.y_max_m must be > y_min_m")
|
||||
if gui.processing.sar3d.z_max_m <= gui.processing.sar3d.z_min_m:
|
||||
raise ValueError("gui.processing.sar3d.z_max_m must be > z_min_m")
|
||||
if gui.processing.sar3d.ground_depth_m < 0.0:
|
||||
raise ValueError("gui.processing.sar3d.ground_depth_m must be >= 0")
|
||||
if gui.processing.sar3d.tx_per_segment < 1:
|
||||
raise ValueError("gui.processing.sar3d.tx_per_segment must be >= 1")
|
||||
if not gui.processing.sar3d.voxel_m > 0.0:
|
||||
raise ValueError("gui.processing.sar3d.voxel_m must be > 0")
|
||||
|
||||
data_actions_object = _as_dict(gui_object.get("data_actions"), "gui.data_actions")
|
||||
gui.data_actions = GuiDataActionsStateModel(
|
||||
@@ -854,40 +681,7 @@ def gui_profile_to_dict(model: GuiProfileModel) -> dict[str, Any]:
|
||||
"visible_z_min_m": gui.processing.legacy_gpr.visible_z_min_m,
|
||||
"visible_z_max_m": gui.processing.legacy_gpr.visible_z_max_m,
|
||||
},
|
||||
"sar3d": {
|
||||
"input_positions": gui.processing.sar3d.input_positions,
|
||||
"output_positions": gui.processing.sar3d.output_positions,
|
||||
"min_depth_m": gui.processing.sar3d.min_depth_m,
|
||||
"max_depth_m": gui.processing.sar3d.max_depth_m,
|
||||
"start_freq_mhz": gui.processing.sar3d.start_freq_mhz,
|
||||
"stop_freq_mhz": gui.processing.sar3d.stop_freq_mhz,
|
||||
"speed_m_s": gui.processing.sar3d.speed_m_s,
|
||||
"ignore_socket_speed_enabled": gui.processing.sar3d.ignore_socket_speed_enabled,
|
||||
"look_angle_deg": gui.processing.sar3d.look_angle_deg,
|
||||
"direction_sign": gui.processing.sar3d.direction_sign,
|
||||
"apply_freq_phase_correction": gui.processing.sar3d.apply_freq_phase_correction,
|
||||
"frame_radius": gui.processing.sar3d.frame_radius,
|
||||
"edge_frame_weight": gui.processing.sar3d.edge_frame_weight,
|
||||
"pitch_override_enabled": gui.processing.sar3d.pitch_override_enabled,
|
||||
"pitch_deg": gui.processing.sar3d.pitch_deg,
|
||||
"ground_depth_m": gui.processing.sar3d.ground_depth_m,
|
||||
"x_min_m": gui.processing.sar3d.x_min_m,
|
||||
"x_max_m": gui.processing.sar3d.x_max_m,
|
||||
"y_min_m": gui.processing.sar3d.y_min_m,
|
||||
"y_max_m": gui.processing.sar3d.y_max_m,
|
||||
"z_min_m": gui.processing.sar3d.z_min_m,
|
||||
"z_max_m": gui.processing.sar3d.z_max_m,
|
||||
"voxel_x_m": gui.processing.sar3d.voxel_x_m,
|
||||
"voxel_y_m": gui.processing.sar3d.voxel_y_m,
|
||||
"voxel_z_m": gui.processing.sar3d.voxel_z_m,
|
||||
"norm_edge_margin_m": gui.processing.sar3d.norm_edge_margin_m,
|
||||
"smooth_sigma": gui.processing.sar3d.smooth_sigma,
|
||||
"comp_range_power": gui.processing.sar3d.comp_range_power,
|
||||
"tx_per_segment": gui.processing.sar3d.tx_per_segment,
|
||||
"use_compensation": gui.processing.sar3d.use_compensation,
|
||||
"normalize_pairs": gui.processing.sar3d.normalize_pairs,
|
||||
"bottom_view_deg": gui.processing.sar3d.bottom_view_deg,
|
||||
},
|
||||
"sar3d": dataclasses.asdict(gui.processing.sar3d),
|
||||
},
|
||||
"data_actions": {
|
||||
"save_count": gui.data_actions.save_count,
|
||||
|
||||
@@ -127,45 +127,25 @@ class GuiLegacyGprStateModel:
|
||||
class GuiSar3dStateModel:
|
||||
"""UI-only defaults for the 3D SAR (sar3d) live settings.
|
||||
|
||||
The band, depth gate, antenna selection and motion knobs mirror the shared
|
||||
gpr_* live-config fields; the sar3d_* knobs mirror the reference notebook
|
||||
(Multi_antenna_3D_clean_v1.ipynb) defaults used by the C++ sar3d processor.
|
||||
Values are the reference notebook's (Multi_antenna_3D_clean_v1.ipynb), whose
|
||||
names the GUI shows: INPUT_IDX, F_START, MIN_DEPTH, LOOK_ANGLE_DEG, ...
|
||||
"""
|
||||
|
||||
input_positions: str = ""
|
||||
output_positions: str = ""
|
||||
min_depth_m: float = 2.5
|
||||
min_depth_m: float = 2.0
|
||||
max_depth_m: float = 8.0
|
||||
start_freq_mhz: float = 2000.0
|
||||
start_freq_mhz: float = 1500.0
|
||||
stop_freq_mhz: float = 6000.0
|
||||
speed_m_s: float = 0.0
|
||||
ignore_socket_speed_enabled: bool = False
|
||||
look_angle_deg: float = 17.5
|
||||
direction_sign: float = 1.0
|
||||
apply_freq_phase_correction: bool = True
|
||||
look_angle_deg: float = 19.5
|
||||
frame_radius: int = 0
|
||||
edge_frame_weight: float = 0.8
|
||||
pitch_override_enabled: bool = False
|
||||
pitch_deg: float = 17.5
|
||||
ground_depth_m: float = 1.25
|
||||
x_min_m: float = -2.0
|
||||
x_max_m: float = 2.0
|
||||
y_min_m: float = -0.5
|
||||
y_max_m: float = 2.0
|
||||
z_min_m: float = 0.0
|
||||
z_max_m: float = 8.0
|
||||
voxel_x_m: float = 0.025
|
||||
voxel_y_m: float = 0.02
|
||||
voxel_z_m: float = 0.02
|
||||
norm_edge_margin_m: float = 0.3
|
||||
smooth_sigma: float = 1.5
|
||||
voxel_m: float = 0.02
|
||||
bp_ground_depth_m: float = 1.25
|
||||
comp_range_power: float = 0.3
|
||||
tx_per_segment: int = 2
|
||||
use_compensation: bool = True
|
||||
normalize_pairs: bool = True
|
||||
# Bottom-view projection angle around the vertical Y axis, degrees:
|
||||
# 0 = front view (X-Y), 90 = side view (Z-Y).
|
||||
bottom_view_deg: float = 0.0
|
||||
normalize_each_frame: bool = True
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
|
||||
@@ -72,40 +72,16 @@ class ProcessingLiveConfig:
|
||||
gpr_visible_x_max_m: float = 2.0
|
||||
gpr_visible_z_min_m: float = 0.0
|
||||
gpr_visible_z_max_m: float = 14.0
|
||||
# 3D SAR (sar3d processor) parameters; defaults mirror the reference
|
||||
# notebook Multi_antenna_3D_clean_v1.ipynb. The frequency band, depth gate,
|
||||
# antenna selection and motion parameters are shared with the gpr_* fields.
|
||||
# 3D SAR (sar3d processor) parameters, named after the reference notebook
|
||||
# Multi_antenna_3D_clean_v1.ipynb (SAR_FRAME_RADIUS, BP_GROUND_DEPTH_M, ...).
|
||||
# The band, depth gate, antenna selection, speed and look angle are the
|
||||
# shared gpr_* fields.
|
||||
sar3d_frame_radius: int = 0
|
||||
sar3d_edge_frame_weight: float = 0.8
|
||||
# Frame pitch override; disabled -> the pitch follows gpr_look_angle_deg.
|
||||
sar3d_pitch_override_enabled: bool = False
|
||||
sar3d_pitch_deg: float = 17.5
|
||||
# Height of the lower antenna row above the ground (scene Y=0 = ground).
|
||||
sar3d_ground_depth_m: float = 1.25
|
||||
# Scene grid bounds (metres): X across, Y height above the ground, Z along
|
||||
# the ground away from the radar. Voxels outside the radar depth gate are
|
||||
# zeroed by the mask regardless of the Z window.
|
||||
sar3d_x_min_m: float = -2.0
|
||||
sar3d_x_max_m: float = 2.0
|
||||
sar3d_y_min_m: float = -0.5
|
||||
sar3d_y_max_m: float = 2.0
|
||||
sar3d_z_min_m: float = 0.0
|
||||
sar3d_z_max_m: float = 8.0
|
||||
sar3d_voxel_x_m: float = 0.025
|
||||
sar3d_voxel_y_m: float = 0.02
|
||||
sar3d_voxel_z_m: float = 0.02
|
||||
# Volume finalization: Y-edge margin excluded from normalization, Gaussian
|
||||
# smoothing sigma over (Z, X) planes.
|
||||
sar3d_norm_edge_margin_m: float = 0.3
|
||||
sar3d_smooth_sigma: float = 1.5
|
||||
sar3d_voxel_m: float = 0.02 # SAR_VOXEL_M, one edge for X, Y and Z
|
||||
sar3d_bp_ground_depth_m: float = 1.25
|
||||
sar3d_comp_range_power: float = 0.3
|
||||
# Fallback frame segmentation when per-trace capture stamps are absent.
|
||||
sar3d_tx_per_segment: int = 2
|
||||
sar3d_use_compensation: bool = True
|
||||
sar3d_normalize_pairs: bool = True
|
||||
# Bottom-view projection angle around the vertical Y axis, degrees:
|
||||
# 0 = front view (X horizontal), 90 = side view (Z horizontal).
|
||||
sar3d_bottom_view_deg: float = 0.0
|
||||
sar3d_normalize_each_frame: bool = True
|
||||
# When true, the C++ data_processor ignores socket-supplied vlc updates
|
||||
# and keeps using `gpr_speed_m_s` from this file.
|
||||
ignore_socket_speed: bool = False
|
||||
@@ -171,26 +147,11 @@ class ProcessingLiveConfig:
|
||||
"gpr_visible_z_min_m": float(self.gpr_visible_z_min_m),
|
||||
"gpr_visible_z_max_m": float(self.gpr_visible_z_max_m),
|
||||
"sar3d_frame_radius": int(self.sar3d_frame_radius),
|
||||
"sar3d_edge_frame_weight": float(self.sar3d_edge_frame_weight),
|
||||
"sar3d_pitch_override_enabled": bool(self.sar3d_pitch_override_enabled),
|
||||
"sar3d_pitch_deg": float(self.sar3d_pitch_deg),
|
||||
"sar3d_ground_depth_m": float(self.sar3d_ground_depth_m),
|
||||
"sar3d_x_min_m": float(self.sar3d_x_min_m),
|
||||
"sar3d_x_max_m": float(self.sar3d_x_max_m),
|
||||
"sar3d_y_min_m": float(self.sar3d_y_min_m),
|
||||
"sar3d_y_max_m": float(self.sar3d_y_max_m),
|
||||
"sar3d_z_min_m": float(self.sar3d_z_min_m),
|
||||
"sar3d_z_max_m": float(self.sar3d_z_max_m),
|
||||
"sar3d_voxel_x_m": float(self.sar3d_voxel_x_m),
|
||||
"sar3d_voxel_y_m": float(self.sar3d_voxel_y_m),
|
||||
"sar3d_voxel_z_m": float(self.sar3d_voxel_z_m),
|
||||
"sar3d_norm_edge_margin_m": float(self.sar3d_norm_edge_margin_m),
|
||||
"sar3d_smooth_sigma": float(self.sar3d_smooth_sigma),
|
||||
"sar3d_voxel_m": float(self.sar3d_voxel_m),
|
||||
"sar3d_bp_ground_depth_m": float(self.sar3d_bp_ground_depth_m),
|
||||
"sar3d_comp_range_power": float(self.sar3d_comp_range_power),
|
||||
"sar3d_tx_per_segment": int(self.sar3d_tx_per_segment),
|
||||
"sar3d_use_compensation": bool(self.sar3d_use_compensation),
|
||||
"sar3d_normalize_pairs": bool(self.sar3d_normalize_pairs),
|
||||
"sar3d_bottom_view_deg": float(self.sar3d_bottom_view_deg),
|
||||
"sar3d_normalize_each_frame": bool(self.sar3d_normalize_each_frame),
|
||||
"ignore_socket_speed": bool(self.ignore_socket_speed),
|
||||
"reprocess_current_result": bool(self.reprocess_current_result),
|
||||
"history_command_seq": int(self.history_command_seq),
|
||||
|
||||
@@ -1,262 +0,0 @@
|
||||
"""Headless smoke: replay the recorded VNA_AlGo dataset through the full pipeline.
|
||||
|
||||
Loads the replay profile, creates neutral preprocess sets, launches the whole
|
||||
pipeline (replay_raw_producer -> data_preprocessor -> data_processor) via the
|
||||
ProcessSupervisor with unique ring names, forces the sar3d processor through
|
||||
processing_live.json, and asserts that:
|
||||
|
||||
- sar3d projection payloads arrive with a positive peak;
|
||||
- collection ids increase monotonically (including across a dataset loop);
|
||||
- the preprocessor is transparent: a preprocessed_tap trace equals the
|
||||
recorded snapshot's s21 within float32 rounding.
|
||||
|
||||
Usage:
|
||||
python -m python_app.scripts.replay_smoke_run \
|
||||
[--config run_config_examples/run_config_replay_vna.example.json] \
|
||||
[--duration 20.0]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
|
||||
from python_app.models.run_config_model import RunConfigModel
|
||||
from python_app.orchestration.config_writer import ConfigWriter
|
||||
from python_app.orchestration.live_processing_config import (
|
||||
ProcessingLiveConfig,
|
||||
ProcessingLiveConfigWriter,
|
||||
)
|
||||
from python_app.orchestration.process_supervisor import ProcessSupervisor
|
||||
from python_app.orchestration.shm import ShmRingReader
|
||||
from python_app.orchestration.shm.decoder import PREPROC_MAGIC, decode_trace_collection
|
||||
from python_app.storage.npz_store import NpzStore, radar_key_from_config
|
||||
from python_app.workflows.kamil_adc_neutral_preprocess import build_neutral_s21_sets
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def _make_unique_ring_name(prefix: str) -> str:
|
||||
"""Build unique POSIX SHM ring name."""
|
||||
return f"/{prefix}_{os.getpid()}_{time.monotonic_ns()}"
|
||||
|
||||
|
||||
def _sar3d_live_config(profile_path: Path) -> ProcessingLiveConfig:
|
||||
"""Build a sar3d live config from the profile's gui.processing.sar3d section.
|
||||
|
||||
RunConfigModel does not carry the GUI section (it belongs to the GUI profile
|
||||
layer), so the sar3d defaults are read straight from the profile JSON.
|
||||
"""
|
||||
payload = json.loads(profile_path.read_text(encoding="utf-8"))
|
||||
sar3d = (
|
||||
payload.get("gui", {}).get("processing", {}).get("sar3d", {})
|
||||
if isinstance(payload, dict)
|
||||
else {}
|
||||
)
|
||||
|
||||
def _positions(text: str) -> list[int]:
|
||||
return [int(value) for value in str(text).split(",") if value.strip()]
|
||||
|
||||
live = ProcessingLiveConfig(processor_mode="sar3d", ignore_socket_speed=True)
|
||||
if not isinstance(sar3d, dict) or not sar3d:
|
||||
return live
|
||||
live.gpr_input_positions = _positions(sar3d.get("input_positions", ""))
|
||||
live.gpr_output_positions = _positions(sar3d.get("output_positions", ""))
|
||||
live.gpr_min_depth_m = float(sar3d.get("min_depth_m", live.gpr_min_depth_m))
|
||||
live.gpr_max_depth_m = float(sar3d.get("max_depth_m", live.gpr_max_depth_m))
|
||||
live.gpr_start_freq_mhz = float(sar3d.get("start_freq_mhz", live.gpr_start_freq_mhz))
|
||||
live.gpr_stop_freq_mhz = float(sar3d.get("stop_freq_mhz", live.gpr_stop_freq_mhz))
|
||||
live.gpr_speed_m_s = float(sar3d.get("speed_m_s", live.gpr_speed_m_s))
|
||||
live.gpr_look_angle_deg = float(sar3d.get("look_angle_deg", live.gpr_look_angle_deg))
|
||||
live.gpr_direction_sign = float(sar3d.get("direction_sign", live.gpr_direction_sign))
|
||||
live.gpr_apply_freq_phase_correction = bool(
|
||||
sar3d.get("apply_freq_phase_correction", live.gpr_apply_freq_phase_correction)
|
||||
)
|
||||
live.sar3d_frame_radius = int(sar3d.get("frame_radius", live.sar3d_frame_radius))
|
||||
live.sar3d_edge_frame_weight = float(
|
||||
sar3d.get("edge_frame_weight", live.sar3d_edge_frame_weight)
|
||||
)
|
||||
live.sar3d_pitch_override_enabled = bool(
|
||||
sar3d.get("pitch_override_enabled", live.sar3d_pitch_override_enabled)
|
||||
)
|
||||
live.sar3d_pitch_deg = float(sar3d.get("pitch_deg", live.sar3d_pitch_deg))
|
||||
live.sar3d_ground_depth_m = float(sar3d.get("ground_depth_m", live.sar3d_ground_depth_m))
|
||||
live.sar3d_x_min_m = float(sar3d.get("x_min_m", live.sar3d_x_min_m))
|
||||
live.sar3d_x_max_m = float(sar3d.get("x_max_m", live.sar3d_x_max_m))
|
||||
live.sar3d_y_min_m = float(sar3d.get("y_min_m", live.sar3d_y_min_m))
|
||||
live.sar3d_y_max_m = float(sar3d.get("y_max_m", live.sar3d_y_max_m))
|
||||
live.sar3d_z_min_m = float(sar3d.get("z_min_m", live.sar3d_z_min_m))
|
||||
live.sar3d_z_max_m = float(sar3d.get("z_max_m", live.sar3d_z_max_m))
|
||||
live.sar3d_voxel_x_m = float(sar3d.get("voxel_x_m", live.sar3d_voxel_x_m))
|
||||
live.sar3d_voxel_y_m = float(sar3d.get("voxel_y_m", live.sar3d_voxel_y_m))
|
||||
live.sar3d_voxel_z_m = float(sar3d.get("voxel_z_m", live.sar3d_voxel_z_m))
|
||||
live.sar3d_norm_edge_margin_m = float(
|
||||
sar3d.get("norm_edge_margin_m", live.sar3d_norm_edge_margin_m)
|
||||
)
|
||||
live.sar3d_smooth_sigma = float(sar3d.get("smooth_sigma", live.sar3d_smooth_sigma))
|
||||
live.sar3d_comp_range_power = float(
|
||||
sar3d.get("comp_range_power", live.sar3d_comp_range_power)
|
||||
)
|
||||
live.sar3d_tx_per_segment = int(sar3d.get("tx_per_segment", live.sar3d_tx_per_segment))
|
||||
live.sar3d_use_compensation = bool(sar3d.get("use_compensation", live.sar3d_use_compensation))
|
||||
live.sar3d_normalize_pairs = bool(sar3d.get("normalize_pairs", live.sar3d_normalize_pairs))
|
||||
live.sar3d_bottom_view_deg = float(sar3d.get("bottom_view_deg", live.sar3d_bottom_view_deg))
|
||||
return live
|
||||
|
||||
|
||||
def _check_preprocessor_transparency(
|
||||
tap_reader: ShmRingReader, dataset_dir: Path
|
||||
) -> bool:
|
||||
"""Compare one preprocessed trace against the recorded snapshot's s21."""
|
||||
payload = tap_reader.peek_latest_payload()
|
||||
if payload is None:
|
||||
logger.warning("No preprocessed_tap payload available for transparency check")
|
||||
return False
|
||||
preprocessed = decode_trace_collection(payload, PREPROC_MAGIC)
|
||||
|
||||
# Any snapshot works: neutral preprocessing is time-invariant, so match the
|
||||
# trace against every recorded snapshot and accept the best one.
|
||||
trace = preprocessed.traces[0]
|
||||
combo_file = f"i{trace.combo.input}_o{trace.combo.output}_s21.npy"
|
||||
best_delta = np.inf
|
||||
for folder in sorted(p for p in dataset_dir.iterdir() if p.name[:1].isdigit()):
|
||||
recorded = np.load(folder / combo_file).astype(np.complex64)
|
||||
if recorded.size != np.asarray(trace.s21).size:
|
||||
continue
|
||||
delta = float(np.max(np.abs(np.asarray(trace.s21) - recorded)))
|
||||
best_delta = min(best_delta, delta)
|
||||
if best_delta == 0.0:
|
||||
break
|
||||
logger.info("Preprocessor transparency: max|delta| vs best snapshot = %.3e", best_delta)
|
||||
return best_delta < 1e-6
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Run the replay pipeline smoke and report pass/fail."""
|
||||
parser = argparse.ArgumentParser(description="Headless replay pipeline smoke")
|
||||
parser.add_argument(
|
||||
"--config",
|
||||
type=Path,
|
||||
default=PROJECT_ROOT / "run_config_examples/run_config_replay_vna.example.json",
|
||||
)
|
||||
parser.add_argument("--duration", type=float, default=20.0)
|
||||
args = parser.parse_args()
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
|
||||
|
||||
config = RunConfigModel.load_from_path(args.config)
|
||||
config.ensure_combos()
|
||||
# expanduser: "~/..." dataset paths from machine-portable profiles.
|
||||
dataset_dir = Path(config.radar.replay.dataset_dir).expanduser()
|
||||
if not dataset_dir.is_dir():
|
||||
raise RuntimeError(f"Replay dataset not found: {dataset_dir}")
|
||||
|
||||
config.rings.raw.name = _make_unique_ring_name("radar_raw_replay_smoke")
|
||||
config.rings.raw_tap.name = _make_unique_ring_name("radar_raw_tap_replay_smoke")
|
||||
config.rings.preprocessed.name = _make_unique_ring_name("radar_preprocessed_replay_smoke")
|
||||
config.rings.preprocessed_tap.name = _make_unique_ring_name("radar_preprocessed_tap_replay_smoke")
|
||||
config.rings.results.name = _make_unique_ring_name("radar_results_replay_smoke")
|
||||
# Private live-config path so the smoke never clobbers the GUI's file.
|
||||
live_path = PROJECT_ROOT / "python_app/runtime/processing_live_replay_smoke.json"
|
||||
config.runtime.processing_live_config_path = str(
|
||||
live_path.relative_to(PROJECT_ROOT)
|
||||
)
|
||||
|
||||
radar_key = radar_key_from_config(
|
||||
model_name=config.radar.model,
|
||||
serial=config.radar.serial,
|
||||
sweep_start_hz=config.radar.sweep.start_hz,
|
||||
sweep_stop_hz=config.radar.sweep.stop_hz,
|
||||
sweep_points=config.radar.sweep.points,
|
||||
ifbw_hz=config.radar.sweep.if_bandwidth_hz,
|
||||
power_dbm=config.radar.sweep.power_dbm,
|
||||
extra_serials=config.radar_key_extra_parts() or None,
|
||||
)
|
||||
store = NpzStore(PROJECT_ROOT / "python_app/data")
|
||||
set_name = config.preprocess.s21.calibration.set_name or "neutral_replay"
|
||||
if set_name not in store.list_sets("s21_calibration", radar_key):
|
||||
calibration, reference = build_neutral_s21_sets(config)
|
||||
store.save_set("s21_calibration", radar_key, set_name, calibration)
|
||||
store.save_set("s21_reference", radar_key, set_name, reference)
|
||||
logger.info("Created neutral sets '%s' (radar_key=%s)", set_name, radar_key)
|
||||
config.preprocess.s21.calibration.set_name = set_name
|
||||
config.preprocess.s21.reference.set_name = set_name
|
||||
|
||||
ProcessingLiveConfigWriter(live_path).write(_sar3d_live_config(args.config))
|
||||
|
||||
config_writer = ConfigWriter(PROJECT_ROOT / "python_app/runtime")
|
||||
config_writer.prepare_preprocess_bundles(store, radar_key, config)
|
||||
config_path = config_writer.write(
|
||||
config, PROJECT_ROOT / "python_app/runtime/run_config_replay_smoke.json"
|
||||
)
|
||||
|
||||
supervisor = ProcessSupervisor(PROJECT_ROOT)
|
||||
result_reader: ShmRingReader | None = None
|
||||
tap_reader: ShmRingReader | None = None
|
||||
failures: list[str] = []
|
||||
try:
|
||||
supervisor.start(config_path)
|
||||
result_reader = ShmRingReader(config.rings.results.name, open_timeout_s=15.0)
|
||||
tap_reader = ShmRingReader(config.rings.preprocessed_tap.name, open_timeout_s=15.0)
|
||||
|
||||
deadline = time.monotonic() + args.duration
|
||||
received = 0
|
||||
sar3d_results = 0
|
||||
last_peak = 0.0
|
||||
last_id = 0
|
||||
ids_monotonic = True
|
||||
while time.monotonic() < deadline:
|
||||
result = result_reader.pop_result_collection()
|
||||
if result is None:
|
||||
time.sleep(0.02)
|
||||
continue
|
||||
received += 1
|
||||
if last_id and result.collection_id <= last_id:
|
||||
ids_monotonic = False
|
||||
last_id = result.collection_id
|
||||
names = {p.processing_name: p for p in result.collection_payloads}
|
||||
if "sar3d_proj_xz" in names and "sar3d_proj_bottom" in names:
|
||||
sar3d_results += 1
|
||||
peak_payload = names.get("sar3d_peak")
|
||||
if peak_payload is not None:
|
||||
last_peak = float(peak_payload.scalar_value)
|
||||
|
||||
logger.info(
|
||||
"Results: total=%d, with sar3d projections=%d, last collection_id=%d, last peak=%.4e",
|
||||
received, sar3d_results, last_id, last_peak,
|
||||
)
|
||||
if received == 0:
|
||||
failures.append("no result collections received")
|
||||
if sar3d_results == 0:
|
||||
failures.append("no sar3d projection payloads received")
|
||||
if not (last_peak > 0.0):
|
||||
failures.append(f"sar3d peak is not positive: {last_peak}")
|
||||
if not ids_monotonic:
|
||||
failures.append("collection ids were not monotonically increasing")
|
||||
if not _check_preprocessor_transparency(tap_reader, dataset_dir):
|
||||
failures.append("preprocessor is not transparent vs recorded snapshots")
|
||||
finally:
|
||||
supervisor.stop_all()
|
||||
if result_reader is not None:
|
||||
result_reader.close()
|
||||
if tap_reader is not None:
|
||||
tap_reader.close()
|
||||
|
||||
if failures:
|
||||
for failure in failures:
|
||||
logger.error("FAIL: %s", failure)
|
||||
return 1
|
||||
logger.info("REPLAY SMOKE OK")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,389 +0,0 @@
|
||||
"""Reference implementation of the sar3d volume from Multi_antenna_3D_clean_v1.ipynb.
|
||||
|
||||
Executable specification for the C++ port: recomputes a single-frame
|
||||
(aperture radius 0) 3D SAR volume from a preprocessed snapshot exactly the
|
||||
way the notebook does, then compares it against a reference *_volume.npz
|
||||
exported by the notebook.
|
||||
|
||||
Usage:
|
||||
python sar3d_reference_check.py <dataset_root> <reference_volume.npz>
|
||||
|
||||
<dataset_root> is an extracted replay dataset (contains config_profile.json
|
||||
and preprocessed/<snapshot>/i*_o*_{s21,freq}.npy + meta.json). The snapshot
|
||||
name and all physics parameters are taken from the npz meta.
|
||||
|
||||
Exits non-zero when the recomputed volume differs from the reference by more
|
||||
than the tolerance. The only expected difference is the Gaussian smoothing
|
||||
(manual separable filter vs scipy) at the 1e-6 level.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
TOLERANCE = 2e-6
|
||||
|
||||
C_LIGHT = 3e8
|
||||
|
||||
# Notebook constants not recorded in the npz meta (cell 0 / cell 4).
|
||||
BP_OVERSAMPLE = 8
|
||||
BP_WINDOW = True
|
||||
PAIR_NORM_PERCENTILE = 50.0
|
||||
PAIR_NORM_EPS = 1e-15
|
||||
COMP_RANGE_POWER = 0.3
|
||||
COMP_RANGE_WEIGHT_MAX = 5.0
|
||||
COMP_REF_DEPTH = 5.0
|
||||
BP_SMOOTH_SIGMA = 1.5
|
||||
SAR_Y_RANGE_M = (-0.5, 2.0)
|
||||
SAR_NORM_EDGE_MARGIN_M = 0.3
|
||||
X_MARGIN_M = 2.0
|
||||
MOTION_PHASE_SIGN = -1.0
|
||||
DIRECTION_SIGN = +1.0
|
||||
|
||||
|
||||
def gaussian_kernel1d(sigma: float, radius: int) -> np.ndarray:
|
||||
x = np.arange(-radius, radius + 1, dtype=float)
|
||||
phi = np.exp(-0.5 / (sigma * sigma) * x * x)
|
||||
return phi / phi.sum()
|
||||
|
||||
|
||||
def correlate1d_reflect(arr: np.ndarray, weights: np.ndarray, axis: int) -> np.ndarray:
|
||||
r = (len(weights) - 1) // 2
|
||||
a = np.moveaxis(arr, axis, -1)
|
||||
pad = np.concatenate([a[..., r - 1::-1], a, a[..., :-r - 1:-1]], axis=-1)
|
||||
out = np.zeros_like(a)
|
||||
for k, w in enumerate(weights):
|
||||
out += w * pad[..., k:k + a.shape[-1]]
|
||||
return np.moveaxis(out, -1, axis)
|
||||
|
||||
|
||||
def gaussian_filter(arr: np.ndarray, sigma: tuple[float, ...], truncate: float = 4.0) -> np.ndarray:
|
||||
"""scipy.ndimage.gaussian_filter equivalent (mode='reflect')."""
|
||||
out = np.asarray(arr, dtype=float).copy()
|
||||
for axis, s in enumerate(sigma):
|
||||
if s <= 0:
|
||||
continue
|
||||
radius = int(truncate * float(s) + 0.5)
|
||||
out = correlate1d_reflect(out, gaussian_kernel1d(float(s), radius), axis)
|
||||
return out
|
||||
|
||||
|
||||
def load_geometry(profile_path: Path) -> dict:
|
||||
profile = json.loads(profile_path.read_text())
|
||||
gpr = profile["gpr"]
|
||||
tx_geom = {int(e["output_pos"]): (e["x_m"], e.get("y_m", 0.0), e.get("z_m", 0.0))
|
||||
for e in gpr["tx_geometry"]}
|
||||
rx_geom = {int(e["input_pos"]): (e["x_m"], e.get("y_m", 0.0), e.get("z_m", 0.0))
|
||||
for e in gpr["rx_geometry"]}
|
||||
combos = profile["run"]["combos"]
|
||||
pair_order = [(int(c["output"]), int(c["input"])) for c in combos]
|
||||
tx_order_full: list[int] = []
|
||||
for tx, _ in pair_order:
|
||||
if tx not in tx_order_full:
|
||||
tx_order_full.append(tx)
|
||||
output_idx = sorted(tx_geom)
|
||||
input_idx = sorted(rx_geom)
|
||||
tx_xyz = np.array([tx_geom[i] for i in output_idx], dtype=float)
|
||||
rx_xyz = np.array([rx_geom[i] for i in input_idx], dtype=float)
|
||||
return {
|
||||
"output_idx": output_idx, "input_idx": input_idx,
|
||||
"tx_xyz": tx_xyz, "rx_xyz": rx_xyz,
|
||||
"pair_order": pair_order, "tx_order_full": tx_order_full,
|
||||
}
|
||||
|
||||
|
||||
def snapshot_timing(snapshot_dir: Path) -> dict | None:
|
||||
"""Per-Tx capture windows from the snapshot's meta.json trace labels."""
|
||||
try:
|
||||
meta = json.loads((snapshot_dir / "meta.json").read_text())
|
||||
except OSError:
|
||||
return None
|
||||
traces = [t for t in meta.get("traces", [])
|
||||
if "capture_start_ns" in t and "capture_end_ns" in t and "output" in t]
|
||||
if not traces:
|
||||
return None
|
||||
first = min(int(t["capture_start_ns"]) for t in traces)
|
||||
last = max(int(t["capture_end_ns"]) for t in traces)
|
||||
by_tx: dict[int, tuple[int, int]] = {}
|
||||
for t in traces:
|
||||
by_tx.setdefault(int(t["output"]), (int(t["capture_start_ns"]), int(t["capture_end_ns"])))
|
||||
return {
|
||||
"acq_s": (last - first) * 1e-9,
|
||||
"center_abs_s": 0.5 * (first + last) * 1e-9,
|
||||
"tx_window_s": {tx: ((a - first) * 1e-9, (b - first) * 1e-9) for tx, (a, b) in by_tx.items()},
|
||||
}
|
||||
|
||||
|
||||
def pair_timestamps(geometry: dict, timing: dict, speed_m_s: float, look_angle_deg: float,
|
||||
wave_speed: float) -> dict:
|
||||
"""Segment/slot assignment and coarse pair dtau (notebook compute_pair_timestamps_for_snapshot)."""
|
||||
windows = timing["tx_window_s"]
|
||||
hw_order = [tx for tx in geometry["tx_order_full"] if tx in windows]
|
||||
hw_order += [tx for tx in sorted(windows) if tx not in hw_order]
|
||||
groups: dict[tuple[float, float], list[int]] = {}
|
||||
for tx in hw_order:
|
||||
groups.setdefault(windows[tx], []).append(tx)
|
||||
seg_of = {}
|
||||
for seg_idx, win in enumerate(sorted(groups)):
|
||||
for slot, tx in enumerate(groups[win]):
|
||||
seg_of[tx] = (seg_idx, slot, len(groups[win]))
|
||||
cos_theta = np.cos(np.radians(look_angle_deg))
|
||||
t_ref = 0.5 * timing["acq_s"]
|
||||
tx_map = {p: k for k, p in enumerate(geometry["output_idx"])}
|
||||
rx_map = {p: k for k, p in enumerate(geometry["input_idx"])}
|
||||
ts = {}
|
||||
for tx_phys, rx_phys in geometry["pair_order"]:
|
||||
_, slot, n_slots = seg_of[tx_phys]
|
||||
t0, t1 = windows[tx_phys]
|
||||
t_center = 0.5 * (t0 + t1)
|
||||
dz_motion = DIRECTION_SIGN * speed_m_s * (t_center - t_ref) * cos_theta
|
||||
ts[(tx_map[tx_phys], rx_map[rx_phys])] = {
|
||||
"slot": slot, "n_slots": n_slots,
|
||||
"segment_start_s": t0, "segment_time_s": t1 - t0,
|
||||
"t_center_s": t_center,
|
||||
"dtau_motion_s": 2.0 * dz_motion / wave_speed,
|
||||
}
|
||||
return ts
|
||||
|
||||
|
||||
def timing_abs_center(snapshot_dir: Path) -> float:
|
||||
timing = snapshot_timing(snapshot_dir)
|
||||
if timing is None:
|
||||
raise RuntimeError(f"meta.json trace labels required in {snapshot_dir}")
|
||||
return timing["center_abs_s"]
|
||||
|
||||
|
||||
def load_mimo(snapshot_dir: Path, geometry: dict) -> tuple[dict, dict]:
|
||||
s21_data, freq_data = {}, {}
|
||||
for f in snapshot_dir.glob("i*_o*_s21.npy"):
|
||||
parts = f.stem.split("_")
|
||||
rx_phys = int(parts[0][1:])
|
||||
tx_phys = int(parts[1][1:])
|
||||
if tx_phys not in geometry["output_idx"] or rx_phys not in geometry["input_idx"]:
|
||||
continue
|
||||
key = (geometry["output_idx"].index(tx_phys), geometry["input_idx"].index(rx_phys))
|
||||
s21_data[key] = np.load(f)
|
||||
freq_data[key] = np.asarray(np.load(f.parent / f"i{rx_phys}_o{tx_phys}_freq.npy"), dtype=float)
|
||||
return s21_data, freq_data
|
||||
|
||||
|
||||
def apply_phase_correction(s21: np.ndarray, freq_full: np.ndarray, info: dict,
|
||||
f_start: float, f_stop: float, speed_m_s: float,
|
||||
look_angle_deg: float, wave_speed: float) -> np.ndarray:
|
||||
"""Coarse pair + intra-sweep motion correction as one phase ramp before the IFFT."""
|
||||
idx = np.flatnonzero((freq_full >= f_start) & (freq_full <= f_stop))
|
||||
n_full = len(freq_full)
|
||||
dt_event = info["segment_time_s"] / (info["n_slots"] * n_full)
|
||||
t_abs = info["segment_start_s"] + (info["n_slots"] * idx + info["slot"]) * dt_event
|
||||
dt_intra = t_abs - info["t_center_s"]
|
||||
dtau_intra = 2.0 * DIRECTION_SIGN * speed_m_s * np.cos(np.radians(look_angle_deg)) * dt_intra / wave_speed
|
||||
total_dtau = dtau_intra + info["dtau_motion_s"]
|
||||
phi = 2.0 * np.pi * freq_full[idx] * total_dtau
|
||||
out = np.array(s21, dtype=np.complex128, copy=True)
|
||||
out[idx] *= np.exp(1j * MOTION_PHASE_SIGN * phi)
|
||||
return out
|
||||
|
||||
|
||||
def compute_ascan(s21: np.ndarray, freq: np.ndarray, f_start: float, f_stop: float):
|
||||
"""Oversampled complex A-scan with the notebook's frequency-shifted IFFT."""
|
||||
mask = (freq >= f_start) & (freq <= f_stop)
|
||||
freq_cut = freq[mask]
|
||||
s21_cut = s21[mask]
|
||||
df = float(np.median(np.diff(freq_cut)))
|
||||
n = len(freq_cut)
|
||||
k0 = int(round(freq_cut[0] / df))
|
||||
n_fft = (1 << int(np.ceil(np.log2(2 * (k0 + n - 1))))) * BP_OVERSAMPLE
|
||||
dt = 1.0 / (n_fft * df)
|
||||
s = s21_cut * np.hanning(n) if BP_WINDOW else s21_cut.copy()
|
||||
spectrum = np.zeros(n_fft, dtype=np.complex128)
|
||||
spectrum[k0:k0 + n] = s
|
||||
return np.arange(n_fft, dtype=float) * dt, np.fft.ifft(spectrum)
|
||||
|
||||
|
||||
def interp_complex(tau: np.ndarray, t_axis: np.ndarray, h_axis: np.ndarray) -> np.ndarray:
|
||||
n = t_axis.size
|
||||
dt = (float(t_axis[-1]) - float(t_axis[0])) / (n - 1)
|
||||
pos = (tau - float(t_axis[0])) / dt
|
||||
inside = (pos >= 0.0) & (pos <= n - 1)
|
||||
k = np.clip(np.floor(pos), 0, n - 2).astype(np.intp)
|
||||
frac = pos - k
|
||||
out = h_axis[k] + frac * (h_axis[k + 1] - h_axis[k])
|
||||
return np.where(inside, out, 0.0 + 0.0j)
|
||||
|
||||
|
||||
def axis_linspace(lo: float, hi: float, step: float) -> np.ndarray:
|
||||
return np.linspace(lo, hi, max(int(round((hi - lo) / step)) + 1, 2))
|
||||
|
||||
|
||||
def compute_volume(dataset_root: Path, meta: dict) -> dict:
|
||||
"""Single-frame volume via the notebook's ANIMATION path.
|
||||
|
||||
The reference npz files were exported from animation frames: the base grid
|
||||
is shifted by the radar displacement between the frame and the scene-origin
|
||||
snapshot (meta reference_snapshot), antennas get the same offset, and the
|
||||
depth window follows the radar (depth_shift). Physically it all cancels for
|
||||
an aperture of radius 0, but replicating the exact float arithmetic is what
|
||||
makes the comparison bit-tight at the depth-mask knife edge.
|
||||
"""
|
||||
geometry = load_geometry(dataset_root / "config_profile.json")
|
||||
snapshot_dir = dataset_root / "preprocessed" / meta["snapshot"]
|
||||
|
||||
eps_r = 1.0
|
||||
wave_speed = C_LIGHT / np.sqrt(eps_r)
|
||||
f_start = float(meta["f_start_hz"])
|
||||
f_stop = float(meta["f_stop_hz"])
|
||||
min_depth = float(meta["min_depth_m"])
|
||||
max_depth = float(meta["max_depth_m"])
|
||||
speed = float(meta["speed_m_s"])
|
||||
look_angle = float(meta["look_angle_deg"])
|
||||
ground_depth = float(meta["ground_depth_m"])
|
||||
voxel = [float(a) for a in meta["voxel_m"]]
|
||||
|
||||
x_tx, y_tx, z_tx = geometry["tx_xyz"].T
|
||||
x_rx, y_rx, z_rx = geometry["rx_xyz"].T
|
||||
n_tx, n_rx = len(x_tx), len(x_rx)
|
||||
|
||||
timing = snapshot_timing(snapshot_dir)
|
||||
if timing is None:
|
||||
raise RuntimeError(f"meta.json trace labels required in {snapshot_dir}")
|
||||
ts = pair_timestamps(geometry, timing, speed, look_angle, wave_speed)
|
||||
|
||||
s21_data, freq_data = load_mimo(snapshot_dir, geometry)
|
||||
ascan_h, ascan_t = {}, {}
|
||||
for key, s21 in s21_data.items():
|
||||
corrected = apply_phase_correction(
|
||||
s21, freq_data[key], ts[key], f_start, f_stop, speed, look_angle, wave_speed)
|
||||
t_axis, h = compute_ascan(corrected, freq_data[key], f_start, f_stop)
|
||||
z_axis = t_axis * wave_speed / 2
|
||||
m = (z_axis >= min_depth) & (z_axis <= max_depth)
|
||||
scale = float(np.percentile(np.abs(h)[m], PAIR_NORM_PERCENTILE))
|
||||
if not np.isfinite(scale) or scale <= PAIR_NORM_EPS:
|
||||
scale = 1.0
|
||||
ascan_h[key] = h / (scale + PAIR_NORM_EPS)
|
||||
ascan_t[key] = t_axis
|
||||
|
||||
pitch = np.radians(look_angle)
|
||||
cos_p, sin_p = np.cos(pitch), np.sin(pitch)
|
||||
|
||||
def scene_to_radar_yz(y_scene, z_scene):
|
||||
y0 = y_scene - ground_depth
|
||||
return y0 * cos_p + z_scene * sin_p, -y0 * sin_p + z_scene * cos_p
|
||||
|
||||
x_ant = np.concatenate([x_tx, x_rx])
|
||||
y_lo, y_hi = SAR_Y_RANGE_M
|
||||
z_span = [(d - float(scene_to_radar_yz(y, 0.0)[1])) / cos_p
|
||||
for y in (y_lo, y_hi) for d in (min_depth, max_depth)]
|
||||
gx = axis_linspace(float(x_ant.min() - X_MARGIN_M), float(x_ant.max() + X_MARGIN_M), voxel[0])
|
||||
gy = axis_linspace(y_lo, y_hi, voxel[1])
|
||||
gz = axis_linspace(min(z_span), max(z_span), voxel[2])
|
||||
|
||||
# Displacement of this frame relative to the scene-origin snapshot
|
||||
# (notebook sar_frame_offsets + anim_frame_displacement).
|
||||
off_dy = off_dz = 0.0
|
||||
disp_y = disp_z = 0.0
|
||||
reference_name = meta.get("reference_snapshot", meta["snapshot"])
|
||||
if reference_name != meta["snapshot"]:
|
||||
ref_timing = snapshot_timing(dataset_root / "preprocessed" / reference_name)
|
||||
if ref_timing is None:
|
||||
raise RuntimeError(f"meta.json trace labels required in {reference_name}")
|
||||
dt = timing_abs_center(snapshot_dir) - ref_timing["center_abs_s"]
|
||||
horizontal = DIRECTION_SIGN * speed * dt
|
||||
off_dy = float(horizontal * np.sin(pitch))
|
||||
off_dz = float(horizontal * np.cos(pitch))
|
||||
# Scene displacement = difference of two transformed points; the ground
|
||||
# lift cancels, leaving essentially (0, ~0, horizontal).
|
||||
disp_y = (off_dy * cos_p - off_dz * sin_p + ground_depth) - ground_depth
|
||||
disp_z = off_dy * sin_p + off_dz * cos_p
|
||||
gy_s = gy + disp_y
|
||||
gz_s = gz + disp_z
|
||||
depth_shift = off_dz
|
||||
grid_xx, grid_zz = np.meshgrid(gx, gz_s)
|
||||
|
||||
ground_plane_y = (-ground_depth + COMP_REF_DEPTH * sin_p) / cos_p
|
||||
|
||||
def pair_geo_ref(i, j):
|
||||
xc = (x_tx[i] + x_rx[j]) / 2.0
|
||||
r_tx = np.sqrt((xc - x_tx[i]) ** 2 + (ground_plane_y - y_tx[i]) ** 2 + (COMP_REF_DEPTH - z_tx[i]) ** 2)
|
||||
r_rx = np.sqrt((xc - x_rx[j]) ** 2 + (ground_plane_y - y_rx[j]) ** 2 + (COMP_REF_DEPTH - z_rx[j]) ** 2)
|
||||
return 1.0 / (r_tx * r_rx + 1e-12) + 1e-30
|
||||
|
||||
geo_ref = {(i, j): pair_geo_ref(i, j) for i in range(n_tx) for j in range(n_rx)}
|
||||
dx2_tx = [(grid_xx - x_tx[i]) ** 2 for i in range(n_tx)]
|
||||
dx2_rx = [(grid_xx - x_rx[j]) ** 2 for j in range(n_rx)]
|
||||
|
||||
acc_vol = np.zeros((gy.size, gz.size, gx.size), dtype=np.complex128)
|
||||
cnt_vol = np.zeros(acc_vol.shape, dtype=float)
|
||||
for iy, y_plane in enumerate(gy_s):
|
||||
y_rad, z_rad = scene_to_radar_yz(y_plane, grid_zz)
|
||||
depth_ok = (z_rad - depth_shift >= min_depth) & (z_rad - depth_shift <= max_depth)
|
||||
r_tx_all = [np.sqrt(dx2_tx[i] + (y_rad - (y_tx[i] + off_dy)) ** 2 + (z_rad - (z_tx[i] + off_dz)) ** 2)
|
||||
for i in range(n_tx)]
|
||||
r_rx_all = [np.sqrt(dx2_rx[j] + (y_rad - (y_rx[j] + off_dy)) ** 2 + (z_rad - (z_rx[j] + off_dz)) ** 2)
|
||||
for j in range(n_rx)]
|
||||
acc = np.zeros_like(grid_xx, dtype=np.complex128)
|
||||
cnt = np.zeros_like(grid_xx, dtype=float)
|
||||
for i in range(n_tx):
|
||||
for j in range(n_rx):
|
||||
key = (i, j)
|
||||
if key not in ascan_h:
|
||||
continue
|
||||
r_tx, r_rx = r_tx_all[i], r_rx_all[j]
|
||||
tau = (r_tx + r_rx) / wave_speed
|
||||
t_axis = ascan_t[key]
|
||||
valid = depth_ok & (tau >= t_axis[0]) & (tau <= t_axis[-1])
|
||||
h = interp_complex(tau, t_axis, ascan_h[key])
|
||||
ratio = (1.0 / (r_tx * r_rx + 1e-12) + 1e-30) / geo_ref[key]
|
||||
weight = np.clip(1.0 / (ratio ** COMP_RANGE_POWER + 1e-12), 0.0, COMP_RANGE_WEIGHT_MAX)
|
||||
acc += np.where(valid, h * weight, 0.0 + 0.0j)
|
||||
cnt += valid
|
||||
acc_vol[iy] = acc
|
||||
cnt_vol[iy] = cnt
|
||||
|
||||
volume = np.abs(acc_vol / (cnt_vol + 1e-12))
|
||||
z_rad_planes = np.asarray([scene_to_radar_yz(yp, grid_zz)[1] - depth_shift for yp in gy_s])
|
||||
volume = np.where((z_rad_planes >= min_depth) & (z_rad_planes <= max_depth), volume, 0.0)
|
||||
ref_planes = (gy_s >= gy_s[0] + SAR_NORM_EDGE_MARGIN_M) & (gy_s <= gy_s[-1] - SAR_NORM_EDGE_MARGIN_M)
|
||||
level_raw = volume[ref_planes].max() + 1e-12
|
||||
volume = gaussian_filter(volume / level_raw, sigma=(0.0, BP_SMOOTH_SIGMA, BP_SMOOTH_SIGMA))
|
||||
level_smooth = volume[ref_planes].max() + 1e-12
|
||||
volume = np.clip(volume / level_smooth, 0.0, None).astype(np.float32)
|
||||
return {"volume": volume, "level": float(level_raw * level_smooth), "x": gx, "y": gy, "z": gz}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
parser.add_argument("dataset_root", type=Path,
|
||||
help="extracted dataset dir (config_profile.json + preprocessed/)")
|
||||
parser.add_argument("reference_npz", type=Path, help="reference *_volume.npz from the notebook")
|
||||
args = parser.parse_args()
|
||||
|
||||
ref = np.load(args.reference_npz)
|
||||
meta = json.loads(str(ref["meta"]))
|
||||
print(f"snapshot: {meta['snapshot']}")
|
||||
result = compute_volume(args.dataset_root, meta)
|
||||
|
||||
volume, ref_volume = result["volume"], ref["volume"]
|
||||
axes_ok = all(np.allclose(result[k], ref[f"{k}_m"]) for k in ("x", "y", "z"))
|
||||
level_rel = abs(result["level"] - meta["level"]) / abs(meta["level"])
|
||||
print(f"axes match: {axes_ok}; level rel diff: {level_rel:.3e} "
|
||||
f"(mine {result['level']:.9f}, ref {meta['level']:.9f})")
|
||||
if volume.shape != ref_volume.shape:
|
||||
print(f"FAIL: shape mismatch {volume.shape} vs {ref_volume.shape}")
|
||||
return 1
|
||||
diff = np.abs(volume.astype(np.float64) - ref_volume.astype(np.float64))
|
||||
argmax = np.unravel_index(int(np.argmax(volume)), volume.shape)
|
||||
ref_argmax = np.unravel_index(int(np.argmax(ref_volume)), ref_volume.shape)
|
||||
print(f"max |diff| = {diff.max():.3e}, mean = {diff.mean():.3e}")
|
||||
print(f"argmax: mine {argmax}, ref {ref_argmax}")
|
||||
ok = axes_ok and diff.max() <= TOLERANCE and argmax == ref_argmax and level_rel <= TOLERANCE
|
||||
print("OK" if ok else "FAIL")
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,89 +0,0 @@
|
||||
"""Compare a sar3d volume dumped by the C++ processor with a notebook npz export.
|
||||
|
||||
Usage:
|
||||
python sar3d_volume_compare.py <dump_base> <reference_volume.npz>
|
||||
|
||||
<dump_base> is the path prefix written by `sar3d_parity --dump-dir` (or by the
|
||||
processor with `sar3d_volume_dump_dir` set): <dump_base>.f32 holds the raw
|
||||
float32 volume [iy][iz][ix], <dump_base>.json its axes and level.
|
||||
|
||||
The C++ path accumulates the backprojection in float32 (CUDA kernel), so the
|
||||
comparison is tolerance-based rather than bitwise; geometry is computed in
|
||||
double, keeping the expected difference at the 1e-5 level. Voxels within the
|
||||
smoothing radius of the depth-mask boundary are reported separately: there the
|
||||
float32 knife-edge can flip whole boundary voxels (the same effect exists
|
||||
between two float64 runs with shifted grids).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
MAX_DIFF_TOLERANCE = 1e-4
|
||||
LEVEL_REL_TOLERANCE = 1e-4
|
||||
# Gaussian smoothing radius (sigma 1.5 -> radius 6) around the depth-mask edge.
|
||||
EDGE_EXCLUSION_VOXELS = 7
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
parser.add_argument("dump_base", type=Path, help="path prefix of the .f32/.json dump")
|
||||
parser.add_argument("reference_npz", type=Path, help="reference *_volume.npz from the notebook")
|
||||
args = parser.parse_args()
|
||||
|
||||
header = json.loads(Path(str(args.dump_base) + ".json").read_text())
|
||||
shape = (header["ny"], header["nz"], header["nx"])
|
||||
volume = np.fromfile(str(args.dump_base) + ".f32", dtype=np.float32).reshape(shape)
|
||||
|
||||
ref = np.load(args.reference_npz)
|
||||
ref_volume = ref["volume"]
|
||||
meta = json.loads(str(ref["meta"]))
|
||||
print(f"snapshot: {meta['snapshot']}")
|
||||
if volume.shape != ref_volume.shape:
|
||||
print(f"FAIL: shape mismatch {volume.shape} vs {ref_volume.shape}")
|
||||
return 1
|
||||
|
||||
axes_ok = all(
|
||||
np.allclose(np.asarray(header[key]), ref[key])
|
||||
for key in ("x_m", "y_m", "z_m")
|
||||
)
|
||||
level_rel = abs(header["level"] - meta["level"]) / abs(meta["level"])
|
||||
print(f"axes match: {axes_ok}; level rel diff: {level_rel:.3e} "
|
||||
f"(dump {header['level']:.9f}, ref {meta['level']:.9f})")
|
||||
|
||||
diff = np.abs(volume.astype(np.float64) - ref_volume.astype(np.float64))
|
||||
|
||||
# Depth-mask boundary band: voxels whose (iy, iz) sit within the smoothing
|
||||
# radius of a masked/unmasked transition. Reconstructed from the reference
|
||||
# parameters exactly like the processor computes the mask.
|
||||
pitch = np.radians(float(meta.get("ground_pitch_deg") or meta["look_angle_deg"]))
|
||||
y0 = np.asarray(ref["y_m"]) - float(meta["ground_depth_m"])
|
||||
z_rad = -y0[:, None] * np.sin(pitch) + np.asarray(ref["z_m"])[None, :] * np.cos(pitch)
|
||||
mask = (z_rad >= float(meta["min_depth_m"])) & (z_rad <= float(meta["max_depth_m"]))
|
||||
edge = np.zeros_like(mask)
|
||||
r = EDGE_EXCLUSION_VOXELS
|
||||
for shift in range(-r, r + 1):
|
||||
edge |= np.roll(mask, shift, axis=1) != mask
|
||||
interior = ~edge
|
||||
|
||||
argmax = np.unravel_index(int(np.argmax(volume)), volume.shape)
|
||||
ref_argmax = np.unravel_index(int(np.argmax(ref_volume)), ref_volume.shape)
|
||||
interior_diff = diff[interior[:, :, None] & np.ones(volume.shape, dtype=bool)]
|
||||
print(f"max |diff| (all voxels) = {diff.max():.3e}")
|
||||
print(f"max |diff| (off depth-mask edge) = {interior_diff.max():.3e}, "
|
||||
f"mean = {interior_diff.mean():.3e}")
|
||||
print(f"argmax: dump {argmax}, ref {ref_argmax}")
|
||||
|
||||
ok = (axes_ok and level_rel <= LEVEL_REL_TOLERANCE and argmax == ref_argmax
|
||||
and interior_diff.max() <= MAX_DIFF_TOLERANCE)
|
||||
print("OK" if ok else "FAIL")
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -8,6 +8,7 @@ from python_app.models.gui_profile_model import (
|
||||
GuiPassThroughStateModel,
|
||||
GuiProcessingStateModel,
|
||||
GuiProfileModel,
|
||||
GuiSar3dStateModel,
|
||||
GuiStateModel,
|
||||
)
|
||||
|
||||
@@ -53,6 +54,31 @@ class GuiProfileCodecTest(unittest.TestCase):
|
||||
twice = GuiProfileModel.from_dict(once).to_dict()
|
||||
self.assertEqual(once["gui"], twice["gui"])
|
||||
|
||||
def test_sar3d_section_round_trips(self) -> None:
|
||||
sar3d = GuiSar3dStateModel(
|
||||
input_positions="0,2",
|
||||
output_positions="1,3",
|
||||
min_depth_m=2.5,
|
||||
max_depth_m=9.0,
|
||||
start_freq_mhz=2000.0,
|
||||
stop_freq_mhz=5500.0,
|
||||
speed_m_s=1.42,
|
||||
ignore_socket_speed_enabled=True,
|
||||
look_angle_deg=6.5,
|
||||
frame_radius=3,
|
||||
voxel_m=0.035,
|
||||
bp_ground_depth_m=1.1,
|
||||
comp_range_power=0.5,
|
||||
use_compensation=False,
|
||||
normalize_each_frame=False,
|
||||
)
|
||||
profile = GuiProfileModel(gui=GuiStateModel(processing=GuiProcessingStateModel(sar3d=sar3d)))
|
||||
|
||||
decoded = GuiProfileModel.from_dict(profile.to_dict())
|
||||
|
||||
assert decoded.gui is not None
|
||||
self.assertEqual(decoded.gui.processing.sar3d, sar3d)
|
||||
|
||||
def test_missing_gui_section_yields_none(self) -> None:
|
||||
self.assertIsNone(GuiProfileModel.from_dict({}).gui)
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Speed authority between a locator socket client and the GUI field.
|
||||
|
||||
The data_processor uses the speed a socket client streams unless the live config
|
||||
sets `ignore_socket_speed`; then `gpr_speed_m_s` from the GUI field applies. Each
|
||||
GPR-like page (gpr, legacy_gpr, sar3d) has its own checkbox and field, and the
|
||||
field is editable only while it is the one in force.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
from PyQt6.QtWidgets import QApplication # noqa: E402
|
||||
|
||||
from python_app.gui.app_window import AppWindow # noqa: E402
|
||||
|
||||
_app: QApplication | None = None
|
||||
_window: AppWindow | None = None
|
||||
|
||||
# mode -> (checkbox, speed field) attributes of its page
|
||||
_PAGES = {
|
||||
"gpr": ("_gpr_ignore_socket_speed_enabled", "_gpr_speed_m_s"),
|
||||
"legacy_gpr": ("_legacy_gpr_ignore_socket_speed_enabled", "_legacy_gpr_speed_m_s"),
|
||||
"sar3d": ("_sar3d_ignore_socket_speed_enabled", "_sar3d_speed_m_s"),
|
||||
}
|
||||
|
||||
|
||||
def setUpModule() -> None:
|
||||
global _app, _window
|
||||
_app = QApplication.instance() or QApplication([])
|
||||
_window = AppWindow(Path("."))
|
||||
|
||||
|
||||
def tearDownModule() -> None:
|
||||
if _window is not None:
|
||||
_window.close()
|
||||
|
||||
|
||||
class SocketSpeedControlsTest(unittest.TestCase):
|
||||
def test_speed_field_is_editable_only_while_the_socket_is_ignored(self) -> None:
|
||||
assert _window is not None
|
||||
for mode, (checkbox_name, speed_name) in _PAGES.items():
|
||||
checkbox, speed = getattr(_window, checkbox_name), getattr(_window, speed_name)
|
||||
for ignore in (True, False):
|
||||
checkbox.setChecked(ignore)
|
||||
self.assertEqual(speed.isEnabled(), ignore, msg=f"{mode}, ignore={ignore}")
|
||||
|
||||
def test_live_config_takes_the_active_page(self) -> None:
|
||||
assert _window is not None
|
||||
for mode, (checkbox_name, speed_name) in _PAGES.items():
|
||||
_window._set_combo_current_text(_window._processing_mode, mode)
|
||||
getattr(_window, checkbox_name).setChecked(True)
|
||||
getattr(_window, speed_name).setValue(1.25)
|
||||
config = _window._live_processing_config()
|
||||
self.assertTrue(config.ignore_socket_speed, msg=mode)
|
||||
self.assertAlmostEqual(config.gpr_speed_m_s, 1.25, msg=mode)
|
||||
getattr(_window, checkbox_name).setChecked(False)
|
||||
self.assertFalse(_window._live_processing_config().ignore_socket_speed, msg=mode)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+3
-3
@@ -12,8 +12,8 @@ pyqtgraph>=0.13.7
|
||||
rpi-hardware-pwm>=0.2.2,<1
|
||||
fastapi>=0.110 # embedded web UI (browser view + controls), served in-process
|
||||
uvicorn[standard]>=0.29 # ASGI server for the web UI, run on a background thread
|
||||
# CUDA headers + libnvrtc for building the vendored gpr_cpp library (3D SAR).
|
||||
# x86_64 dev machines only: on Jetson (aarch64) CUDA comes with JetPack at
|
||||
# /usr/local/cuda and build_cmake.sh falls back to it automatically.
|
||||
# nvrtc.h, cuda.h and libnvrtc for the sar3d processor's GPU code (kernels are
|
||||
# compiled at run time, no nvcc). x86_64 only: on Jetson (aarch64) CUDA comes
|
||||
# with JetPack at /usr/local/cuda, where the Makefile finds it.
|
||||
nvidia-cuda-nvrtc==13.3.33; platform_machine == "x86_64" # nvrtc.h + libnvrtc
|
||||
nvidia-cuda-runtime==13.3.29; platform_machine == "x86_64" # cuda.h (Driver API)
|
||||
|
||||
@@ -178,21 +178,12 @@
|
||||
"speed_m_s": 0.854,
|
||||
"ignore_socket_speed_enabled": true,
|
||||
"look_angle_deg": 6.0,
|
||||
"direction_sign": 1.0,
|
||||
"apply_freq_phase_correction": true,
|
||||
"frame_radius": 6,
|
||||
"edge_frame_weight": 0.75,
|
||||
"pitch_deg": 6.0,
|
||||
"center_depth_m": 5.6,
|
||||
"half_width_x_m": 1.8,
|
||||
"half_width_y_m": 1.0,
|
||||
"half_width_z_m": 1.8,
|
||||
"grid_nx": 150,
|
||||
"grid_ny": 120,
|
||||
"grid_nz": 160,
|
||||
"voxel_m": 0.02,
|
||||
"bp_ground_depth_m": 1.25,
|
||||
"comp_range_power": 0.3,
|
||||
"use_compensation": true,
|
||||
"normalize_pairs": true,
|
||||
"bottom_view_deg": 0.0
|
||||
"normalize_each_frame": true
|
||||
}
|
||||
},
|
||||
"preprocess_dialog": {
|
||||
|
||||
@@ -133,10 +133,27 @@ except Exception:
|
||||
echo "[start.sh] Detected radar model: ${RADAR_MODEL} (config: ${config_path})"
|
||||
}
|
||||
|
||||
# radar.replay.dataset_dir of a config, empty unless it replays a recording.
|
||||
replay_dataset_dir() {
|
||||
python3 -c '
|
||||
import json, sys
|
||||
try:
|
||||
radar = json.load(open(sys.argv[1])).get("radar", {})
|
||||
print(str(radar.get("replay", {}).get("dataset_dir", "")).strip())
|
||||
except Exception:
|
||||
print("")
|
||||
' "$1" 2>/dev/null
|
||||
}
|
||||
|
||||
# Acquisition producer for the active model, mirroring the process supervisor's
|
||||
# selection so --producer-only behaves identically to a full pipeline launch.
|
||||
# selection so --producer-only behaves identically to a full pipeline launch: a
|
||||
# replay dataset takes precedence over the model's producer.
|
||||
producer_command() {
|
||||
local config_path="$1"
|
||||
if [[ -n "$(replay_dataset_dir "${config_path}")" ]]; then
|
||||
printf '%s\0' "${PYTHON_CMD}" -m python_app.scripts.replay_raw_producer --config "${config_path}"
|
||||
return
|
||||
fi
|
||||
case "${RADAR_MODEL}" in
|
||||
kamil_adc)
|
||||
printf '%s\0' "${PYTHON_CMD}" -m python_app.scripts.kamil_adc_raw_producer --config "${config_path}" ;;
|
||||
@@ -244,7 +261,7 @@ ensure_system_dependencies() {
|
||||
return
|
||||
fi
|
||||
|
||||
echo "[start.sh] Installing system dependency: libusb-1.0 dev headers..."
|
||||
echo "[start.sh] Installing system dependencies: libusb-1.0 dev headers (and Qt xcb libraries on apt)..."
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
run_privileged apt-get update
|
||||
run_privileged apt-get install -y pkg-config libusb-1.0-0-dev libxcb-cursor0 libxkbcommon-x11-0
|
||||
|
||||
Reference in New Issue
Block a user