Update GPR and SAR3D processing

This commit is contained in:
Ayzen
2026-10-08 16:24:01 +03:00
parent 390015ec39
commit 3575ca68f5
67 changed files with 1187 additions and 2581 deletions
+12 -8
View File
@@ -109,7 +109,7 @@ TARGETS := \
$(BIN_DIR)/data_preprocessor \
$(BIN_DIR)/data_processor
.PHONY: all clean rph_radar_collector check-native check-sar3d
.PHONY: all clean rph_radar_collector check-native check-sar3d check-gpr
all: $(TARGETS)
@@ -119,19 +119,23 @@ check-native: $(NATIVE_BUILD_DIR)/native_regressions $(BIN_DIR)/collector_regres
LD_LIBRARY_PATH=$(abspath $(NATIVE_BUILD_DIR)/rph_sdk) $(BIN_DIR)/collector_regressions
$(NATIVE_BUILD_DIR)/native_regressions run_config_examples/run_config_simulator.example.json
SAR3D_TEST_SOURCES := data_acq_and_processing/tests/sar3d_fixture.cpp \
BACKPROJECTION_TEST_SOURCES := data_acq_and_processing/tests/backprojection_fixture.cpp \
data_acq_and_processing/common_cpp/ipc/src/shared_types.cpp \
data_acq_and_processing/processing/processors/src/sar3d_processor.cpp \
data_acq_and_processing/processing/processors/src/sar3d_gpu.cpp
SAR3D_TEST_OBJS := $(addprefix $(NATIVE_BUILD_DIR)/,$(SAR3D_TEST_SOURCES:.cpp=.o))
DEPFILES += $(SAR3D_TEST_OBJS:.o=.d)
data_acq_and_processing/processing/processors/src/sar3d_gpu.cpp \
data_acq_and_processing/processing/processors/src/gpr_processor.cpp
BACKPROJECTION_TEST_OBJS := $(addprefix $(NATIVE_BUILD_DIR)/,$(BACKPROJECTION_TEST_SOURCES:.cpp=.o))
DEPFILES += $(BACKPROJECTION_TEST_OBJS:.o=.d)
# The sar3d processor on the real CUDA backend against the notebook reference;
# without a usable GPU the test checks the warning and skips the rest.
check-sar3d: $(NATIVE_BUILD_DIR)/sar3d_fixture
SAR3D_FIXTURE="$(abspath $<)" $(PYTHON) data_acq_and_processing/tests/test_sar3d.py
check-sar3d: $(NATIVE_BUILD_DIR)/backprojection_fixture
BACKPROJECTION_FIXTURE="$(abspath $<)" $(PYTHON) data_acq_and_processing/tests/test_backprojection.py Sar3dTest
$(NATIVE_BUILD_DIR)/sar3d_fixture: $(SAR3D_TEST_OBJS)
check-gpr: $(NATIVE_BUILD_DIR)/backprojection_fixture
BACKPROJECTION_FIXTURE="$(abspath $<)" $(PYTHON) data_acq_and_processing/tests/test_backprojection.py GprTest
$(NATIVE_BUILD_DIR)/backprojection_fixture: $(BACKPROJECTION_TEST_OBJS)
$(CXX) $(NATIVE_CXXFLAGS) $^ -o $@ $(LDFLAGS) $(SAR3D_LDFLAGS)
$(NATIVE_BUILD_DIR)/data_acq_and_processing/processing/processors/src/sar3d_gpu.o: NATIVE_CXXFLAGS += $(SAR3D_CXXFLAGS)
+46 -13
View File
@@ -66,9 +66,10 @@ controls, processing settings and a copy of the live plot through an embedded we
- **pass_through** — magnitude and phase of S21 for each combo.
- **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`
- **gpr** — coherent back-projection on the ground plane, as in `Horns_motion_4x4_ground.py`,
with object detection. Radar height and look angle define the slice in radar X/Z axes;
the wave speed is 3×10⁸ m/s (air).
- **sar3d** — 3D SAR of the reference notebook `Multi_antenna_demonstrate_depthnorm.ipynb`
on an NVIDIA GPU through CUDA: front (X-Y), side (Z-Y), top (X-Z)
and oblique maximum projections of the imaged
volume. Its settings carry the notebook's parameter names; the scene window is
@@ -77,6 +78,28 @@ controls, processing settings and a copy of the live plot through an embedded we
(default 2 cm). The radar motion is corrected from each trace's capture
timestamps, so the sweep timing needs no parameters.
Both coherent GPR and SAR apply `PAIR_DEPTH_EQUALIZE` (on by default) before the
per-pair median normalization. The notebook's 1.5 m sliding median estimates each
pair's depth-dependent level; positive gains bring the pairs to their geometric
mean while preserving phase and the common depth envelope. **Subtract mean of previous
collections** subtracts up to **Mean count** preceding spectra for each pair before
motion correction and the IFFT; zero count disables it. SAR uses a causal aperture:
`SAR_FRAME_RADIUS` previous frames plus the current frame, with the current scene origin.
The processor retains enough history for the aperture and each frame's preceding
mean; transport ring capacity does not shorten this background window.
Scene width `SAR_X_SIZE_M` and length `SAR_Z_SIZE_M` use 0 for the automatic notebook
bounds; positive values resize the axes about their automatic centres. `SAR_Y_RANGE_M`
sets the height limits. The depth gate remains independent of these scene bounds.
SAR **Zoom (%)** shows the full scene at 100%; increasing it shrinks all four
views uniformly towards the brightest response. **SAR_CLOUD_THRESHOLD** hides
values below the selected fraction of the normalization maximum. Both controls
redraw the existing result without recalculating it. Tx/Rx markers appear on the
front, side and top projections in ground coordinates. A hollow square marks the
peak; its size follows the contiguous half-maximum width along the volume axes
(FWHM, at least one voxel). This measures the imaged response rather than the
reflector's physical dimensions.
The desktop uses a light theme with dark plot canvases. **Mode** stays below the
**Actions** buttons while the settings sections scroll; the settings panel and
runtime log can be collapsed with their arrow buttons. SAR3D status, peak
@@ -88,8 +111,8 @@ first, then input, and wraps to the first recorded pair; it is enabled with at l
two pairs. Desktop and browser selectors redraw the cached B-scan immediately.
Tx/Rx antenna positions are edited in tables with switch position and X/Y/Z in
metres, on both the desktop and web interface. Coordinate and legacy-GPR medium
changes apply to the running processor and recompute its retained result. Profiles
metres, on both the desktop and web interface. Coordinate changes apply to the
running processor and recompute its retained result. Profiles
preserve all three coordinates. A new table row becomes active after X is entered;
desktop, web and profile geometry use one Python validator; C++ validates its own
input. GPR/SAR views wait for a result marked with the current settings revision,
@@ -431,6 +454,7 @@ backup to `radar_system`. Restart the service if it is normally used.
QT_QPA_PLATFORM=offscreen .venv/bin/python -m unittest discover -s python_app/tests
make check-native
make check-sar3d PYTHON=.venv/bin/python
make check-gpr
ASAN_OPTIONS=detect_leaks=0 make check-native NATIVE_SANITIZERS=address,undefined
```
@@ -441,24 +465,33 @@ also exercise mutated frames, ring concurrency, locator input and ADC whole-swee
Firmware host tests check cart timer rollover and SBUS packing; they do not validate
physical timing. Real devices, GPIO and the CUDA backend need their own hardware run.
`check-sar3d` runs the real CUDA processor (`data_acq_and_processing/tests/test_sar3d.py`).
`check-sar3d` runs the real CUDA processor (`data_acq_and_processing/tests/test_backprojection.py`).
It compares it with `sar3d_reference.npz`: compact float32/complex64 inputs, the three
maximum projections of the notebook's volume, and its joint peak/level for four
compensation/normalization and timing cases. The expected values come from unchanged
functions in cells 0 and 4 of the original notebook. The same test checks the published
maximum projections of the notebook's volume, its joint peak/level and response
widths for six compensation/normalization, equalization, background, scene and
timing cases. The expected values come from unchanged functions in cells 1 and 14
of the original notebook, with each frame's previous-collection mean subtracted
from its input spectra by NumPy. The same test checks the published
status for invalid input, a result too large for the ring slot, an empty and a very weak
signal. Without a usable GPU it checks only the CUDA warning and skips the rest; the
notebook itself is not needed to run it. To regenerate the reference, pass the
original notebook (it is kept outside the repository):
```bash
.venv/bin/python data_acq_and_processing/tests/generate_sar3d_reference.py \
--notebook /path/to/Multi_antenna_3D_clean_v1.ipynb
.venv/bin/python data_acq_and_processing/tests/generate_backprojection_reference.py \
--notebook /path/to/Multi_antenna_demonstrate_depthnorm.ipynb \
--gpr-script /path/to/Horns_motion_4x4_ground.py
```
The archive records cell-source SHA256 hashes and NumPy/SciPy versions. Another build
The generator also writes `gpr_reference.npz` from the unchanged Horns coherent BP
functions, with the notebook equalizer inserted before scalar normalization.
`make check-gpr` compares the CPU processor with those images, including motion and
previous-collection subtraction. Neither source file is needed to run the tests.
The archives record source SHA256 hashes and NumPy/SciPy versions. Another build
can be checked with `make check-sar3d BUILD_DIR=/path/to/build` or by running
`test_sar3d.py` with `SAR3D_FIXTURE=/path/to/build/native_tests/sar3d_fixture`.
`test_backprojection.py Sar3dTest` with
`BACKPROJECTION_FIXTURE=/path/to/build/native_tests/backprojection_fixture`.
For older GPUs, pass the same `BUILD_DIR` and NVRTC 12 `CUDA_ROOTS` used to build.
For development, install `pyright` into the venv, then run
@@ -92,7 +92,6 @@ struct GprRxGeometry {
struct GprConfig {
// Initial GPR settings; the data_processor can override them through live config.
float relative_permittivity = 1.0F;
std::vector<GprTxGeometry> tx_geometry{};
std::vector<GprRxGeometry> rx_geometry{};
};
@@ -277,7 +277,6 @@ void validate_combo_key(const ipc::ComboKey& key, const RunConfig& config) {
[[nodiscard]] auto parse_gpr_config(const Json& object, const RunConfig& run_config) -> GprConfig {
const auto* gpr_obj = as_object(object, "gpr");
GprConfig config{};
config.relative_permittivity = optional_f32(*gpr_obj, "relative_permittivity", 1.0F);
if (const auto* tx_value = optional_field(*gpr_obj, "tx_geometry"); tx_value != nullptr) {
const auto* tx_array = as_array(*tx_value, "gpr.tx_geometry");
@@ -375,10 +374,6 @@ void validate_combo_key(const ipc::ComboKey& key, const RunConfig& config) {
} // namespace
void validate_gpr_config(const GprConfig& config, const RunConfig& run_config) {
if (!std::isfinite(config.relative_permittivity) || !(config.relative_permittivity > 0.0F)) {
throw std::runtime_error("gpr.relative_permittivity must be > 0");
}
std::unordered_set<std::uint32_t> seen_tx_positions{};
for (const auto& entry : config.tx_geometry) {
if (!std::isfinite(entry.x_m) || !std::isfinite(entry.y_m) || !std::isfinite(entry.z_m)) {
@@ -46,12 +46,7 @@ class DataProcessor {
// 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.
[[nodiscard]] auto build_locator_filter(const ProcessingLiveConfig& live_config) const
-> radar::locator::FilterParams;
void publish_locator(const ipc::ResultCollection& collection, const ProcessingLiveConfig& live_config);
void publish_locator(const ipc::ResultCollection& collection);
const config::RunConfig& config_;
config::RunConfig processing_config_;
@@ -10,19 +10,6 @@
namespace radar::processing {
enum class LegacyGprMode {
Point,
Extended,
};
enum class LegacyGprReferenceMode {
// t_ref = midpoint between the first and last event centers.
FrameCenter,
// t_ref = center of the first event. Useful when motion offsets should be
// accumulated from frame start, e.g. for tagging frames by their head time.
FirstTxEvent,
};
struct HistoryTarget {
std::uint64_t collection_id = 0;
std::uint64_t capture_start_ns = 0;
@@ -36,15 +23,13 @@ struct ProcessingLiveConfig {
float bscan_gain = 1.0F;
float bscan_start_freq_mhz = 100.0F;
float bscan_stop_freq_mhz = 8800.0F;
LegacyGprMode legacy_gpr_mode = LegacyGprMode::Point;
// Absent in legacy live files: use the initial run_config geometry and medium.
// Absent geometry overrides use the initial run_config geometry.
std::optional<config::GprConfig> gpr{};
std::vector<std::uint32_t> gpr_input_positions{};
std::vector<std::uint32_t> gpr_output_positions{};
float gpr_min_depth_m = 2.0F;
float gpr_max_depth_m = 14.0F;
float gpr_range_comp_power = 0.1F;
float gpr_comp_power = 0.2F;
// BP object-detection stop level, as a fraction of the global peak; peaks
// below it are not objects.
float gpr_object_min_frac = 0.7F;
@@ -54,29 +39,15 @@ struct ProcessingLiveConfig {
std::string gpr_motion_mode = "int_minus";
float gpr_speed_m_s = 0.0F;
float gpr_look_angle_deg = 0.0F;
// Motion model parameters for the legacy GPR pipeline. The direction sign
// selects which way later Tx-events appear deeper (+1) or shallower (-1).
// Intra-sweep phase correction compensates the motion that happens *inside*
// one Tx-sweep before the IFFT — it is independent of the per-pair coarse
// tau shift and can be disabled without affecting the rest of the pipeline.
// The reference mode picks the anchor used to compute dt_ref per event.
float gpr_direction_sign = 1.0F;
bool gpr_apply_freq_phase_correction = true;
LegacyGprReferenceMode gpr_reference_mode = LegacyGprReferenceMode::FrameCenter;
float gpr_snr_thresh = 4.5F;
float gpr_snr_comp_max = 25.0F;
float gpr_start_freq_mhz = 3000.0F;
float gpr_stop_freq_mhz = 6000.0F;
bool gpr_background_subtract_enabled = true;
std::uint32_t gpr_background_mean_count = 10U;
bool gpr_remove_sidelobe_objects_enabled = true;
// BP image is computed in the y=imaging_plane_y_m slice of the 3D grid.
// Default 0 keeps legacy 1D antenna layouts imaging in the antenna plane.
float gpr_imaging_plane_y_m = 0.0F;
// Coherent BP object visibility (window + the draw limits below) is applied in
// the processor itself, with no score threshold. Only legacy GPR thresholds,
// on a pair count.
float legacy_gpr_min_visible_pair_count = 0.0F;
// Ground plane in radar coordinates, at this height and the configured pitch.
float gpr_bp_ground_depth_m = 1.25F;
bool pair_depth_equalize = true;
std::uint32_t gpr_max_detected_objects_to_draw = 0;
std::uint32_t gpr_draw_top_m_objects = 0;
// Cross-frame approach filter: an object is shown only once it persists as a
@@ -89,7 +60,7 @@ struct ProcessingLiveConfig {
float gpr_visible_z_min_m = 0.0F;
float gpr_visible_z_max_m = 14.0F;
// 3D SAR (sar3d processor) parameters, named after the reference notebook
// Multi_antenna_3D_clean_v1.ipynb (SAR_FRAME_RADIUS, BP_GROUND_DEPTH_M, ...).
// Multi_antenna_demonstrate_depthnorm.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;
@@ -99,6 +70,10 @@ struct ProcessingLiveConfig {
float sar3d_comp_range_power = 0.3F;
bool sar3d_use_compensation = true;
bool sar3d_normalize_each_frame = true;
float sar3d_x_size_m = 0.0F;
float sar3d_y_min_m = -0.5F;
float sar3d_y_max_m = 2.0F;
float sar3d_z_size_m = 0.0F;
// 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.
@@ -22,10 +22,15 @@ namespace {
constexpr const char* kDefaultProcessorMode = "pass_through";
constexpr std::size_t kBscanReplayWindow = 50U;
[[nodiscard]] auto replay_history_limit(const config::RunConfig& config) -> std::size_t {
[[nodiscard]] auto processing_history_limit(const config::RunConfig& config, const ProcessingLiveConfig& live) -> std::size_t {
const auto preprocessed_capacity = static_cast<std::size_t>(std::max<std::uint32_t>(1U, config.rings.preprocessed.capacity));
const auto results_capacity = static_cast<std::size_t>(std::max<std::uint32_t>(1U, config.rings.results.capacity));
return std::min({preprocessed_capacity, results_capacity, kBscanReplayWindow});
std::size_t required = 1U;
if (live.processor_mode == "gpr" || live.processor_mode == "sar3d") {
if (live.gpr_background_subtract_enabled) { required += live.gpr_background_mean_count; }
if (live.processor_mode == "sar3d") { required += live.sar3d_frame_radius; }
}
return std::max(required, std::min({preprocessed_capacity, results_capacity, kBscanReplayWindow}));
}
void publish_result_collection(
@@ -68,7 +73,6 @@ DataProcessor::DataProcessor(
void DataProcessor::run(const std::atomic<bool>& stop_requested) {
std::vector<std::uint8_t> bytes{};
std::vector<ipc::PreprocessedCollection> preprocessed_history{};
const std::size_t history_limit = replay_history_limit(config_);
std::uint64_t last_replayed_revision = live_config_loader_.revision();
runtime::CollectionErrors errors;
std::uint64_t oversized_results = 0;
@@ -92,6 +96,7 @@ void DataProcessor::run(const std::atomic<bool>& stop_requested) {
processing_config_.gpr = live_config.gpr.value_or(config_.gpr);
}
auto& processor = resolve_processor(live_config);
const auto history_limit = processing_history_limit(config_, live_config);
const bool socket_speed_dirty = current_socket_speed != last_reprocessed_socket_speed;
const auto was_removed = [&](const ipc::PreprocessedCollection& collection) {
@@ -107,6 +112,9 @@ void DataProcessor::run(const std::atomic<bool>& stop_requested) {
};
if (live_revision != last_replayed_revision || socket_speed_dirty) {
std::erase_if(preprocessed_history, was_removed);
if (preprocessed_history.size() > history_limit) {
preprocessed_history.erase(preprocessed_history.begin(), preprocessed_history.end() - history_limit);
}
if (!live_config.reprocess_current_result) {
// Socket-fed speed updates should affect only future preprocessed collections,
@@ -120,7 +128,7 @@ void DataProcessor::run(const std::atomic<bool>& stop_requested) {
live_config
);
publish_result_collection(replay_result, results_ring_, oversized_results);
publish_locator(replay_result, live_config);
publish_locator(replay_result);
}
} else if (!preprocessed_history.empty()) {
const auto replay_result = process_collection(
@@ -130,7 +138,7 @@ void DataProcessor::run(const std::atomic<bool>& stop_requested) {
live_config
);
publish_result_collection(replay_result, results_ring_, oversized_results);
publish_locator(replay_result, live_config);
publish_locator(replay_result);
}
last_replayed_revision = live_revision;
//record the speed we just reprocessed with so an
@@ -156,8 +164,8 @@ void DataProcessor::run(const std::atomic<bool>& stop_requested) {
preprocessed.capture_start_ns <= preprocessed_history.back().capture_start_ns;
if (!fed_back) {
preprocessed_history.push_back(std::move(preprocessed));
while (preprocessed_history.size() > history_limit) {
preprocessed_history.erase(preprocessed_history.begin());
if (preprocessed_history.size() > history_limit) {
preprocessed_history.erase(preprocessed_history.begin(), preprocessed_history.end() - history_limit);
}
}
const auto& current = fed_back ? preprocessed : preprocessed_history.back();
@@ -175,7 +183,7 @@ void DataProcessor::run(const std::atomic<bool>& stop_requested) {
);
publish_result_collection(result_collection, results_ring_, oversized_results);
if (!fed_back) {
publish_locator(result_collection, live_config);
publish_locator(result_collection);
}
errors.progress();
continue;
@@ -237,40 +245,13 @@ auto DataProcessor::socket_speed(const ProcessingLiveConfig& live_config) const
return locator_server_->latest_socket_speed();
}
auto DataProcessor::build_locator_filter(const ProcessingLiveConfig& live_config) const
-> radar::locator::FilterParams {
const std::string requested_mode =
live_config.processor_mode.empty() ? default_processor_mode_ : live_config.processor_mode;
radar::locator::FilterParams filter{};
if (requested_mode == "legacy_gpr") {
// Legacy GPR emits its objects unfiltered, so the socket applies the legacy
// rule here: clip to the visible window and threshold on the pair count. The
// GUI disables "draw top N" for legacy, so we skip it on the wire to match.
filter.visible_bounds = radar::locator::VisibleBounds{
.x_min = live_config.gpr_visible_x_min_m,
.x_max = live_config.gpr_visible_x_max_m,
.z_min = live_config.gpr_visible_z_min_m,
.z_max = live_config.gpr_visible_z_max_m,
};
filter.min_score = live_config.legacy_gpr_min_visible_pair_count;
filter.draw_limits.reset();
}
// Coherent BP already emits the FINAL visible object set from the processor
// (window + N/M, no score threshold), so the
// socket forwards it verbatim. The default FilterParams passes everything through
// (it only drops non-finite rows), keeping the filtering logic in one place.
return filter;
}
void DataProcessor::publish_locator(
const ipc::ResultCollection& collection,
const ProcessingLiveConfig& live_config
const ipc::ResultCollection& collection
) {
if (locator_server_ == nullptr || !locator_server_->is_running()) {
if (locator_server_ == nullptr) {
return;
}
locator_server_->publish(collection, build_locator_filter(live_config));
locator_server_->publish(collection);
}
auto create_default_processors() -> ProcessorRegistry {
@@ -287,10 +268,6 @@ auto create_default_processors() -> ProcessorRegistry {
auto processor = std::make_unique<GprProcessor>();
processors.emplace(processor->name(), std::move(processor));
}
{
auto processor = std::make_unique<LegacyGprProcessor>();
processors.emplace(processor->name(), std::move(processor));
}
{
auto processor = std::make_unique<Sar3dProcessor>();
processors.emplace(processor->name(), std::move(processor));
@@ -19,28 +19,6 @@ namespace {
using Json = nlohmann::json;
[[nodiscard]] auto parse_legacy_gpr_mode(const std::string& value, const std::string& field_name) -> LegacyGprMode {
if (value == "point") {
return LegacyGprMode::Point;
}
if (value == "extended") {
return LegacyGprMode::Extended;
}
throw std::runtime_error(field_name + " must be one of: point, extended");
}
[[nodiscard]] auto parse_legacy_gpr_reference_mode(
const std::string& value, const std::string& field_name
) -> LegacyGprReferenceMode {
if (value == "frame_center") {
return LegacyGprReferenceMode::FrameCenter;
}
if (value == "first_tx_event") {
return LegacyGprReferenceMode::FirstTxEvent;
}
throw std::runtime_error(field_name + " must be one of: frame_center, first_tx_event");
}
[[nodiscard]] auto parse_gpr_score_mode(const std::string& value, const std::string& field_name) -> std::string {
if (value == "peak" || value == "combined") {
return value;
@@ -55,24 +33,6 @@ using Json = nlohmann::json;
throw std::runtime_error(field_name + " must be one of: int_minus, int_focus");
}
void apply_legacy_gpr_algorithm_alias(ProcessingLiveConfig& config, const std::string& value) {
if (value == "backprojection") {
return;
}
if (value == "legacy_point") {
config.legacy_gpr_mode = LegacyGprMode::Point;
} else if (value == "legacy_extended") {
config.legacy_gpr_mode = LegacyGprMode::Extended;
} else {
throw std::runtime_error(
"processing.gpr_algorithm must be one of: backprojection, legacy_point, legacy_extended"
);
}
if (config.processor_mode.empty() || config.processor_mode == "gpr") {
config.processor_mode = "legacy_gpr";
}
}
[[nodiscard]] auto parse_f32(const Json& value, const std::string& field_name) -> float {
if (!value.is_number()) {
throw std::runtime_error(field_name + " must be number");
@@ -192,11 +152,8 @@ auto parse_geometry(const Json& value, const char* position_field, std::uint32_t
if (const auto found = root.find("settings_revision"); found != root.end()) {
config.settings_revision = parse_u64_number(*found, "processing.settings_revision");
}
if (root.contains("relative_permittivity") || root.contains("tx_geometry") || root.contains("rx_geometry")) {
if (root.contains("tx_geometry") || root.contains("rx_geometry")) {
config.gpr = run_config.gpr;
if (const auto found = root.find("relative_permittivity"); found != root.end()) {
config.gpr->relative_permittivity = parse_f32(*found, "processing.relative_permittivity");
}
if (const auto found = root.find("tx_geometry"); found != root.end()) {
config.gpr->tx_geometry = parse_geometry(*found, "output_pos", &config::GprTxGeometry::output_pos);
}
@@ -206,25 +163,16 @@ auto parse_geometry(const Json& value, const char* position_field, std::uint32_t
config::validate_gpr_config(*config.gpr, run_config);
}
assign_optional(root, "processor_mode", config.processor_mode);
if (!config.processor_mode.empty() && config.processor_mode != "pass_through"
&& config.processor_mode != "bscan" && config.processor_mode != "gpr" && config.processor_mode != "sar3d") {
throw std::runtime_error("Unsupported processing.processor_mode: " + config.processor_mode);
}
assign_optional(root, "bscan_axis", config.bscan_axis);
assign_optional(root, "bscan_cut_m", config.bscan_cut_m);
assign_optional(root, "bscan_max_depth_m", config.bscan_max_depth_m);
assign_optional(root, "bscan_gain", config.bscan_gain);
assign_optional(root, "bscan_start_freq_mhz", config.bscan_start_freq_mhz);
assign_optional(root, "bscan_stop_freq_mhz", config.bscan_stop_freq_mhz);
if (const auto found = root.find("legacy_gpr_mode"); found != root.end()) {
if (!found->is_string()) {
throw std::runtime_error("processing.legacy_gpr_mode must be string");
}
config.legacy_gpr_mode =
parse_legacy_gpr_mode(found->get<std::string>(), "processing.legacy_gpr_mode");
}
if (const auto found = root.find("gpr_algorithm"); found != root.end()) {
if (!found->is_string()) {
throw std::runtime_error("processing.gpr_algorithm must be string");
}
apply_legacy_gpr_algorithm_alias(config, found->get<std::string>());
}
if (const auto found = root.find("gpr_input_positions"); found != root.end()) {
config.gpr_input_positions = parse_u32_array(*found, "processing.gpr_input_positions");
}
@@ -235,7 +183,6 @@ auto parse_geometry(const Json& value, const char* position_field, std::uint32_t
assign_optional(root, "gpr_max_depth_m", config.gpr_max_depth_m);
assign_optional(root, "gpr_range_comp_power", config.gpr_range_comp_power);
assign_optional(root, "gpr_object_min_frac", config.gpr_object_min_frac);
assign_optional(root, "gpr_comp_power", config.gpr_comp_power);
if (const auto found = root.find("gpr_score_mode"); found != root.end()) {
if (!found->is_string()) {
throw std::runtime_error("processing.gpr_score_mode must be string");
@@ -251,16 +198,6 @@ auto parse_geometry(const Json& value, const char* position_field, std::uint32_t
assign_optional(root, "gpr_speed_m_s", config.gpr_speed_m_s);
assign_optional(root, "gpr_look_angle_deg", config.gpr_look_angle_deg);
assign_optional(root, "gpr_direction_sign", config.gpr_direction_sign);
assign_optional(root, "gpr_apply_freq_phase_correction", config.gpr_apply_freq_phase_correction);
if (const auto found = root.find("gpr_reference_mode"); found != root.end()) {
if (!found->is_string()) {
throw std::runtime_error("processing.gpr_reference_mode must be string");
}
config.gpr_reference_mode =
parse_legacy_gpr_reference_mode(found->get<std::string>(), "processing.gpr_reference_mode");
}
assign_optional(root, "gpr_snr_thresh", config.gpr_snr_thresh);
assign_optional(root, "gpr_snr_comp_max", config.gpr_snr_comp_max);
assign_optional(root, "gpr_start_freq_mhz", config.gpr_start_freq_mhz);
assign_optional(root, "gpr_stop_freq_mhz", config.gpr_stop_freq_mhz);
assign_optional(root, "gpr_background_subtract_enabled", config.gpr_background_subtract_enabled);
@@ -268,7 +205,8 @@ auto parse_geometry(const Json& value, const char* position_field, std::uint32_t
config.gpr_background_mean_count = parse_u32_number(*found, "processing.gpr_background_mean_count");
}
assign_optional(root, "gpr_remove_sidelobe_objects_enabled", config.gpr_remove_sidelobe_objects_enabled);
assign_optional(root, "gpr_imaging_plane_y_m", config.gpr_imaging_plane_y_m);
assign_optional(root, "gpr_bp_ground_depth_m", config.gpr_bp_ground_depth_m);
assign_optional(root, "pair_depth_equalize", config.pair_depth_equalize);
for (const auto& [key, target] : {
std::pair{"gpr_visible_x_min_m", &config.gpr_visible_x_min_m},
std::pair{"gpr_visible_x_max_m", &config.gpr_visible_x_max_m},
@@ -277,7 +215,6 @@ auto parse_geometry(const Json& value, const char* position_field, std::uint32_t
}) {
assign_optional(root, key, *target);
}
assign_optional(root, "legacy_gpr_min_visible_pair_count", config.legacy_gpr_min_visible_pair_count);
if (const auto found = root.find("gpr_max_detected_objects_to_draw"); found != root.end()) {
config.gpr_max_detected_objects_to_draw =
parse_u32_number(*found, "processing.gpr_max_detected_objects_to_draw");
@@ -297,6 +234,10 @@ auto parse_geometry(const Json& value, const char* position_field, std::uint32_t
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_x_size_m", &config.sar3d_x_size_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_size_m", &config.sar3d_z_size_m},
}) {
assign_optional(root, key, *target);
}
@@ -306,6 +247,10 @@ auto parse_geometry(const Json& value, const char* position_field, std::uint32_t
}) {
assign_optional(root, key, *target);
}
if (config.sar3d_x_size_m < 0.0F || config.sar3d_z_size_m < 0.0F ||
config.sar3d_y_max_m <= config.sar3d_y_min_m) {
throw std::runtime_error("SAR scene sizes must be non-negative and SAR_Y_RANGE_M must increase");
}
assign_optional(root, "ignore_socket_speed", config.ignore_socket_speed);
assign_optional(root, "reprocess_current_result", config.reprocess_current_result);
if (const auto found = root.find("history_clear_before_ns"); found != root.end()) {
@@ -1,7 +1,6 @@
#pragma once
#include <cstdint>
#include <optional>
#include <string>
#include <vector>
@@ -10,47 +9,9 @@
namespace radar::locator {
// Inclusive bounds used to clip locator observations to a visible window.
struct VisibleBounds {
float x_min = 0.0F;
float x_max = 0.0F;
float z_min = 0.0F;
float z_max = 0.0F;
};
// Limits mirroring the GUI semantics: if the number of detected objects exceeds
// `max_detected_objects`, the result is intentionally empty (matches Python
// reference). Otherwise, at most `draw_top_objects` rows are emitted.
struct DrawLimits {
std::uint32_t max_detected_objects = 0;
std::uint32_t draw_top_objects = 0;
};
// Filter parameters for the payload builder. None of these fields couple to GUI
// state; they are resolved by the data_processor from the live processing config
// and passed in explicitly so the builder remains a pure function.
struct FilterParams {
float min_score = 0.0F;
std::optional<VisibleBounds> visible_bounds{};
std::optional<DrawLimits> draw_limits{};
};
// Extract observations from one result collection.
//
// Mirrors `python_app/orchestration/gpr_locator.py::locator_observations_from_collection`:
// * Looks for a `gpr_points` TableF32 payload first, then falls back to
// `gpr_region_centers`. Both layouts encode `[x_m, z_m, score, ...]` rows.
// * Drops rows with non-finite coordinates and those below `min_score`.
// * If `visible_bounds` are provided, drops rows outside the inclusive window.
// * If `draw_limits` are provided and the surviving count exceeds
// `max_detected_objects`, returns an empty vector (the GUI's "too many to
// trust" heuristic). Otherwise, keeps the first `draw_top_objects` rows.
//
// The function never throws; malformed payloads degrade to an empty result.
[[nodiscard]] auto observations_from_collection(
const ipc::ResultCollection& collection,
const FilterParams& filter
) -> std::vector<Observation>;
// Extract the final coherent-GPR objects, dropping malformed or non-finite rows.
[[nodiscard]] auto observations_from_collection(const ipc::ResultCollection& collection)
-> std::vector<Observation>;
// Serialize a list of observations into the JSON payload format expected by
// locator clients: `{"ver": <n>, "tim": "HH:MM:SS.mmm", "sts": 1, "obs": [...]}`.
@@ -138,7 +138,7 @@ class TcpServer {
// Build and broadcast one locator packet derived from a GPR result
// collection. Never blocks for I/O. Filter parameters are supplied by the
// caller so the builder remains state-free.
void publish(const ipc::ResultCollection& collection, const FilterParams& filter);
void publish(const ipc::ResultCollection& collection);
// Most recent `vlc` value received from any connected client, or nullopt
// if no client has ever submitted one since startup. Lock-free.
@@ -19,14 +19,12 @@ namespace {
using Json = nlohmann::json;
constexpr const char* kGprPointsName = "gpr_points";
constexpr const char* kGprRegionCentersName = "gpr_region_centers";
constexpr std::uint32_t kMinTableColumns = 3U; // [x_m, z_m, score, ...]
// Locate the first TableF32 payload matching one of the GPR object names.
// Returns nullptr if no usable payload is present in the collection.
[[nodiscard]] auto find_object_table(const ipc::ResultCollection& collection)
-> const ipc::ResultPayload* {
const ipc::ResultPayload* fallback = nullptr;
for (const auto& payload : collection.collection_payloads) {
if (payload.kind != ipc::ResultKind::TableF32) {
continue;
@@ -37,11 +35,8 @@ constexpr std::uint32_t kMinTableColumns = 3U; // [x_m, z_m, score, ...]
if (payload.processing_name == kGprPointsName) {
return &payload;
}
if (fallback == nullptr && payload.processing_name == kGprRegionCentersName) {
fallback = &payload;
}
}
return fallback;
return nullptr;
}
// Quantise to one decimal place (0.1 m = decimetres). Equivalent to Python's
@@ -51,29 +46,6 @@ constexpr std::uint32_t kMinTableColumns = 3U; // [x_m, z_m, score, ...]
return std::round(value * 10.0) / 10.0;
}
[[nodiscard]] auto passes_basic_filter(
float x_m,
float z_m,
float score,
float min_score
) -> bool {
if (!std::isfinite(x_m) || !std::isfinite(z_m) || !std::isfinite(score)) {
return false;
}
return score >= min_score;
}
[[nodiscard]] auto passes_visible_bounds(
float x_m,
float z_m,
const VisibleBounds& bounds
) -> bool {
return x_m >= bounds.x_min
&& x_m <= bounds.x_max
&& z_m >= bounds.z_min
&& z_m <= bounds.z_max;
}
// Render the current wall-clock time as "HH:MM:SS.mmm". Uses localtime to match
// the Python reference behaviour (which calls datetime.now() with no tz info).
[[nodiscard]] auto format_timestamp_now() -> std::string {
@@ -107,66 +79,20 @@ void append_u32_little_endian(std::vector<std::uint8_t>& buffer, std::uint32_t v
} // namespace
auto observations_from_collection(
const ipc::ResultCollection& collection,
const FilterParams& filter
) -> std::vector<Observation> {
auto observations_from_collection(const ipc::ResultCollection& collection) -> std::vector<Observation> {
const auto* payload = find_object_table(collection);
if (payload == nullptr) {
return {};
}
const std::uint32_t columns = payload->table_columns;
if (columns == 0U) {
return {};
}
const std::size_t row_count = payload->table_values.size() / columns;
if (row_count == 0U) {
return {};
}
std::vector<Observation> visible;
visible.reserve(row_count);
for (std::size_t row = 0; row < row_count; ++row) {
const std::size_t base = row * static_cast<std::size_t>(columns);
const float x_m = payload->table_values[base + 0U];
const float z_m = payload->table_values[base + 1U];
const float score = payload->table_values[base + 2U];
if (!passes_basic_filter(x_m, z_m, score, filter.min_score)) {
continue;
}
if (filter.visible_bounds.has_value()
&& !passes_visible_bounds(x_m, z_m, *filter.visible_bounds)) {
continue;
}
visible.push_back({.crs = x_m, .dst = z_m});
}
if (!filter.draw_limits.has_value()) {
std::vector<Observation> quantised;
quantised.reserve(visible.size());
for (const auto& observation : visible) {
quantised.push_back({
.crs = quantise_to_decimetres(observation.crs),
.dst = quantise_to_decimetres(observation.dst),
});
}
return quantised;
}
const auto& limits = *filter.draw_limits;
if (visible.size() > limits.max_detected_objects) {
return {};
}
const std::size_t kept = std::min<std::size_t>(visible.size(), limits.draw_top_objects);
const std::size_t columns = payload->table_columns;
std::vector<Observation> result;
result.reserve(kept);
for (std::size_t index = 0; index < kept; ++index) {
result.push_back({
.crs = quantise_to_decimetres(visible[index].crs),
.dst = quantise_to_decimetres(visible[index].dst),
});
for (std::size_t base = 0; base + columns <= payload->table_values.size(); base += columns) {
const auto x = payload->table_values[base];
const auto z = payload->table_values[base + 1];
const auto score = payload->table_values[base + 2];
if (std::isfinite(x) && std::isfinite(z) && std::isfinite(score)) {
result.push_back({.crs = quantise_to_decimetres(x), .dst = quantise_to_decimetres(z)});
}
}
return result;
}
@@ -461,12 +461,12 @@ auto TcpServer::is_running() const -> bool {
return running_.load(std::memory_order_acquire);
}
void TcpServer::publish(const ipc::ResultCollection& collection, const FilterParams& filter) {
void TcpServer::publish(const ipc::ResultCollection& collection) {
if (!running_.load(std::memory_order_acquire)) {
return;
}
const auto observations = observations_from_collection(collection, filter);
const auto observations = observations_from_collection(collection);
const auto payload_json = build_payload_json(observations, config_.protocol_version);
auto packet = encode_packet(payload_json, config_.device_id);
cache_latest_packet(packet);
@@ -21,15 +21,4 @@ class GprProcessor final : public ProcessorInterface {
ObjectApproachFilter approach_filter_{};
};
class LegacyGprProcessor final : public ProcessorInterface {
public:
[[nodiscard]] auto name() const -> std::string override;
[[nodiscard]] auto process_collection(
const config::RunConfig& run_config,
const ipc::PreprocessedCollection& collection,
std::span<const ipc::PreprocessedCollection> previous_collections,
const ProcessingLiveConfig& live_config
) -> ipc::ResultCollection override;
};
} // namespace radar::processing
@@ -8,7 +8,7 @@
namespace radar::processing {
// 3D SAR of the reference notebook Multi_antenna_3D_clean_v1.ipynb on an NVIDIA
// 3D SAR of the reference notebook Multi_antenna_demonstrate_depthnorm.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
@@ -0,0 +1,231 @@
#pragma once
#include "shared_types.hpp"
#include <algorithm>
#include <cmath>
#include <complex>
#include <cstdint>
#include <span>
#include <map>
#include <limits>
#include <numbers>
#include <stdexcept>
#include <unordered_map>
#include <vector>
namespace radar::processing::bp {
constexpr double kWaveSpeed = 3e8; // v in both reference algorithms
using Background = std::unordered_map<ipc::ComboKey, std::vector<std::complex<double>>, ipc::ComboKeyHash>;
// Mean of up to count previous collections, separately for each physical pair.
inline auto background_mean(std::span<const ipc::PreprocessedCollection> previous, bool enabled,
std::size_t count) -> Background {
Background result;
if (!enabled || count == 0U) { return result; }
std::unordered_map<ipc::ComboKey, std::size_t, ipc::ComboKeyHash> counts;
for (const auto& collection : previous.last(std::min(count, previous.size()))) {
for (const auto& trace : collection.traces) {
const auto key = trace.combo;
auto& sum = result[key];
if (sum.empty()) { sum.resize(trace.s21.size()); }
if (sum.size() != trace.s21.size()) { continue; }
for (std::size_t i = 0; i < sum.size(); ++i) {
sum[i] += std::complex<double>(trace.s21[i].re, trace.s21[i].im);
}
++counts[key];
}
}
for (auto& [key, samples] : result) {
for (auto& sample : samples) { sample /= static_cast<double>(counts.at(key)); }
}
return result;
}
inline auto subtract_background(const ipc::SweepTraceBlock& trace, const Background& background)
-> std::vector<std::complex<double>> {
std::vector<std::complex<double>> samples;
samples.reserve(trace.s21.size());
const auto found = background.find(trace.combo);
const bool subtract = found != background.end() && found->second.size() == trace.s21.size();
for (std::size_t i = 0; i < trace.s21.size(); ++i) {
samples.emplace_back(std::complex<double>(trace.s21[i].re, trace.s21[i].im) -
(subtract ? found->second[i] : std::complex<double>{}));
}
return samples;
}
// When the samples of a trace were measured, relative to the acquisition centre
// of its collection (compute_pair_timestamps_for_snapshot and
// compute_frequency_sample_times). Traces sharing a capture window come from
// one sweep: at every frequency point its stimulus stages - the outputs, which
// are numbered in device stage order - take turns while all inputs receive.
struct TraceClock {
double start_s = 0.0; // sweep start
double event_s = 0.0; // sweep duration / (stages * points)
std::size_t stage = 0U;
std::size_t stages = 1U;
[[nodiscard]] auto sample_time_s(std::size_t point) const -> double {
return start_s + (static_cast<double>(stages) * point + stage) * event_s;
}
};
struct CollectionTiming {
std::uint64_t center_ns = 0U; // centre of the collection's capture window
std::vector<TraceClock> clocks; // per collection trace
};
// Timing from the per-trace capture windows (snapshot_acquisition_timing); every
// trace takes part, so the stages do not depend on the INPUT_IDX/OUTPUT_IDX choice.
[[nodiscard]] inline auto collection_timing(const ipc::PreprocessedCollection& collection) -> CollectionTiming {
if (collection.traces.empty()) {
throw std::runtime_error("collection has no traces");
}
std::map<std::pair<std::uint64_t, std::uint64_t>, std::vector<std::uint32_t>> sweep_outputs{};
std::uint64_t first = std::numeric_limits<std::uint64_t>::max();
std::uint64_t last = 0U;
for (const auto& trace : collection.traces) {
if (trace.capture_end_ns <= trace.capture_start_ns) {
throw std::runtime_error("collection has traces without a capture window");
}
if (trace.frequency_hz.empty()) {
throw std::runtime_error("collection has a trace without frequency points");
}
sweep_outputs[{trace.capture_start_ns, trace.capture_end_ns}].push_back(trace.combo.output_pos);
first = std::min(first, trace.capture_start_ns);
last = std::max(last, trace.capture_end_ns);
}
for (auto& [window, outputs] : sweep_outputs) {
std::sort(outputs.begin(), outputs.end());
outputs.erase(std::unique(outputs.begin(), outputs.end()), outputs.end());
}
CollectionTiming timing{};
const auto duration_ns = last - first;
timing.center_ns = first + duration_ns / 2U;
for (const auto& trace : collection.traces) {
const auto& stages = sweep_outputs.at({trace.capture_start_ns, trace.capture_end_ns});
const auto stage = std::find(stages.begin(), stages.end(), trace.combo.output_pos) - stages.begin();
TraceClock clock{};
clock.start_s = 1e-9 * (static_cast<double>(trace.capture_start_ns - first) -
0.5 * static_cast<double>(duration_ns));
clock.stage = stage;
clock.stages = stages.size();
clock.event_s = 1e-9 * static_cast<double>(trace.capture_end_ns - trace.capture_start_ns) /
static_cast<double>(clock.stages) / trace.frequency_hz.size();
timing.clocks.push_back(clock);
}
return timing;
}
// apply_intra_sweep_phase_correction: one phase model includes the pair offset
// and the intra-sweep motion. Focus mode removes its constant and linear terms.
inline void apply_motion_phase(std::vector<std::complex<double>>& samples, const std::vector<float>& frequency,
const TraceClock& clock, double speed, double pitch, double direction, bool focus,
double f_start, double f_stop) {
if (speed == 0.0) { return; }
std::vector<std::size_t> points;
std::vector<double> phase;
const double factor = 4.0 * std::numbers::pi * direction * speed * std::cos(pitch) / kWaveSpeed;
double sum_f = 0.0, sum_phase = 0.0;
for (std::size_t i = 0; i < frequency.size(); ++i) {
if (frequency[i] < f_start || frequency[i] > f_stop) { continue; }
points.push_back(i);
phase.push_back(factor * frequency[i] * clock.sample_time_s(i));
sum_f += frequency[i];
sum_phase += phase.back();
}
if (points.empty()) { return; }
if (focus) {
const double mean_f = sum_f / points.size(), mean_phase = sum_phase / points.size();
double cross = 0.0, square = 0.0;
for (std::size_t i = 0; i < points.size(); ++i) {
const double f = frequency[points[i]] - mean_f;
cross += f * phase[i]; square += f * f;
}
const double slope = square > 0.0 ? cross / square : 0.0;
for (std::size_t i = 0; i < points.size(); ++i) {
phase[i] -= mean_phase + slope * (frequency[points[i]] - mean_f);
}
}
for (std::size_t i = 0; i < points.size(); ++i) { samples[points[i]] *= std::polar(1.0, -phase[i]); }
}
// pair_depth_eq_z_max: cover the far scene corner, aperture and half a median window.
inline auto equalize_depth_limit(double max_depth, double x_min, double x_max, double antenna_x_max) -> double {
const double x_far = std::max(std::abs(x_min), std::abs(x_max)) + antenna_x_max;
return std::sqrt(max_depth * max_depth + x_far * x_far + 2.5 * 2.5) + 1.5 + 0.75;
}
// bp_plane_y, Horns_motion_4x4_ground.py: the ground plane in radar axes.
inline auto ground_y(double z, double height, double pitch) -> double {
if (std::abs(std::cos(pitch)) < 1e-6) { throw std::runtime_error("Ground BP is undefined at a look angle of 90 degrees"); }
return (-height + z * std::sin(pitch)) / std::cos(pitch);
}
// equalize_pairs_by_depth, Multi_antenna_demonstrate_depthnorm.ipynb, cell 1.
// All time axes start at zero; nearest median filtering uses the upper median
// for even windows, as scipy.ndimage.median_filter does.
template <class Real>
void equalize_pairs_by_depth(const std::vector<std::span<std::complex<Real>>>& pairs, double dz, double z_max) {
if (pairs.size() < 2U) { return; }
std::size_t length = pairs.front().size();
for (const auto pair : pairs) { length = std::min(length, pair.size()); }
const double step = std::max(1.0, std::nearbyint(0.10 / dz));
if (step > length) { return; }
const auto d = static_cast<std::size_t>(step);
const auto n = static_cast<std::size_t>(std::max(3.0, std::nearbyint(1.5 / (dz * d))));
const auto nc = static_cast<std::size_t>(std::min(static_cast<double>(length), std::ceil(z_max / dz))) / d;
if (nc < 2U) { return; }
std::vector<std::vector<double>> levels(pairs.size(), std::vector<double>(nc));
std::vector<double> common(nc, 0.0), envelope(nc), window(n);
for (std::size_t a = 0; a < pairs.size(); ++a) {
for (std::size_t i = 0; i < nc; ++i) {
double sum = 0.0;
for (std::size_t k = 0; k < d; ++k) { sum += std::abs(std::complex<double>(pairs[a][i * d + k])); }
envelope[i] = sum / static_cast<double>(d);
}
for (std::size_t i = 0; i < nc; ++i) {
for (std::size_t k = 0; k < n; ++k) {
const auto index = std::clamp<std::ptrdiff_t>(static_cast<std::ptrdiff_t>(i) +
static_cast<std::ptrdiff_t>(k) - static_cast<std::ptrdiff_t>(n / 2), 0, nc - 1);
window[k] = envelope[index];
}
std::nth_element(window.begin(), window.begin() + n / 2, window.end());
levels[a][i] = std::log(std::max(window[n / 2], 1e-30));
common[i] += levels[a][i] / static_cast<double>(pairs.size());
}
}
for (std::size_t a = 0; a < pairs.size(); ++a) {
for (std::size_t i = 0; i < nc; ++i) { levels[a][i] = std::exp(common[i] - levels[a][i]); }
for (std::size_t i = 0; i < nc * d; ++i) {
const double pos = (static_cast<double>(i) - (static_cast<double>(d) - 1.0) / 2.0) / d;
const auto j = static_cast<std::size_t>(std::clamp(std::floor(pos), 0.0, static_cast<double>(nc - 2)));
const double w = std::clamp(pos - j, 0.0, 1.0);
pairs[a][i] *= static_cast<Real>(levels[a][j] * (1.0 - w) + levels[a][j + 1] * w);
}
}
}
// Scalar median normalization follows the depth equalizer in both references.
template <class Real>
void normalize_pair(std::span<std::complex<Real>> samples, double dz, double min_depth, double max_depth) {
std::vector<double> magnitudes;
for (std::size_t i = 0; i < samples.size(); ++i) {
if (i * dz >= min_depth && i * dz <= max_depth) { magnitudes.push_back(std::abs(std::complex<double>(samples[i]))); }
}
if (magnitudes.empty()) {
for (const auto sample : samples) { magnitudes.push_back(std::abs(std::complex<double>(sample))); }
}
if (magnitudes.empty()) { return; }
const auto middle = magnitudes.begin() + magnitudes.size() / 2;
std::nth_element(magnitudes.begin(), middle, magnitudes.end());
double scale = *middle;
if (magnitudes.size() % 2 == 0) { scale = 0.5 * (scale + *std::max_element(magnitudes.begin(), middle)); }
if (!std::isfinite(scale) || scale <= 1e-15) { scale = 1.0; }
for (auto& sample : samples) { sample /= static_cast<Real>(scale + 1e-15); }
}
} // namespace radar::processing::bp
@@ -1,5 +1,4 @@
constexpr double kPi = 3.14159265358979323846;
constexpr double kSpeedOfLightMetersPerSec = 299'792'458.0;
constexpr double kXMarginM = 2.0;
constexpr double kGridZMinM = 0.1;
constexpr std::size_t kGridWidth = 300U;
@@ -7,13 +6,8 @@ constexpr std::size_t kGridHeight = 300U;
constexpr std::size_t kAscanOversample = 8U;
constexpr double kRangeWeightMax = 5.0;
constexpr double kAngleWeightMax = 2.0;
constexpr double kTotalWeightMax = 8.0;
constexpr double kCompensationReferenceDepthM = 5.0;
constexpr bool kPairNormalize = true;
constexpr double kPairNormPercentile = 50.0;
constexpr double kPairNormEps = 1e-15;
constexpr double kSmoothSigma = 1.5;
// Gaussian-kernel half-width in sigmas, matching scipy.ndimage.gaussian_filter's
@@ -55,15 +49,8 @@ using PairKey = std::uint64_t;
// IntMinus — full intra-frequency correction to the interleaved-sweep centre.
// IntFocus — remove the constant + linear-in-frequency part of phi(f), leaving
// the focusing residual without a net Z shift.
enum class MotionMode { IntMinus, IntFocus };
[[nodiscard]] auto parse_motion_mode(const std::string& value) -> MotionMode {
return value == "int_focus" ? MotionMode::IntFocus : MotionMode::IntMinus;
}
struct GeometrySelection {
// Per-local-index Tx/Rx antenna coordinates in metres. y_/z_ default to 0
// for legacy configs so the imaging plane coincides with the antennas.
// Per-local-index Tx/Rx antenna coordinates in metres.
std::vector<std::uint32_t> input_positions{};
std::vector<std::uint32_t> output_positions{};
std::vector<double> x_tx{};
@@ -76,11 +63,6 @@ struct GeometrySelection {
std::unordered_map<std::uint32_t, std::uint32_t> output_local_by_pos{};
};
struct BackgroundAccumulator {
std::vector<std::complex<double>> sum{};
std::size_t count = 0U;
};
struct SelectedTrace {
ipc::ComboKey combo{};
std::uint32_t tx_local_index = 0U;
@@ -268,20 +250,6 @@ void fft_inplace(std::vector<std::complex<double>>& values, bool inverse) {
return axis;
}
[[nodiscard]] auto clamp_index(std::ptrdiff_t value, std::size_t limit) -> std::size_t {
if (limit == 0U) {
return 0U;
}
if (value < 0) {
return 0U;
}
const auto max_index = static_cast<std::ptrdiff_t>(limit - 1U);
if (value > max_index) {
return limit - 1U;
}
return static_cast<std::size_t>(value);
}
// scipy 'reflect' boundary (half-sample symmetric): the signal is mirrored about
// the outer edge of the first/last sample, so index -1 maps to 0, -2 to 1, n to
// n-1, and so on. Matches scipy.ndimage.gaussian_filter's default mode.
@@ -314,7 +282,7 @@ void normalize_in_place(std::vector<double>& values) {
return;
}
for (auto& value : values) {
value /= maximum;
value /= maximum + 1e-12;
}
}
@@ -545,67 +513,10 @@ void validate_collection_trace_order(
}
}
[[nodiscard]] auto build_background_mean(
std::span<const ipc::PreprocessedCollection> previous_collections,
const GeometrySelection& selection,
const ProcessingLiveConfig& live_config
) -> std::unordered_map<PairKey, std::vector<std::complex<double>>> {
std::unordered_map<PairKey, std::vector<std::complex<double>>> result{};
if (!live_config.gpr_background_subtract_enabled || live_config.gpr_background_mean_count == 0U) {
return result;
}
const std::size_t mean_count = static_cast<std::size_t>(live_config.gpr_background_mean_count);
const std::size_t start_index =
previous_collections.size() > mean_count ? previous_collections.size() - mean_count : 0U;
std::unordered_map<PairKey, BackgroundAccumulator> accumulators{};
for (std::size_t collection_index = start_index; collection_index < previous_collections.size(); ++collection_index) {
const auto& collection = previous_collections[collection_index];
for (const auto& trace : collection.traces) {
const auto output_it = selection.output_local_by_pos.find(trace.combo.output_pos);
const auto input_it = selection.input_local_by_pos.find(trace.combo.input_pos);
if (output_it == selection.output_local_by_pos.end() || input_it == selection.input_local_by_pos.end()) {
continue;
}
const auto key = make_pair_key(output_it->second, input_it->second);
auto& accumulator = accumulators[key];
if (accumulator.sum.empty()) {
accumulator.sum.assign(trace.s21.size(), std::complex<double>(0.0, 0.0));
}
if (accumulator.sum.size() != trace.s21.size()) {
continue;
}
for (std::size_t sample_index = 0U; sample_index < trace.s21.size(); ++sample_index) {
const auto& sample = trace.s21[sample_index];
accumulator.sum[sample_index] += std::complex<double>(sample.re, sample.im);
}
accumulator.count += 1U;
}
}
for (auto& [key, accumulator] : accumulators) {
if (accumulator.count == 0U) {
continue;
}
auto& mean_trace = result[key];
mean_trace = std::move(accumulator.sum);
const double inverse_count = 1.0 / static_cast<double>(accumulator.count);
for (auto& sample : mean_trace) {
sample *= inverse_count;
}
}
return result;
}
[[nodiscard]] auto collect_selected_traces(
const ipc::PreprocessedCollection& collection,
const GeometrySelection& selection,
const std::unordered_map<PairKey, std::vector<std::complex<double>>>& background_mean
const bp::Background& background_mean
) -> std::vector<SelectedTrace> {
std::vector<SelectedTrace> traces{};
traces.reserve(collection.traces.size());
@@ -637,15 +548,7 @@ void validate_collection_trace_order(
throw std::runtime_error("GPR requires unique selected combos; duplicate combo: " + combo_to_string(trace.combo));
}
const auto background_it = background_mean.find(key);
selected.s21.reserve(trace.s21.size());
for (std::size_t sample_index = 0U; sample_index < trace.s21.size(); ++sample_index) {
std::complex<double> sample(trace.s21[sample_index].re, trace.s21[sample_index].im);
if (background_it != background_mean.end() && background_it->second.size() == trace.s21.size()) {
sample -= background_it->second[sample_index];
}
selected.s21.push_back(sample);
}
selected.s21 = bp::subtract_background(trace, background_mean);
traces.push_back(std::move(selected));
}
@@ -653,112 +556,6 @@ void validate_collection_trace_order(
return traces;
}
// Frequency-domain speed correction applied to S21 *before* the IFFT. The measurement is interleaved Tx-by-frequency
// — Tx0(f0), Tx1(f0), Tx0(f1), ... — so each Tx occupies one interleave slot and
// its frequency k is sampled at global step (slot_count*k + slot). Each in-band
// frequency's phase is rotated back to the centre of the (full) interleaved sweep.
//
// Unlike the offline Python script, every physical input is taken from the live
// system: `speed_mps` is the socket-fed velocity, `look_angle_deg`/`direction_sign`
// are config, and `tx_sweep_time_s` is derived per-collection from the acquisition
// timestamps (capture_span / Tx count). A zero speed or non-positive sweep time
// makes this a no-op.
void apply_intra_sweep_motion_correction(
SelectedTrace& trace,
std::size_t slot,
std::size_t slot_count,
double start_hz,
double stop_hz,
double velocity_mps,
double speed_mps,
double look_angle_deg,
double direction_sign,
double tx_sweep_time_s,
MotionMode motion_mode
) {
const std::size_t n_full = trace.frequency_hz.size();
if (n_full < 2U || trace.s21.size() != n_full || slot_count == 0U
|| !(velocity_mps > 0.0) || !(tx_sweep_time_s > 0.0)) {
return;
}
const double low_hz = std::min(start_hz, stop_hz);
const double high_hz = std::max(start_hz, stop_hz);
// Absolute sample time of full-sweep frequency index k for this Tx slot.
const double dt_base = tx_sweep_time_s / static_cast<double>(n_full - 1U);
const auto sample_time_s = [&](std::size_t index) {
return ((static_cast<double>(slot_count) * static_cast<double>(index)) + static_cast<double>(slot)) * dt_base;
};
// t_center is the midpoint of the *full* interleaved sweep for this slot,
// i.e. between the first and last full-array frequencies (not the cut band).
const double t_center_s = 0.5 * (sample_time_s(0U) + sample_time_s(n_full - 1U));
const double theta_rad = (look_angle_deg * kPi) / 180.0;
const double motion_factor = direction_sign * speed_mps * std::cos(theta_rad);
std::vector<std::size_t> band_indices{};
std::vector<double> band_frequency_hz{};
std::vector<double> phi{};
band_indices.reserve(n_full);
band_frequency_hz.reserve(n_full);
phi.reserve(n_full);
bool any_nonzero = false;
for (std::size_t index = 0U; index < n_full; ++index) {
const double frequency = trace.frequency_hz[index];
if (frequency < low_hz || frequency > high_hz) {
continue;
}
const double dt_intra_s = sample_time_s(index) - t_center_s;
const double delta_range_m = motion_factor * dt_intra_s;
const double delta_path_m = 2.0 * delta_range_m;
const double dtau_s = delta_path_m / velocity_mps;
if (dtau_s != 0.0) {
any_nonzero = true;
}
band_indices.push_back(index);
band_frequency_hz.push_back(frequency);
phi.push_back(2.0 * kPi * frequency * dtau_s);
}
if (!any_nonzero || band_indices.empty()) {
return;
}
// int_focus removes the constant + linear-in-frequency component of phi(f).
// With f_rel = f - mean(f) the design columns [1, f_rel] are orthogonal, so
// the least-squares fit is intercept = mean(phi), slope = <f_rel, phi>/<f_rel, f_rel>.
if (motion_mode == MotionMode::IntFocus) {
const auto count = static_cast<double>(band_frequency_hz.size());
double mean_frequency = 0.0;
double mean_phi = 0.0;
for (std::size_t i = 0U; i < band_frequency_hz.size(); ++i) {
mean_frequency += band_frequency_hz[i];
mean_phi += phi[i];
}
mean_frequency /= count;
mean_phi /= count;
double cross = 0.0;
double f_rel_sq = 0.0;
for (std::size_t i = 0U; i < band_frequency_hz.size(); ++i) {
const double f_rel = band_frequency_hz[i] - mean_frequency;
cross += f_rel * phi[i];
f_rel_sq += f_rel * f_rel;
}
const double slope = (f_rel_sq > 0.0) ? (cross / f_rel_sq) : 0.0;
for (std::size_t i = 0U; i < phi.size(); ++i) {
const double f_rel = band_frequency_hz[i] - mean_frequency;
phi[i] -= mean_phi + (slope * f_rel);
}
}
for (std::size_t i = 0U; i < band_indices.size(); ++i) {
trace.s21[band_indices[i]] *= std::polar(1.0, -phi[i]);
}
}
[[nodiscard]] auto compute_ascan(
const SelectedTrace& trace,
double start_hz,
@@ -804,7 +601,7 @@ void apply_intra_sweep_motion_correction(
return result;
}
const auto start_bin = static_cast<std::int64_t>(std::llround(frequency_hz.front() / df_hz));
const auto start_bin = static_cast<std::int64_t>(std::nearbyint(frequency_hz.front() / df_hz));
if (start_bin < 0) {
return result;
}
@@ -842,50 +639,6 @@ void apply_intra_sweep_motion_correction(
return result;
}
void normalize_pair_ascans(
std::unordered_map<PairKey, AscanResult>& ascans_by_pair,
double velocity_mps,
double min_depth_m,
double max_depth_m
) {
if (!kPairNormalize) {
return;
}
for (auto& item : ascans_by_pair) {
auto& ascan = item.second;
if (ascan.samples.empty() || ascan.time_s.size() != ascan.samples.size()) {
continue;
}
std::vector<double> amplitudes{};
amplitudes.reserve(ascan.samples.size());
for (std::size_t index = 0U; index < ascan.samples.size(); ++index) {
const double depth_m = 0.5 * velocity_mps * ascan.time_s[index];
if (depth_m < min_depth_m || depth_m > max_depth_m) {
continue;
}
amplitudes.push_back(std::abs(ascan.samples[index]));
}
if (amplitudes.empty()) {
amplitudes.reserve(ascan.samples.size());
for (const auto& sample : ascan.samples) {
amplitudes.push_back(std::abs(sample));
}
}
double scale = percentile_copy(std::move(amplitudes), kPairNormPercentile);
if (!std::isfinite(scale) || !(scale > kPairNormEps)) {
scale = 1.0;
}
const double denominator = scale + kPairNormEps;
for (auto& sample : ascan.samples) {
sample /= denominator;
}
}
}
[[nodiscard]] auto distance_3d(double dx, double dy, double dz) -> double {
return std::sqrt((dx * dx) + (dy * dy) + (dz * dz));
}
@@ -894,7 +647,7 @@ void normalize_pair_ascans(
const GeometrySelection& selection,
double max_depth_m,
double min_z_m,
double imaging_plane_y_m
double ground_depth_m, double pitch_rad
) -> GridDefinition {
GridDefinition grid{};
if (selection.x_tx.empty() || selection.x_rx.empty() || !(max_depth_m > min_z_m)) {
@@ -922,14 +675,14 @@ void normalize_pair_ascans(
for (std::size_t tx_index = 0U; tx_index < selection.x_tx.size(); ++tx_index) {
grid.tx_distance_grids[tx_index][cell_index] = distance_3d(
x_value - selection.x_tx[tx_index],
imaging_plane_y_m - selection.y_tx[tx_index],
bp::ground_y(z_value, ground_depth_m, pitch_rad) - selection.y_tx[tx_index],
z_value - selection.z_tx[tx_index]
);
}
for (std::size_t rx_index = 0U; rx_index < selection.x_rx.size(); ++rx_index) {
grid.rx_distance_grids[rx_index][cell_index] = distance_3d(
x_value - selection.x_rx[rx_index],
imaging_plane_y_m - selection.y_rx[rx_index],
bp::ground_y(z_value, ground_depth_m, pitch_rad) - selection.y_rx[rx_index],
z_value - selection.z_rx[rx_index]
);
}
@@ -946,10 +699,10 @@ void normalize_pair_ascans(
const GeometrySelection& selection,
double max_depth_m,
double min_z_m,
double imaging_plane_y_m
double ground_depth_m, double pitch_rad
) -> std::vector<double> {
std::vector<double> signature;
signature.reserve((selection.x_tx.size() + selection.x_rx.size()) * 3U + 3U);
std::vector<double> signature{static_cast<double>(selection.x_tx.size()), static_cast<double>(selection.x_rx.size())};
signature.reserve((selection.x_tx.size() + selection.x_rx.size()) * 3U + 6U);
const auto append = [&](const std::vector<double>& axis) {
signature.insert(signature.end(), axis.begin(), axis.end());
};
@@ -961,7 +714,8 @@ void normalize_pair_ascans(
append(selection.z_rx);
signature.push_back(max_depth_m);
signature.push_back(min_z_m);
signature.push_back(imaging_plane_y_m);
signature.push_back(ground_depth_m);
signature.push_back(pitch_rad);
return signature;
}
@@ -983,67 +737,18 @@ void normalize_pair_ascans(
return (ascan.samples[index] * (1.0 - frac)) + (ascan.samples[index + 1U] * frac);
}
[[nodiscard]] auto attenuation_components(
double r_tx,
double r_rx,
double dz_tx,
double dz_rx
) -> std::pair<double, double> {
// Antennas boresight along +Z, so cos(theta) = (z_pixel - z_antenna) / R.
const double geo = 1.0 / ((r_tx * r_rx) + 1e-12);
const double angle =
std::pow(dz_tx / (r_tx + 1e-12), 2.0) *
std::pow(dz_rx / (r_rx + 1e-12), 2.0);
return {geo + 1e-30, angle + 1e-30};
}
[[nodiscard]] auto attenuation_components_at_ref_depth(
std::uint32_t tx_index,
std::uint32_t rx_index,
const GeometrySelection& selection,
double imaging_plane_y_m
) -> std::pair<double, double> {
const double x_center = 0.5 * (selection.x_tx[tx_index] + selection.x_rx[rx_index]);
const double dz_tx = kCompensationReferenceDepthM - selection.z_tx[tx_index];
const double dz_rx = kCompensationReferenceDepthM - selection.z_rx[rx_index];
const double r_tx = distance_3d(
x_center - selection.x_tx[tx_index],
imaging_plane_y_m - selection.y_tx[tx_index],
dz_tx
);
const double r_rx = distance_3d(
x_center - selection.x_rx[rx_index],
imaging_plane_y_m - selection.y_rx[rx_index],
dz_rx
);
return attenuation_components(r_tx, r_rx, dz_tx, dz_rx);
}
[[nodiscard]] auto compensation_weight(
double r_tx,
double r_rx,
double dz_tx,
double dz_rx,
double geo_ref,
double angle_ref,
double range_power,
double angle_power
// attenuation_geo_at_ref_depth, Horns_motion_4x4_ground.py (no angle compensation).
[[nodiscard]] auto attenuation_geo_at_ref_depth(
std::uint32_t tx, std::uint32_t rx, const GeometrySelection& selection,
double ground_depth_m, double pitch_rad
) -> double {
const auto [geo, angle] = attenuation_components(r_tx, r_rx, dz_tx, dz_rx);
const double geo_norm = geo / geo_ref;
const double angle_norm = angle / angle_ref;
const double range_weight = std::clamp(
1.0 / (std::pow(geo_norm, range_power) + 1e-12),
0.0,
kRangeWeightMax
);
const double angle_weight = std::clamp(
1.0 / (std::pow(angle_norm, angle_power) + 1e-12),
0.0,
kAngleWeightMax
);
return std::clamp(range_weight * angle_weight, 0.0, kTotalWeightMax);
const double x_center = 0.5 * (selection.x_tx[tx] + selection.x_rx[rx]);
const double y = bp::ground_y(kCompensationReferenceDepthM, ground_depth_m, pitch_rad);
const double r_tx = distance_3d(x_center - selection.x_tx[tx], y - selection.y_tx[tx],
kCompensationReferenceDepthM - selection.z_tx[tx]);
const double r_rx = distance_3d(x_center - selection.x_rx[rx], y - selection.y_rx[rx],
kCompensationReferenceDepthM - selection.z_rx[rx]);
return 1.0 / (r_tx * r_rx + 1e-12) + 1e-30;
}
[[nodiscard]] auto backproject_coherent(
@@ -1051,12 +756,11 @@ void normalize_pair_ascans(
const std::unordered_map<PairKey, AscanResult>& ascans_by_pair,
const GridDefinition& grid,
const GeometrySelection& selection,
double imaging_plane_y_m,
double ground_depth_m, double pitch_rad,
double velocity_mps,
double min_depth_m,
double max_depth_m,
double range_power,
double angle_power
double range_power
) -> BpMap {
const std::size_t width = grid.x_grid.size();
const std::size_t height = grid.z_grid.size();
@@ -1076,9 +780,6 @@ void normalize_pair_ascans(
const std::vector<double>* rx_distances;
const AscanResult* ascan;
double geo_ref;
double angle_ref;
double z_tx_ant;
double z_rx_ant;
};
std::vector<PairContribution> contributions;
@@ -1089,20 +790,17 @@ void normalize_pair_ascans(
if (ascan_it == ascans_by_pair.end() || ascan_it->second.time_s.empty()) {
continue;
}
const auto [geo_ref, angle_ref] = attenuation_components_at_ref_depth(
const double geo_ref = attenuation_geo_at_ref_depth(
trace.tx_local_index,
trace.rx_local_index,
selection,
imaging_plane_y_m
ground_depth_m, pitch_rad
);
contributions.push_back(PairContribution{
&grid.tx_distance_grids[trace.tx_local_index],
&grid.rx_distance_grids[trace.rx_local_index],
&ascan_it->second,
geo_ref,
angle_ref,
selection.z_tx[trace.tx_local_index],
selection.z_rx[trace.rx_local_index],
});
}
@@ -1133,16 +831,9 @@ void normalize_pair_ascans(
}
const auto sample = interpolate_complex(ascan, tau_s);
const double weight = compensation_weight(
r_tx,
r_rx,
z_m - contribution.z_tx_ant,
z_m - contribution.z_rx_ant,
contribution.geo_ref,
contribution.angle_ref,
range_power,
angle_power
);
const double geo = 1.0 / (r_tx * r_rx + 1e-12) + 1e-30;
const double weight = std::clamp(1.0 / (std::pow(geo / contribution.geo_ref, range_power) + 1e-12),
0.0, kRangeWeightMax);
coherent_sum += sample * weight;
incoherent_sum += std::abs(sample) * weight;
contribution_count += 1.0;
@@ -1151,8 +842,8 @@ void normalize_pair_ascans(
if (!(contribution_count > 0.0)) {
continue;
}
coherent_sum /= contribution_count;
incoherent_sum /= contribution_count;
coherent_sum /= contribution_count + 1e-12;
incoherent_sum /= contribution_count + 1e-12;
result.coherent[cell_index] = coherent_sum;
result.incoherent[cell_index] = incoherent_sum;
result.image[cell_index] = std::abs(coherent_sum);
@@ -1503,7 +1194,7 @@ void apply_depth_gate(
const ObjectRecord& object,
const std::vector<SelectedTrace>& selected_traces,
const GeometrySelection& selection,
double imaging_plane_y_m
double ground_depth_m, double pitch_rad
) -> std::vector<double> {
std::vector<double> signature{};
signature.reserve(selected_traces.size());
@@ -1512,12 +1203,12 @@ void apply_depth_gate(
const auto rx = trace.rx_local_index;
const double r_tx = distance_3d(
object.x_m - selection.x_tx[tx],
imaging_plane_y_m - selection.y_tx[tx],
bp::ground_y(object.z_m, ground_depth_m, pitch_rad) - selection.y_tx[tx],
object.z_m - selection.z_tx[tx]
);
const double r_rx = distance_3d(
object.x_m - selection.x_rx[rx],
imaging_plane_y_m - selection.y_rx[rx],
bp::ground_y(object.z_m, ground_depth_m, pitch_rad) - selection.y_rx[rx],
object.z_m - selection.z_rx[rx]
);
signature.push_back(0.5 * (r_tx + r_rx));
@@ -1529,12 +1220,12 @@ void mark_sidelobe_candidates(
std::vector<ObjectRecord>& objects,
const std::vector<SelectedTrace>& selected_traces,
const GeometrySelection& selection,
double imaging_plane_y_m
double ground_depth_m, double pitch_rad
) {
std::vector<std::vector<double>> signatures{};
signatures.reserve(objects.size());
for (const auto& object : objects) {
signatures.push_back(bistatic_depth_signature(object, selected_traces, selection, imaging_plane_y_m));
signatures.push_back(bistatic_depth_signature(object, selected_traces, selection, ground_depth_m, pitch_rad));
}
for (std::size_t object_index = 0U; object_index < objects.size(); ++object_index) {
@@ -1849,15 +1540,15 @@ void add_bp_score_metrics(
}
validate_collection_trace_order(run_config, collection);
const auto background_mean = build_background_mean(previous_collections, selection, live_config);
const auto background_mean = bp::background_mean(previous_collections, live_config.gpr_background_subtract_enabled,
live_config.gpr_background_mean_count);
auto selected_traces = collect_selected_traces(collection, selection, background_mean);
if (selected_traces.empty()) {
return results;
}
// Coherent BP fixes the medium to vacuum/air (eps_r = 1); the configurable
// relative_permittivity is a legacy-GPR-only knob.
const double velocity_mps = kSpeedOfLightMetersPerSec;
// Coherent BP propagates in air, as in Horns_motion_4x4_ground.py.
const double velocity_mps = bp::kWaveSpeed;
const double start_hz = static_cast<double>(live_config.gpr_start_freq_mhz) * 1'000'000.0;
const double stop_hz = static_cast<double>(live_config.gpr_stop_freq_mhz) * 1'000'000.0;
const double min_depth_m = static_cast<double>(live_config.gpr_min_depth_m);
@@ -1866,36 +1557,12 @@ void add_bp_score_metrics(
return results;
}
// Intra-sweep speed correction before the IFFT. The Tx interleave slot is the
// Tx local index (outputs are sorted), with slot_count = number of selected Tx.
// tx_sweep_time is derived per-collection from the acquisition timestamps:
// one Tx's interleaved sweep spans the whole frame, so capture_span / N_tx.
// Speed comes from the socket-fed live config; look-angle/direction from config.
const auto motion_mode = parse_motion_mode(live_config.gpr_motion_mode);
const std::size_t motion_slot_count = selection.output_positions.size();
const double capture_span_s =
collection.capture_end_ns > collection.capture_start_ns
? static_cast<double>(collection.capture_end_ns - collection.capture_start_ns) * 1e-9
: 0.0;
const double tx_sweep_time_s =
motion_slot_count > 0U ? capture_span_s / static_cast<double>(motion_slot_count) : 0.0;
const double motion_speed_mps = static_cast<double>(live_config.gpr_speed_m_s);
const double motion_look_angle_deg = static_cast<double>(live_config.gpr_look_angle_deg);
const double motion_direction_sign = static_cast<double>(live_config.gpr_direction_sign);
const auto timing = bp::collection_timing(collection);
const double motion_look_angle_deg = live_config.gpr_look_angle_deg;
for (auto& trace : selected_traces) {
apply_intra_sweep_motion_correction(
trace,
trace.tx_local_index,
motion_slot_count,
start_hz,
stop_hz,
velocity_mps,
motion_speed_mps,
motion_look_angle_deg,
motion_direction_sign,
tx_sweep_time_s,
motion_mode
);
bp::apply_motion_phase(trace.s21, collection.traces[trace.run_order].frequency_hz, timing.clocks[trace.run_order],
live_config.gpr_speed_m_s, motion_look_angle_deg * kPi / 180.0,
live_config.gpr_direction_sign, live_config.gpr_motion_mode == "int_focus", start_hz, stop_hz);
}
std::unordered_map<PairKey, AscanResult> ascans_by_pair{};
@@ -1912,9 +1579,30 @@ void add_bp_score_metrics(
if (ascans_by_pair.empty()) {
return results;
}
normalize_pair_ascans(ascans_by_pair, velocity_mps, min_depth_m, max_depth_m);
const double imaging_plane_y_m = static_cast<double>(live_config.gpr_imaging_plane_y_m);
std::vector<std::span<std::complex<double>>> pairs;
for (const auto& trace : selected_traces) {
const auto found = ascans_by_pair.find(make_pair_key(trace.tx_local_index, trace.rx_local_index));
if (found != ascans_by_pair.end()) { pairs.emplace_back(found->second.samples); }
}
const double dt = ascans_by_pair.begin()->second.dt_s;
double x_min = selection.x_tx.front(), x_max = x_min, max_abs_x = 0.0;
for (const auto* xs : {&selection.x_tx, &selection.x_rx}) {
for (const double x : *xs) {
x_min = std::min(x_min, x); x_max = std::max(x_max, x); max_abs_x = std::max(max_abs_x, std::abs(x));
}
}
if (live_config.pair_depth_equalize) {
for (const auto& [key, ascan] : ascans_by_pair) {
if (ascan.dt_s != dt) { throw std::runtime_error("PAIR_DEPTH_EQUALIZE requires a common frequency grid"); }
}
bp::equalize_pairs_by_depth(pairs, dt * velocity_mps / 2.0,
bp::equalize_depth_limit(max_depth_m, x_min - kXMarginM, x_max + kXMarginM, max_abs_x));
}
for (auto& [key, ascan] : ascans_by_pair) {
bp::normalize_pair(std::span(ascan.samples), ascan.dt_s * velocity_mps / 2.0, min_depth_m, max_depth_m);
}
const double ground_depth_m = live_config.gpr_bp_ground_depth_m;
const double pitch_rad = motion_look_angle_deg * kPi / 180.0;
// The imaging grid (90k cells x 6 distance fields) only depends on antenna
// geometry and the depth window, which are constant across a run, so it is
@@ -1923,9 +1611,9 @@ void add_bp_score_metrics(
// backprojection workers join before it returns), so a local static is safe.
static std::vector<double> cached_grid_signature;
static GridDefinition cached_grid;
const auto signature = grid_signature(selection, max_depth_m, kGridZMinM, imaging_plane_y_m);
const auto signature = grid_signature(selection, max_depth_m, kGridZMinM, ground_depth_m, pitch_rad);
if (cached_grid.x_grid.empty() || signature != cached_grid_signature) {
cached_grid = build_grid(selection, max_depth_m, kGridZMinM, imaging_plane_y_m);
cached_grid = build_grid(selection, max_depth_m, kGridZMinM, ground_depth_m, pitch_rad);
cached_grid_signature = signature;
}
const GridDefinition& grid = cached_grid;
@@ -1938,12 +1626,11 @@ void add_bp_score_metrics(
ascans_by_pair,
grid,
selection,
imaging_plane_y_m,
ground_depth_m, pitch_rad,
velocity_mps,
min_depth_m,
max_depth_m,
std::max(0.0, static_cast<double>(live_config.gpr_range_comp_power)),
0.0 // angle compensation is fixed off for coherent BP
std::max(0.0, static_cast<double>(live_config.gpr_range_comp_power))
);
if (bp.image.empty()) {
return results;
@@ -1956,7 +1643,7 @@ void add_bp_score_metrics(
auto objects = find_bp_objects(display_map, grid, static_cast<double>(live_config.gpr_object_min_frac));
add_local_prominence_metrics(objects, display_map, grid, min_depth_m, max_depth_m);
add_incoherent_support_metrics(objects, incoherent_display_map, bp.coherence_factor);
mark_sidelobe_candidates(objects, selected_traces, selection, imaging_plane_y_m);
mark_sidelobe_candidates(objects, selected_traces, selection, ground_depth_m, pitch_rad);
add_bp_score_metrics(objects, live_config);
if (live_config.gpr_remove_sidelobe_objects_enabled) {
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,5 @@
#include "gpr_processor.hpp"
#include "backprojection.hpp"
#include <algorithm>
#include <cmath>
@@ -22,7 +23,6 @@ namespace {
// Algorithm bodies stay private to this translation unit; the public processor remains a small dispatcher.
#include "gpr_backprojection_processor.ipp"
#include "gpr_legacy_processor.ipp"
} // namespace
@@ -39,17 +39,4 @@ auto GprProcessor::process_collection(
return process_backprojection_gpr(run_config, collection, previous_collections, live_config, approach_filter_);
}
auto LegacyGprProcessor::name() const -> std::string {
return "legacy_gpr";
}
auto LegacyGprProcessor::process_collection(
const config::RunConfig& run_config,
const ipc::PreprocessedCollection& collection,
std::span<const ipc::PreprocessedCollection> previous_collections,
const ProcessingLiveConfig& live_config
) -> ipc::ResultCollection {
return process_legacy_gpr(run_config, collection, previous_collections, live_config);
}
} // namespace radar::processing
@@ -226,6 +226,34 @@ extern "C" __global__ void peak_planes(
indices[y] = index;
}
// Contiguous half-maximum interval through the joint peak on each scene axis.
// Interpolate the two crossings; use one voxel as the resolution floor.
extern "C" __global__ void peak_widths(
const float* volume, const double* gx, const double* gy, const double* gz,
const int nx, const int ny, const int nz, const int px, const int py, const int pz,
float* widths)
{
const int a = (int)(blockIdx.x * blockDim.x + threadIdx.x);
if (a >= 3) return;
const int count[3] = {nx, ny, nz}, stride[3] = {1, nx*nz, nx}, peak[3] = {px, py, pz};
const double* axis = a == 0 ? gx : a == 1 ? gy : gz;
const int origin = (py*nz + pz)*nx + px, k = peak[a], step = stride[a];
const float half = .5f * volume[origin];
int low = k, high = k;
while (low > 0 && volume[origin + (low-k-1)*step] >= half) --low;
while (high+1 < count[a] && volume[origin + (high-k+1)*step] >= half) ++high;
double left = axis[low], right = axis[high];
if (low > 0) {
const float inside = volume[origin + (low-k)*step], outside = volume[origin + (low-k-1)*step];
left -= (axis[low]-axis[low-1]) * (inside-half)/(inside-outside);
}
if (high+1 < count[a]) {
const float inside = volume[origin + (high-k)*step], outside = volume[origin + (high-k+1)*step];
right += (axis[high+1]-axis[high]) * (inside-half)/(inside-outside);
}
widths[a] = (float)fmax(right-left, axis[1]-axis[0]);
}
// 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.
@@ -417,6 +445,7 @@ struct Gpu::Impl {
CUfunction smooth = module.function("smooth");
CUfunction max_project = module.function("max_project");
CUfunction peak_planes = module.function("peak_planes");
CUfunction peak_widths = module.function("peak_widths");
CUfunction project_oblique = module.function("project_oblique");
int smooth_radius = 0;
DeviceBuffer taps;
@@ -510,6 +539,13 @@ auto Gpu::Impl::project(const VolumeRequest& request) -> Projections {
const auto plane_levels = download<float>(levels, static_cast<std::size_t>(ny));
const auto plane_indices = download<unsigned int>(indices, static_cast<std::size_t>(ny));
choose_peak(result, request, plane_levels, plane_indices);
if (result.level > 0.0F) {
const auto widths = gpu.peak_stats.reserve(3U * sizeof(float));
launch(gpu.peak_widths, 3, volume, gx, gy, gz, nx, ny, nz,
static_cast<int>(result.peak[2]), static_cast<int>(result.peak[0]), static_cast<int>(result.peak[1]), widths);
const auto measured = download<float>(widths, 3U);
std::copy(measured.begin(), measured.end(), result.target_width.begin());
}
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);
@@ -10,7 +10,7 @@
// CUDA backend 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 and peak leave the backend.
// Only the projections, peak and its axial half-maximum widths leave the backend.
namespace radar::processing::sar3d {
class CudaUnavailable : public std::runtime_error {
@@ -70,6 +70,7 @@ struct Projections {
float level = 0.0F;
float raw_level = 0.0F;
std::array<std::size_t, 3> peak{}; // joint voxel maximum, [y, z, x]
std::array<float, 3> target_width{}; // contiguous FWHM through the peak, [X, Y, Z], metres
};
// CUDA context and runtime-compiled kernels. A missing device raises
@@ -1,4 +1,5 @@
#include "sar3d_processor.hpp"
#include "backprojection.hpp"
#include <algorithm>
#include <array>
@@ -23,8 +24,8 @@
#include "sar3d_gpu.hpp"
// Port of the 3D SAR part of the reference notebook Multi_antenna_3D_clean_v1.ipynb
// (cells 0 and 4). Comments name the notebook functions and constants each step
// Port of the 3D SAR part of the reference notebook Multi_antenna_demonstrate_depthnorm.ipynb
// (cells 1 and 14). Comments name the notebook functions and constants each step
// follows; the parameters keep the notebook names in the GUI.
namespace radar::processing {
namespace {
@@ -32,10 +33,8 @@ namespace {
using std::numbers::pi;
// Notebook constants that are not live parameters.
constexpr double kWaveSpeed = 3e8; // v, eps_r = 1
constexpr double kWaveSpeed = bp::kWaveSpeed; // v, eps_r = 1
constexpr std::size_t kOversample = 8U; // BP_OVERSAMPLE
constexpr double kPairNormPercentile = 50.0; // PAIR_NORM_PERCENTILE
constexpr double kPairNormEps = 1e-15; // PAIR_NORM_EPS
constexpr double kCompRefDepth = 5.0; // COMP_REF_DEPTH
constexpr float kCompRangeWeightMax = 5.0F; // COMP_RANGE_WEIGHT_MAX
constexpr double kEdgeFrameWeight = 0.8; // SAR_EDGE_FRAME_WEIGHT
@@ -45,9 +44,6 @@ constexpr double kNormEdgeMargin = 0.3; // SAR_NORM_EDGE_MARGIN_M
// (x_ant.min() - 2 ... x_ant.max() + 2), Y is the height above the ground
// (SAR_Y_RANGE_M), Z along the ground follows the depth gate.
constexpr double kSceneXMargin = 2.0;
constexpr double kSceneYMin = -0.5;
constexpr double kSceneYMax = 2.0;
static_assert(kSceneYMax - kSceneYMin > 2.5 * kNormEdgeMargin, "else sar_norm_reference_mask keeps every plane");
// The 3D view looks along the camera of the notebook's 3D scene
// (sar_scene_layout: Plotly eye (1.7, -2.2, 0.85) in its axes X, Z, Y).
constexpr std::array<double, 3> kCameraEyeXzy{1.7, -2.2, 0.85};
@@ -71,10 +67,17 @@ struct Settings {
bool use_compensation = true; // SAR_USE_COMPENSATION
bool normalize_each_frame = true; // SAR_NORMALIZE_EACH_FRAME
bool pair_depth_equalize = true; // PAIR_DEPTH_EQUALIZE
bool subtract_mean = true;
std::size_t mean_count = 10U;
double x_size_m = 0.0, z_size_m = 0.0; // 0: notebook's automatic extent
double y_min_m = -0.5, y_max_m = 2.0; // SAR_Y_RANGE_M
// Everything the cached A-scans depend on.
[[nodiscard]] auto ascan_key() const {
return std::make_tuple(inputs, outputs, f_start_hz, f_stop_hz, min_depth_m, max_depth_m, speed_m_s,
look_angle_rad, normalize_each_frame);
look_angle_rad, normalize_each_frame, pair_depth_equalize, subtract_mean, mean_count,
tx, rx, x_size_m);
}
};
using AscanKey = decltype(std::declval<const Settings&>().ascan_key());
@@ -127,6 +130,16 @@ void select_antennas(
settings.comp_range_power = live.sar3d_comp_range_power;
settings.use_compensation = live.sar3d_use_compensation;
settings.normalize_each_frame = live.sar3d_normalize_each_frame;
settings.pair_depth_equalize = live.pair_depth_equalize;
settings.subtract_mean = live.gpr_background_subtract_enabled;
settings.mean_count = live.gpr_background_mean_count;
settings.x_size_m = live.sar3d_x_size_m;
settings.z_size_m = live.sar3d_z_size_m;
settings.y_min_m = live.sar3d_y_min_m;
settings.y_max_m = live.sar3d_y_max_m;
if (settings.x_size_m < 0.0 || settings.z_size_m < 0.0 || !(settings.y_max_m > settings.y_min_m)) {
throw std::runtime_error("SAR scene sizes must be non-negative and SAR_Y_RANGE_M must increase");
}
if (!(settings.f_stop_hz > settings.f_start_hz)) {
throw std::runtime_error("F_STOP must be above F_START");
}
@@ -152,73 +165,6 @@ void select_antennas(
return *lower_it + (position - static_cast<double>(lower)) * (upper - *lower_it);
}
// When the samples of a trace were measured, relative to the acquisition centre
// of its collection (compute_pair_timestamps_for_snapshot and
// compute_frequency_sample_times). Traces sharing a capture window come from
// one sweep: at every frequency point its stimulus stages - the outputs, which
// are numbered in device stage order - take turns while all inputs receive.
struct TraceClock {
double start_s = 0.0; // sweep start
double event_s = 0.0; // sweep duration / (stages * points)
std::size_t stage = 0U;
std::size_t stages = 1U;
[[nodiscard]] auto sample_time_s(std::size_t point) const -> double {
return start_s + (static_cast<double>(stages) * point + stage) * event_s;
}
};
struct CollectionTiming {
std::uint64_t center_ns = 0U; // centre of the collection's capture window
std::vector<TraceClock> clocks; // per collection trace
};
// Timing from the per-trace capture windows (snapshot_acquisition_timing); every
// trace takes part, so the stages do not depend on the INPUT_IDX/OUTPUT_IDX choice.
[[nodiscard]] auto collection_timing(const ipc::PreprocessedCollection& collection) -> CollectionTiming {
if (collection.traces.empty()) {
throw std::runtime_error("collection has no traces");
}
std::map<std::pair<std::uint64_t, std::uint64_t>, std::vector<std::uint32_t>> sweep_outputs{};
std::uint64_t first = std::numeric_limits<std::uint64_t>::max();
std::uint64_t last = 0U;
for (const auto& trace : collection.traces) {
if (trace.capture_end_ns <= trace.capture_start_ns) {
throw std::runtime_error("collection has traces without a capture window");
}
if (trace.frequency_hz.empty()) {
throw std::runtime_error("collection has a trace without frequency points");
}
sweep_outputs[{trace.capture_start_ns, trace.capture_end_ns}].push_back(trace.combo.output_pos);
first = std::min(first, trace.capture_start_ns);
last = std::max(last, trace.capture_end_ns);
}
for (auto& [window, outputs] : sweep_outputs) {
std::sort(outputs.begin(), outputs.end());
outputs.erase(std::unique(outputs.begin(), outputs.end()), outputs.end());
}
CollectionTiming timing{};
const auto duration_ns = last - first;
timing.center_ns = first + duration_ns / 2U;
for (const auto& trace : collection.traces) {
const auto& stages = sweep_outputs.at({trace.capture_start_ns, trace.capture_end_ns});
const auto stage = std::find(stages.begin(), stages.end(), trace.combo.output_pos) - stages.begin();
TraceClock clock{};
clock.start_s = 1e-9 * (static_cast<double>(trace.capture_start_ns - first) -
0.5 * static_cast<double>(duration_ns));
clock.stage = stage;
clock.stages = stages.size();
clock.event_s = 1e-9 * static_cast<double>(trace.capture_end_ns - trace.capture_start_ns) /
static_cast<double>(clock.stages) / trace.frequency_hz.size();
timing.clocks.push_back(clock);
}
return timing;
}
// Band of one trace, ready for the IFFT (apply_intra_sweep_phase_correction and
// compute_ascan_bp up to the IFFT): the radar motion removed as a phase, then
// the Hann window over the band.
struct Band {
std::size_t first_bin = 0U; // k0, the IFFT bin of the first band point
std::size_t size = 0U; // IFFT length: 2(k0 + n - 1) rounded up to a power of two, x BP_OVERSAMPLE
@@ -226,7 +172,8 @@ struct Band {
std::vector<std::complex<float>> spectrum{};
};
[[nodiscard]] auto trace_band(const ipc::SweepTraceBlock& trace, const TraceClock& clock, const Settings& settings)
[[nodiscard]] auto trace_band(const ipc::SweepTraceBlock& trace, const bp::TraceClock& clock, const Settings& settings,
const bp::Background& background)
-> Band {
const auto& frequency = trace.frequency_hz;
if (trace.s21.size() != frequency.size()) {
@@ -269,47 +216,24 @@ struct Band {
// from the collection centre at the sample time becomes the phase
// exp(-j 4 pi f offset / v): the pair and the intra-sweep corrections in one
// (MOTION_PHASE_SIGN = -1, direction +1).
const double phase_per_hz_s = -4.0 * pi * settings.speed_m_s * std::cos(settings.look_angle_rad) / kWaveSpeed;
auto samples = bp::subtract_background(trace, background);
bp::apply_motion_phase(samples, frequency, clock, settings.speed_m_s, settings.look_angle_rad,
1.0, false, settings.f_start_hz, settings.f_stop_hz);
const auto last = static_cast<double>(points.size() - 1U);
for (std::size_t index = 0U; index < points.size(); ++index) {
const std::size_t point = points[index];
const double hann = 0.5 - 0.5 * std::cos(2.0 * pi * static_cast<double>(index) / last);
const std::complex<double> sample(trace.s21[point].re, trace.s21[point].im);
const auto sample = samples[point];
if (!std::isfinite(sample.real()) || !std::isfinite(sample.imag())) {
throw std::runtime_error("S21 contains a non-finite sample");
}
band.spectrum.emplace_back(
sample * std::polar(hann, phase_per_hz_s * frequency[point] * clock.sample_time_s(point))
sample * hann
);
}
return band;
}
// SAR_NORMALIZE_EACH_FRAME: divide a channel by the PAIR_NORM_PERCENTILE of |h|
// inside the depth gate (sar_pair_ascans).
void normalize_ascan(std::span<std::complex<float>> ascan, double dt_s, const Settings& settings) {
std::vector<double> magnitudes{};
for (std::size_t index = 0U; index < ascan.size(); ++index) {
const double depth = static_cast<double>(index) * dt_s * kWaveSpeed / 2.0;
if (depth >= settings.min_depth_m && depth <= settings.max_depth_m) {
magnitudes.push_back(std::abs(std::complex<double>(ascan[index])));
}
}
if (magnitudes.empty()) {
for (const auto& sample : ascan) {
magnitudes.push_back(std::abs(std::complex<double>(sample)));
}
}
double scale = percentile(std::move(magnitudes), kPairNormPercentile);
if (!std::isfinite(scale) || scale <= kPairNormEps) {
scale = 1.0;
}
const auto divisor = static_cast<float>(scale + kPairNormEps);
for (auto& sample : ascan) {
sample /= divisor;
}
}
// A-scans of every selected Tx/Rx pair of one collection, (Tx, Rx) order.
struct Frame {
std::uint64_t center_ns = 0U;
@@ -321,9 +245,11 @@ struct Frame {
[[nodiscard]] auto compute_frame(
const ipc::PreprocessedCollection& collection,
const Settings& settings,
std::span<const ipc::PreprocessedCollection> previous,
sar3d::Gpu& backend
) -> Frame {
const auto timing = collection_timing(collection);
const auto timing = bp::collection_timing(collection);
const auto background = bp::background_mean(previous, settings.subtract_mean, settings.mean_count);
std::vector<Band> bands{};
for (const auto output : settings.outputs) {
for (const auto input : settings.inputs) {
@@ -337,7 +263,7 @@ struct Frame {
);
}
const auto& clock = timing.clocks[static_cast<std::size_t>(trace - traces.begin())];
bands.push_back(trace_band(*trace, clock, settings));
bands.push_back(trace_band(*trace, clock, settings, background));
}
}
const Band& first = bands.front();
@@ -354,10 +280,27 @@ struct Frame {
frame.samples = first.size;
frame.dt_s = 1.0 / (static_cast<double>(first.size) * first.df_hz);
frame.ascans = backend.inverse_dft(spectra, first.spectrum.size(), first.first_bin, first.size);
if (settings.normalize_each_frame) {
for (std::size_t offset = 0U; offset < frame.ascans.size(); offset += frame.samples) {
normalize_ascan(std::span(frame.ascans).subspan(offset, frame.samples), frame.dt_s, settings);
std::vector<std::span<std::complex<float>>> pairs;
for (std::size_t offset = 0; offset < frame.ascans.size(); offset += frame.samples) {
pairs.push_back(std::span(frame.ascans).subspan(offset, frame.samples));
}
const double dz = frame.dt_s * kWaveSpeed / 2.0;
if (settings.pair_depth_equalize) {
double x_min = settings.tx.front()[0], x_max = x_min, max_abs_x = 0.0;
for (const auto* antennas : {&settings.tx, &settings.rx}) {
for (const auto& antenna : *antennas) {
x_min = std::min(x_min, antenna[0]);
x_max = std::max(x_max, antenna[0]);
max_abs_x = std::max(max_abs_x, std::abs(antenna[0]));
}
}
const double half_size = settings.x_size_m > 0.0 ? settings.x_size_m / 2.0 : (x_max - x_min) / 2.0 + kSceneXMargin;
const double center = (x_min + x_max) / 2.0;
bp::equalize_pairs_by_depth(pairs, dz,
bp::equalize_depth_limit(settings.max_depth_m, center - half_size, center + half_size, max_abs_x));
}
if (settings.normalize_each_frame) {
for (auto pair : pairs) { bp::normalize_pair(pair, dz, settings.min_depth_m, settings.max_depth_m); }
}
return frame;
}
@@ -406,7 +349,7 @@ struct Grid {
}
double z_min = std::numeric_limits<double>::infinity();
double z_max = -std::numeric_limits<double>::infinity();
for (const double height : {kSceneYMin, kSceneYMax}) {
for (const double height : {settings.y_min_m, settings.y_max_m}) {
const double depth_at_zero = -(height - settings.ground_depth_m) * sin_p;
for (const double depth : {settings.min_depth_m, settings.max_depth_m}) {
const double z = (depth - depth_at_zero) / cos_p;
@@ -414,18 +357,23 @@ struct Grid {
z_max = std::max(z_max, z);
}
}
const auto resize = [](double& low, double& high, double size) {
if (size > 0.0) { const double center = (low + high) / 2.0; low = center - size / 2.0; high = center + size / 2.0; }
};
resize(x_min, x_max, settings.x_size_m);
resize(z_min, z_max, settings.z_size_m);
// Match sar_build_full_region_grid and reject oversized products before
// allocating the axes. Every downstream kernel uses signed 32-bit indices.
const auto axis_size = [&](double span) {
return checked_axis_size(std::max(std::nearbyint(span / settings.voxel_m) + 1.0, 2.0));
};
const auto nx = axis_size(x_max - x_min);
const auto ny = axis_size(kSceneYMax - kSceneYMin);
const auto ny = axis_size(settings.y_max_m - settings.y_min_m);
const auto nz = axis_size(z_max - z_min);
if (nx > static_cast<std::size_t>(std::numeric_limits<int>::max()) / ny / nz) {
throw std::runtime_error("SAR grid exceeds 32-bit kernel indexing; increase SAR_VOXEL_M");
}
return {linspace(x_min, x_max, nx), linspace(kSceneYMin, kSceneYMax, ny),
return {linspace(x_min, x_max, nx), linspace(settings.y_min_m, settings.y_max_m, ny),
linspace(z_min, z_max, nz)};
}
@@ -584,6 +532,7 @@ struct Sar3dProcessor::State {
}
struct CachedFrame {
std::vector<SweepKey> background;
std::shared_ptr<const Frame> value;
std::string error;
};
@@ -594,21 +543,28 @@ struct Sar3dProcessor::State {
std::string last_error{};
std::uint64_t repeated_errors = 0U;
auto frame(const ipc::PreprocessedCollection& collection, const Settings& settings, sar3d::Gpu& backend)
auto frame(const ipc::PreprocessedCollection& collection, const Settings& settings,
std::span<const ipc::PreprocessedCollection> previous, sar3d::Gpu& backend)
-> std::shared_ptr<const Frame> {
if (const auto found = frames.find(sweep_key(collection)); found != frames.end()) {
std::vector<SweepKey> background;
if (settings.subtract_mean) {
for (const auto& member : previous.last(std::min(previous.size(), settings.mean_count))) {
background.push_back(sweep_key(member));
}
}
if (const auto found = frames.find(sweep_key(collection)); found != frames.end() && found->second.background == background) {
if (found->second.value == nullptr) { report_error(found->second.error); }
return found->second.value;
}
std::shared_ptr<const Frame> frame{};
std::string failure;
try {
frame = std::make_shared<const Frame>(compute_frame(collection, settings, backend));
frame = std::make_shared<const Frame>(compute_frame(collection, settings, previous, backend));
} catch (const std::exception& error) {
failure = error.what();
report_error(failure);
}
frames.emplace(sweep_key(collection), CachedFrame{frame, std::move(failure)});
frames.insert_or_assign(sweep_key(collection), CachedFrame{std::move(background), frame, std::move(failure)});
return frame;
}
@@ -631,22 +587,20 @@ struct Sar3dProcessor::State {
frames.clear();
frames_key = settings.ascan_key();
}
const auto recent = previous.last(std::min(previous.size(), 2U * settings.frame_radius));
const auto recent = previous.last(std::min(previous.size(), settings.frame_radius));
std::set<SweepKey> retained{sweep_key(collection)};
for (const auto& member : recent) { retained.insert(sweep_key(member)); }
// Prune even when the newest frame is invalid, so a bad input stream
// cannot grow the failed-frame cache forever.
std::erase_if(frames, [&](const auto& entry) { return !retained.contains(entry.first); });
const auto newest = frame(collection, settings, backend);
const auto newest = frame(collection, settings, previous, backend);
if (newest == nullptr) {
return {status_payload(2.0F, last_error)};
}
// Aperture: the newest 2r+1 collections, centred r collections before
// the newest one, so the image trails the input by r frames. Collections
// that left the aperture are dropped from the cache.
// SAR_CAUSAL_ONLY: current collection and r previous ones, current scene origin.
std::vector<std::shared_ptr<const Frame>> aperture{};
for (const auto& member : recent) {
auto member_frame = frame(member, settings, backend);
for (std::size_t i = previous.size() - recent.size(); i < previous.size(); ++i) {
auto member_frame = frame(previous[i], settings, previous.first(i), backend);
const bool same_axis = member_frame != nullptr && member_frame->samples == newest->samples &&
member_frame->dt_s == newest->dt_s;
if (same_axis) {
@@ -654,9 +608,7 @@ struct Sar3dProcessor::State {
}
}
aperture.push_back(newest);
const std::size_t centre_index =
aperture.size() > settings.frame_radius ? aperture.size() - 1U - settings.frame_radius : 0U;
const Frame& centre = *aperture[centre_index];
const Frame& centre = *newest;
std::vector<double> offsets_s{};
for (const auto& member : aperture) {
@@ -761,6 +713,8 @@ struct Sar3dProcessor::State {
basis.insert(basis.end(), oblique.view.v.begin(), oblique.view.v.end());
payloads.push_back(table_payload("sar3d_oblique_basis", 3U, std::move(basis)));
if (level > 0.0F) {
payloads.push_back(table_payload("sar3d_target_width", 3U,
{images.target_width[0], images.target_width[1], images.target_width[2]}));
payloads.push_back(table_payload("sar3d_peak", 4U, {static_cast<float>(normalization), static_cast<float>(grid.x[peak_x]),
static_cast<float>(grid.y[peak_y]),
static_cast<float>(grid.z[peak_z])}));
@@ -0,0 +1,113 @@
#include "sar3d_processor.hpp"
#include "gpr_processor.hpp"
#include <algorithm>
#include <complex>
#include <iostream>
#include <vector>
#include <nlohmann/json.hpp>
using Json = nlohmann::json;
using namespace radar;
namespace {
auto complex_samples(const Json& samples) -> std::vector<std::complex<float>> {
std::vector<std::complex<float>> values;
for (const auto& sample : samples) {
values.emplace_back(sample.at(0).get<float>(), sample.at(1).get<float>());
}
return values;
}
auto collection(const Json& source) -> ipc::PreprocessedCollection {
ipc::PreprocessedCollection result;
result.collection_id = source.at("id");
result.monotonic_ns = source.at("timestamp");
for (const auto& entry : source.at("traces")) {
ipc::SweepTraceBlock trace;
trace.combo = {entry.at("input"), entry.at("output")};
trace.capture_start_ns = entry.at("start");
trace.capture_end_ns = entry.at("end");
trace.frequency_hz = entry.at("frequency").get<std::vector<float>>();
for (const auto& sample : complex_samples(entry.at("s21"))) {
trace.s21.push_back({sample.real(), sample.imag()});
}
if (result.traces.empty() || trace.capture_start_ns < result.capture_start_ns) {
result.capture_start_ns = trace.capture_start_ns;
}
result.capture_end_ns = std::max(result.capture_end_ns, trace.capture_end_ns);
result.traces.push_back(std::move(trace));
}
return result;
}
}
// Runs GPR or sar3d on one request or a batch, retaining processor caches. Each request holds the antenna
// geometry, the live settings and the latest and previous collections; the
// published payloads go to stdout as JSON.
int main() {
try {
const Json source = Json::parse(std::cin);
const bool batch = source.is_array();
const Json requests = batch ? source : Json::array({source});
Json results = Json::array();
processing::Sar3dProcessor sar;
processing::GprProcessor gpr;
for (const auto& input : requests) {
config::RunConfig config;
config.rings.results.slot_size_bytes = input.value("slot", config.rings.results.slot_size_bytes);
for (const auto& row : input.at("tx")) {
config.gpr.tx_geometry.push_back({row.at(0), row.at(1), row.at(2), row.at(3)});
}
for (const auto& row : input.at("rx")) {
config.gpr.rx_geometry.push_back({row.at(0), row.at(1), row.at(2), row.at(3)});
}
processing::ProcessingLiveConfig live;
live.gpr_start_freq_mhz = input.at("f_start").get<double>() / 1e6;
live.gpr_stop_freq_mhz = input.at("f_stop").get<double>() / 1e6;
live.gpr_min_depth_m = input.at("min_depth");
live.gpr_max_depth_m = input.at("max_depth");
live.gpr_speed_m_s = input.at("speed");
live.gpr_look_angle_deg = input.at("pitch");
live.sar3d_voxel_m = input.at("voxel");
live.sar3d_bp_ground_depth_m = input.at("ground");
live.sar3d_frame_radius = input.at("radius");
live.sar3d_comp_range_power = input.at("power");
live.sar3d_use_compensation = input.at("compensation");
live.sar3d_normalize_each_frame = input.at("normalize");
live.pair_depth_equalize = input.value("equalize", true);
live.gpr_background_subtract_enabled = input.value("subtract_mean", false);
live.gpr_background_mean_count = input.value("mean_count", 10U);
live.sar3d_x_size_m = input.value("x_size", 0.0F);
live.sar3d_y_min_m = input.value("y_min", -0.5F);
live.sar3d_y_max_m = input.value("y_max", 2.0F);
live.sar3d_z_size_m = input.value("z_size", 0.0F);
live.gpr_bp_ground_depth_m = input.at("ground");
live.gpr_range_comp_power = input.at("power");
live.gpr_motion_mode = input.value("motion", "int_minus");
live.gpr_object_approach_min_frames = 1;
live.gpr_remove_sidelobe_objects_enabled = false;
std::vector<ipc::PreprocessedCollection> previous;
for (const auto& member : input.at("previous")) {
previous.push_back(collection(member));
}
const auto latest = collection(input.at("latest"));
for (const auto& trace : latest.traces) { config.run_combos.push_back(trace.combo); }
processing::ProcessorInterface& processor = input.value("mode", "sar3d") == "gpr"
? static_cast<processing::ProcessorInterface&>(gpr) : static_cast<processing::ProcessorInterface&>(sar);
const auto result = processor.process_collection(config, latest, previous, live);
Json payloads = Json::array();
for (const auto& payload : result.collection_payloads) {
payloads.push_back({{"name", payload.processing_name},
{"x", payload.image_x_axis}, {"y", payload.image_y_axis},
{"image", payload.image_values}, {"table", payload.table_values},
{"columns", payload.table_columns}});
}
results.push_back(Json{{"payloads", payloads}});
}
std::cout << (batch ? results : results.at(0)) << '\n';
} catch (const std::exception& error) {
std::cout << Json{{"error", error.what()}} << '\n';
return 1;
}
}
@@ -1,12 +1,12 @@
"""Generate small SAR3D references by executing notebook cells 0 and 4.
"""Generate SAR3D/GPR references from notebook cells 1/14 and Horns coherent BP.
Requires the original, untracked Multi_antenna_3D_clean_v1.ipynb; a missing notebook
Requires the original, untracked Multi_antenna_demonstrate_depthnorm.ipynb; a missing notebook
is an error. NumPy/SciPy execute its function/class ASTs unchanged. Only parameter
values are replaced; plotting imports and the interactive top-level runs are
excluded. No production SAR implementation or hand-transcribed oracle is used.
Run from the repository root:
.venv/bin/python data_acq_and_processing/tests/generate_sar3d_reference.py
.venv/bin/python data_acq_and_processing/tests/generate_backprojection_reference.py
"""
from __future__ import annotations
@@ -24,14 +24,14 @@ ROOT = Path(__file__).resolve().parents[2]
REFERENCE = Path(__file__).with_name("sar3d_reference.npz")
def notebook_functions(notebook: Path, parameters: dict) -> tuple[dict, dict]:
cells = json.loads(notebook.read_text(encoding="utf-8"))["cells"]
def reference_functions(source_path: Path, parameters: dict, cells: tuple[int, ...] = (1, 14)) -> tuple[dict, dict]:
contents = source_path.read_text(encoding="utf-8")
sources = [(str(i), "".join(json.loads(contents)["cells"][i]["source"])) for i in cells] if cells else [("script", contents)]
namespace = {"__name__": __name__}
hashes = {}
for index in (0, 4):
source = "".join(cells[index]["source"])
for index, source in sources:
hashes[str(index)] = hashlib.sha256(source.encode()).hexdigest()
tree = ast.parse(source, filename=f"{notebook.name}:cell{index}")
tree = ast.parse(source, filename=f"{source_path.name}:cell{index}")
definitions = []
for node in tree.body:
if isinstance(node, (ast.FunctionDef, ast.ClassDef, ast.ImportFrom)):
@@ -45,23 +45,24 @@ def notebook_functions(notebook: Path, parameters: dict) -> tuple[dict, dict]:
except (ValueError, TypeError):
pass # Dataset loading and the interactive run are supplied below.
namespace.update(parameters)
exec(compile(ast.Module(definitions, type_ignores=[]), f"{notebook.name}:cell{index}",
exec(compile(ast.Module(definitions, type_ignores=[]), f"{source_path.name}:cell{index}",
"exec", dont_inherit=True), namespace)
return namespace, hashes
def scene() -> dict:
def scene(f_start=1e9, f_stop=4e9, points=201) -> dict:
"""Deterministic two-echo input with motion, vertical baselines and four pairs."""
request = dict(
tx=[[0, -.4, 0, 0], [1, .45, .2, .05]],
rx=[[0, -.2, .1, 0], [1, .3, .35, .02]], f_start=1e9, f_stop=4e9,
rx=[[0, -.2, .1, 0], [1, .3, .35, .02]], f_start=f_start, f_stop=f_stop,
min_depth=1., max_depth=3., speed=.4, pitch=13., ground=.3,
voxel=.4, radius=1, power=1.5, compensation=True, normalize=True,
voxel=.4, radius=2, power=1.5, compensation=True, normalize=True,
equalize=True, subtract_mean=False, mean_count=1, motion="int_minus",
)
# Match the float32 settings/geometry accepted by the native fixture.
for key in ("tx", "rx"):
request[key] = np.asarray(request[key], dtype=np.float32).astype(float).tolist()
frequency = np.linspace(1e9, 4e9, 201, dtype=np.float32)
frequency = np.linspace(f_start, f_stop, points, dtype=np.float32)
members = []
for frame, offset in enumerate((0, 180_000_000, 510_000_000)):
traces = []
@@ -91,14 +92,17 @@ def write_dataset(directory: Path, request: dict) -> list[Path]:
]
(directory / "config_profile.json").write_text(json.dumps(profile), encoding="utf-8")
snapshots = []
for index, member in enumerate([*request["previous"], request["latest"]]):
members = [*request["previous"], request["latest"]]
for index, member in enumerate(members):
background = previous_mean({**request, "previous": members[:index]})
snapshot = directory / f'{index:04d}_id{member["id"]}_ns{member["timestamp"]}'
snapshot.mkdir()
metadata = {"traces": []}
for trace in member["traces"]:
stem = f'i{trace["input"]}_o{trace["output"]}'
values = np.asarray(trace["s21"], dtype=np.float32)
np.save(snapshot / f"{stem}_s21.npy", values[:, 0] + 1j * values[:, 1])
values = np.asarray(trace["s21"])
samples = values[:, 0] + 1j * values[:, 1] - background.get((trace["output"], trace["input"]), 0.)
np.save(snapshot / f"{stem}_s21.npy", samples)
np.save(snapshot / f"{stem}_freq.npy", np.asarray(trace["frequency"], dtype=np.float32))
metadata["traces"].append(dict(input=trace["input"], output=trace["output"],
capture_start_ns=trace["start"], capture_end_ns=trace["end"]))
@@ -109,7 +113,7 @@ def write_dataset(directory: Path, request: dict) -> list[Path]:
def reference(notebook: Path, request: dict, directory: Path) -> tuple[dict, dict]:
snapshots = write_dataset(directory, request)
center = snapshots[max(len(snapshots) - 1 - request["radius"], 0)]
center = snapshots[-1]
parameters = dict(
INPUT_IDX=[int(row[0]) for row in request["rx"]],
OUTPUT_IDX=[int(row[0]) for row in request["tx"]],
@@ -120,9 +124,10 @@ def reference(notebook: Path, request: dict, directory: Path) -> tuple[dict, dic
BP_GROUND_DEPTH_M=request["ground"], COMP_RANGE_POWER=request["power"],
SAR_VOXEL_M=(request["voxel"],) * 3, SAR_FRAME_RADIUS=request["radius"],
SAR_USE_COMPENSATION=request["compensation"], SAR_NORMALIZE_EACH_FRAME=request["normalize"],
PAIR_DEPTH_EQUALIZE=request["equalize"],
SAR_SCAN_ROOT=directory, DATA_PATH=center, BG_PATH=directory, v=3e8,
)
ns, hashes = notebook_functions(notebook, parameters)
ns, hashes = reference_functions(notebook, parameters)
tx, rx, order, tx_order, pair_count = ns["load_antenna_config_profile"](
directory / "config_profile.json", ns["INPUT_IDX"], ns["OUTPUT_IDX"])
ns["N_tx"], ns["N_rx"] = len(tx), len(rx)
@@ -130,7 +135,9 @@ def reference(notebook: Path, request: dict, directory: Path) -> tuple[dict, dic
for axis, values in zip("xyz", ns["positions_to_xyz"](positions).T):
ns[f"{axis}_{kind}"] = values
antenna_x = np.concatenate((ns["x_tx"], ns["x_rx"]))
ns["x_grid_bp"] = np.linspace(antenna_x.min() - 2, antenna_x.max() + 2, ns["NX_BP"])
ns["x_ant"] = antenna_x
ns["x_min"], ns["x_max"] = antenna_x.min() - 2, antenna_x.max() + 2
ns["x_grid_bp"] = np.linspace(ns["x_min"], ns["x_max"], ns["NX_BP"])
sweep_time, _, _ = ns["estimate_sweep_time_from_meta"](directory, n_pairs=pair_count)
config = ns["MotionConfig"](
speed_m_s=ns["SPEED_M_S"], look_angle_deg=ns["LOOK_ANGLE_DEG"],
@@ -143,23 +150,118 @@ def reference(notebook: Path, request: dict, directory: Path) -> tuple[dict, dic
motion_phase_sign=ns["MOTION_PHASE_SIGN"],
)
timestamps, _ = ns["compute_pair_timestamps_for_snapshot"](center, config)
result = ns["sar_compute_volume"](center, None, config, timestamps, verbose=False)
grid = ns["sar_build_full_region_grid"](config, y_range_m=(request.get("y_min", -.5), request.get("y_max", 2.)))
for axis in ("x", "z"):
size = request.get(f"{axis}_size", 0.)
if size:
center_axis = .5 * (grid[axis][0] + grid[axis][-1])
grid[axis] = np.linspace(center_axis - size/2, center_axis + size/2, max(round(size/request["voxel"])+1, 2))
grid["XX"], grid["ZZ"] = np.meshgrid(grid["x"], grid["z"])
ns["x_min"], ns["x_max"] = grid["x"][0], grid["x"][-1]
result = ns["sar_compute_volume"](center, None, config, timestamps, grid=grid, verbose=False)
volume, grid = result["volume"], result["grid"]
iy, iz, ix = ns["sar_global_max_index"](volume, grid)
from scipy.signal import peak_widths
widths = []
for axis, peak_index, profile in (("x", ix, volume[iy, iz, :]), ("y", iy, volume[:, iz, ix]), ("z", iz, volume[iy, :, ix])):
profile = np.asarray(profile, dtype=np.float64)
width, *_ = peak_widths(profile, [peak_index], prominence_data=(np.array([profile[peak_index]]),
np.array([0], dtype=np.intp), np.array([len(profile)-1], dtype=np.intp)))
widths.append(max(float(width[0]), 1.) * (grid[axis][1]-grid[axis][0]))
expected = {axis: grid[axis] for axis in "xyz"}
expected["target_width"] = np.asarray(widths)
expected.update(front=volume.max(axis=1), side=volume.max(axis=2), top=volume.max(axis=0),
peak=np.array([result["level"], grid["x"][ix], grid["y"][iy], grid["z"][iz]]))
return expected, hashes
def previous_mean(request: dict) -> dict:
samples = {}
if request["subtract_mean"] and request["mean_count"]:
for member in request["previous"][-request["mean_count"]:]:
for trace in member["traces"]:
values = np.asarray(trace["s21"])
samples.setdefault((trace["output"], trace["input"]), []).append(values[:, 0] + 1j * values[:, 1])
return {key: np.mean(values, axis=0) for key, values in samples.items()}
def gpr_reference(notebook: Path, script: Path, request: dict) -> tuple[np.ndarray, dict]:
"""Execute Horns coherent BP unchanged, adding the notebook's equalizer before its scalar norm."""
parameters = dict(F_START=request["f_start"], F_STOP=request["f_stop"], MIN_DEPTH=request["min_depth"],
MAX_DEPTH=request["max_depth"], LOOK_ANGLE_DEG=request["pitch"],
BP_GROUND_DEPTH_M=request["ground"], COMP_RANGE_POWER=request["power"], v=3e8)
ns, hashes = reference_functions(script, parameters, ())
ns["N_tx"], ns["N_rx"] = len(request["tx"]), len(request["rx"])
ns["OUTPUT_IDX"], ns["INPUT_IDX"] = [int(row[0]) for row in request["tx"]], [int(row[0]) for row in request["rx"]]
for kind in ("tx", "rx"):
for axis, values in zip("xyz", np.asarray(request[kind])[:, 1:].T):
ns[f"{axis}_{kind}"] = values
xs = np.concatenate((ns["x_tx"], ns["x_rx"]))
ns["x_ant"] = xs
ns["x_min"], ns["x_max"] = xs.min()-2, xs.max()+2
ns["x_grid_bp"] = np.linspace(ns["x_min"], ns["x_max"], ns["NX_BP"])
ns["z_grid_bp"] = np.linspace(.1, request["max_depth"], ns["NZ_BP"])
ns["XX_bp"], ns["ZZ_bp"] = np.meshgrid(ns["x_grid_bp"], ns["z_grid_bp"])
ns["depth_gate"] = (ns["ZZ_bp"] >= request["min_depth"]) & (ns["ZZ_bp"] <= request["max_depth"])
member = request["latest"]
duration = (max(t["end"] for t in member["traces"]) - min(t["start"] for t in member["traces"])) * 1e-9
config = ns["MotionConfig"](speed_m_s=request["speed"], look_angle_deg=request["pitch"],
tx_per_segment=len(request["tx"]), tx_order_full=tuple(ns["OUTPUT_IDX"]),
frame_acq_time_s=duration, n_freq_hint=len(member["traces"][0]["frequency"]),
intra_phase_focus_only=request["motion"] == "int_focus", motion_phase_sign=-1.,
pair_order_phys=[(t["output"], t["input"]) for t in member["traces"]])
clocks, _ = ns["compute_pair_timestamps"](config, ns["INPUT_IDX"], ns["OUTPUT_IDX"])
mean = previous_mean(request)
H, T = {}, {}
for trace in member["traces"]:
key = (trace["output"], trace["input"])
values = np.asarray(trace["s21"], dtype=np.float32)
samples = values[:, 0].astype(float) + 1j * values[:, 1].astype(float)
samples -= mean.get(key, 0.)
frequency = np.asarray(trace["frequency"], dtype=np.float32)
samples, _ = ns["apply_intra_sweep_phase_correction"](samples, frequency, clocks[key], config,
ns["v"], ns["F_START"], ns["F_STOP"])
t, _, h, _, _ = ns["compute_ascan_bp"](samples, frequency, ns["F_START"], ns["F_STOP"])
H[key], T[key] = h, t
if request["equalize"]:
eq, notebook_hashes = reference_functions(notebook, {**parameters, "PAIR_DEPTH_EQUALIZE": True})
hashes.update({f"notebook_cell_{cell}": value for cell, value in notebook_hashes.items()})
eq.update(x_ant=xs, x_min=ns["x_min"], x_max=ns["x_max"])
H = eq["equalize_pairs_by_depth"](H, T)
for key, h in H.items():
depth = T[key] * ns["v"] / 2
mask = (depth >= request["min_depth"]) & (depth <= request["max_depth"])
scale = np.percentile(np.abs(h)[mask] if mask.any() else np.abs(h), 50.)
H[key] = h / ((scale if np.isfinite(scale) and scale > 1e-15 else 1.) + 1e-15)
raw, _ = ns["backproject_coherent"](H, T)
return ns["normalize_bp_map"](raw).astype(np.float32), hashes
def generate_gpr(notebook: Path, script: Path, output: Path) -> None:
cases = [dict(equalize=False), dict(equalize=True),
dict(equalize=True, subtract_mean=True, mean_count=1, speed=-.2, pitch=-7., motion="int_focus", power=2.)]
arrays = {}
for i, settings in enumerate(cases):
request = scene(1.01e9, 4.01e9, 151) if i == 1 else scene()
request.update(settings, mode="gpr")
arrays[f"case_{i}_image"], hashes = gpr_reference(notebook, script, request)
arrays[f"case_{i}_request"] = np.array(json.dumps(request))
arrays["provenance"] = np.array(json.dumps(dict(script=script.name, source_sha256=hashes,
notebook=notebook.name, numpy=np.__version__, scipy=scipy.__version__)))
np.savez_compressed(output, **arrays)
print(f"Wrote {output}: {output.stat().st_size} bytes, {len(cases)} GPR references")
def generate(notebook: Path, output: Path) -> None:
# Read up front so a missing notebook cannot produce an incomplete artifact.
notebook.read_text(encoding="utf-8")
cases = [
dict(compensation=True, normalize=True),
dict(compensation=True, normalize=False, speed=-.2, pitch=-7., ground=.5),
dict(compensation=True, normalize=False, equalize=False, speed=-.2, pitch=-7., ground=.5),
dict(compensation=False, normalize=True, f_start=1.3e9, f_stop=3.7e9),
dict(compensation=False, normalize=False, speed=0., pitch=0., radius=0),
dict(compensation=True, normalize=True, subtract_mean=True, mean_count=1),
dict(compensation=True, normalize=True, x_size=3., z_size=2., y_min=-.2, y_max=1.4),
]
arrays, configurations = {}, []
for index, settings in enumerate(cases):
@@ -187,9 +289,9 @@ def generate(notebook: Path, output: Path) -> None:
arrays.update({f"case_{index}_{key}": value for key, value in expected.items()})
arrays["cases"] = np.array(json.dumps(configurations, separators=(",", ":")))
arrays["provenance"] = np.array(json.dumps(dict(
notebook=notebook.name, cell_source_sha256=hashes, cells=[0, 4], cases=len(cases),
notebook=notebook.name, cell_source_sha256=hashes, cells=[1, 14], cases=len(cases),
numpy=np.__version__, scipy=scipy.__version__,
method="Unchanged notebook AST functions; max projections of sar_compute_volume output",
method="Unchanged notebook AST functions; per-frame NumPy previous-mean subtraction; max projections of sar_compute_volume output",
), sort_keys=True))
output.parent.mkdir(parents=True, exist_ok=True)
np.savez_compressed(output, **arrays)
@@ -198,7 +300,10 @@ def generate(notebook: Path, output: Path) -> None:
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--notebook", type=Path, default=ROOT / "Multi_antenna_3D_clean_v1.ipynb")
parser.add_argument("--notebook", type=Path, default=ROOT / "Multi_antenna_demonstrate_depthnorm.ipynb")
parser.add_argument("--output", type=Path, default=REFERENCE)
parser.add_argument("--gpr-script", type=Path, default=ROOT / "Horns_motion_4x4_ground.py")
parser.add_argument("--gpr-output", type=Path, default=REFERENCE.with_name("gpr_reference.npz"))
args = parser.parse_args()
generate(args.notebook, args.output)
generate_gpr(args.notebook, args.gpr_script, args.gpr_output)
Binary file not shown.
@@ -209,12 +209,14 @@ void test_configuration(const std::string& seed_path, const Workspace& workspace
expect_error([&] { (void)config::load_run_config(config_path.string()); });
}
const auto live_path = workspace.path / "live.json";
std::ofstream(live_path) << Json{{"bscan_gain", 2}, {"settings_revision", 11}};
std::ofstream(live_path) << Json{{"bscan_gain", 2}, {"settings_revision", 11},
{"legacy_gpr_mode", false}, {"relative_permittivity", "obsolete"}};
processing::ProcessingLiveConfigLoader loader(live_path.string(), config);
require(loader.refresh_if_needed().bscan_gain == 2, "valid live config did not load");
const auto revision = loader.revision();
for (const auto& invalid : {Json{{"bscan_gain", 1e100}},
Json{{"gpr_input_positions", {0.0000001}}}, Json{{"bscan_gain", true}}}) {
Json{{"gpr_input_positions", {0.0000001}}}, Json{{"bscan_gain", true}},
Json{{"processor_mode", "legacy_gpr"}}}) {
std::ofstream(live_path) << invalid;
std::this_thread::sleep_for(std::chrono::milliseconds(110));
const auto current = loader.refresh_if_needed();
@@ -1,89 +0,0 @@
#include "sar3d_processor.hpp"
#include <algorithm>
#include <complex>
#include <iostream>
#include <vector>
#include <nlohmann/json.hpp>
using Json = nlohmann::json;
using namespace radar;
namespace {
auto complex_samples(const Json& samples) -> std::vector<std::complex<float>> {
std::vector<std::complex<float>> values;
for (const auto& sample : samples) {
values.emplace_back(sample.at(0).get<float>(), sample.at(1).get<float>());
}
return values;
}
auto collection(const Json& source) -> ipc::PreprocessedCollection {
ipc::PreprocessedCollection result;
result.collection_id = source.at("id");
result.monotonic_ns = source.at("timestamp");
for (const auto& entry : source.at("traces")) {
ipc::SweepTraceBlock trace;
trace.combo = {entry.at("input"), entry.at("output")};
trace.capture_start_ns = entry.at("start");
trace.capture_end_ns = entry.at("end");
trace.frequency_hz = entry.at("frequency").get<std::vector<float>>();
for (const auto& sample : complex_samples(entry.at("s21"))) {
trace.s21.push_back({sample.real(), sample.imag()});
}
if (result.traces.empty() || trace.capture_start_ns < result.capture_start_ns) {
result.capture_start_ns = trace.capture_start_ns;
}
result.capture_end_ns = std::max(result.capture_end_ns, trace.capture_end_ns);
result.traces.push_back(std::move(trace));
}
return result;
}
}
// Runs the sar3d processor once: the JSON request on stdin holds the antenna
// geometry, the live settings and the latest and previous collections; the
// published payloads go to stdout as JSON.
int main() {
try {
const Json input = Json::parse(std::cin);
config::RunConfig config;
config.rings.results.slot_size_bytes = input.value("slot", config.rings.results.slot_size_bytes);
for (const auto& row : input.at("tx")) {
config.gpr.tx_geometry.push_back({row.at(0), row.at(1), row.at(2), row.at(3)});
}
for (const auto& row : input.at("rx")) {
config.gpr.rx_geometry.push_back({row.at(0), row.at(1), row.at(2), row.at(3)});
}
processing::ProcessingLiveConfig live;
live.gpr_start_freq_mhz = input.at("f_start").get<float>() / 1e6F;
live.gpr_stop_freq_mhz = input.at("f_stop").get<float>() / 1e6F;
live.gpr_min_depth_m = input.at("min_depth");
live.gpr_max_depth_m = input.at("max_depth");
live.gpr_speed_m_s = input.at("speed");
live.gpr_look_angle_deg = input.at("pitch");
live.sar3d_voxel_m = input.at("voxel");
live.sar3d_bp_ground_depth_m = input.at("ground");
live.sar3d_frame_radius = input.at("radius");
live.sar3d_comp_range_power = input.at("power");
live.sar3d_use_compensation = input.at("compensation");
live.sar3d_normalize_each_frame = input.at("normalize");
std::vector<ipc::PreprocessedCollection> previous;
for (const auto& member : input.at("previous")) {
previous.push_back(collection(member));
}
processing::Sar3dProcessor processor;
const auto result = processor.process_collection(config, collection(input.at("latest")), previous, live);
Json payloads = Json::array();
for (const auto& payload : result.collection_payloads) {
payloads.push_back({{"name", payload.processing_name},
{"x", payload.image_x_axis}, {"y", payload.image_y_axis},
{"image", payload.image_values}, {"table", payload.table_values},
{"columns", payload.table_columns}});
}
std::cout << Json{{"payloads", payloads}} << '\n';
} catch (const std::exception& error) {
std::cout << Json{{"error", error.what()}} << '\n';
return 1;
}
}
Binary file not shown.
@@ -1,9 +1,9 @@
"""The sar3d processor on the real CUDA backend: notebook reference and contracts.
Every case runs build/native_tests/sar3d_fixture (SAR3D_FIXTURE overrides it).
Every case runs build/native_tests/backprojection_fixture (BACKPROJECTION_FIXTURE overrides it).
Without a usable GPU the processor publishes sar3d_status 3: then only that
warning is checked and the remaining tests are skipped. The notebook is needed
only to regenerate sar3d_reference.npz with generate_sar3d_reference.py.
only to regenerate sar3d_reference.npz with generate_backprojection_reference.py.
"""
from __future__ import annotations
@@ -17,15 +17,18 @@ import unittest
import numpy as np
ROOT = Path(__file__).resolve().parents[2]
FIXTURE = Path(os.environ.get("SAR3D_FIXTURE", ROOT / "build/native_tests/sar3d_fixture"))
FIXTURE = Path(os.environ.get("BACKPROJECTION_FIXTURE", ROOT / "build/native_tests/backprojection_fixture"))
REFERENCE = Path(__file__).with_name("sar3d_reference.npz")
VIEWS = ("sar3d_front", "sar3d_side", "sar3d_top", "sar3d_oblique")
def run(request: dict) -> dict[str, dict]:
def run(request: dict | list[dict]) -> dict[str, dict]:
output = subprocess.run([str(FIXTURE)], input=json.dumps(request), text=True,
capture_output=True, timeout=30, check=True)
return {item["name"]: item for item in json.loads(output.stdout)["payloads"]}
result = json.loads(output.stdout)
if isinstance(request, list):
result = result[-1]
return {item["name"]: item for item in result["payloads"]}
def status(payloads: dict[str, dict]) -> tuple[int, str]:
@@ -87,6 +90,8 @@ class Sar3dTest(unittest.TestCase):
rtol=2e-6, atol=1e-6)
np.testing.assert_allclose(np.asarray(payload["image"]).reshape(expected.shape),
expected, rtol=2e-3, atol=3e-4)
np.testing.assert_allclose(payloads["sar3d_target_width"]["table"],
self.reference[f"case_{index}_target_width"], rtol=2e-3, atol=2e-4)
peak = payloads["sar3d_peak"]["table"]
expected_peak = self.reference[f"case_{index}_peak"]
np.testing.assert_allclose(peak[1:], expected_peak[1:], rtol=0, atol=2e-6)
@@ -112,6 +117,15 @@ class Sar3dTest(unittest.TestCase):
self.assertEqual(code, 2, message)
self.assertTrue(message)
def test_cached_aperture_updates_when_previous_mean_context_changes(self) -> None:
request = self.scene(subtract_mean=True, mean_count=1)
shorter = copy.deepcopy(request)
shorter["previous"] = shorter["previous"][-1:]
cached, fresh = run([request, shorter]), run(shorter)
self.assertEqual(status(cached)[0], 0, status(cached))
for name in VIEWS:
np.testing.assert_allclose(cached[name]["image"], fresh[name]["image"], rtol=2e-3, atol=3e-4)
def test_empty_signal_is_reported_without_a_peak(self) -> None:
request = self.scene(previous=[], radius=0)
for trace in request["latest"]["traces"]:
@@ -135,5 +149,32 @@ class Sar3dTest(unittest.TestCase):
self.assertLess(values.max(), 1e-10)
class GprTest(unittest.TestCase):
def test_changing_tx_rx_counts_rebuilds_the_cached_grid(self) -> None:
with np.load(REFERENCE.with_name("gpr_reference.npz"), allow_pickle=False) as reference:
base = json.loads(reference["case_0_request"].item())
requests = []
for tx, rx in ((2, 1), (1, 2)):
request = copy.deepcopy(base)
request.update(tx=[[i, 0, 0, 0] for i in range(tx)], rx=[[i, 0, 0, 0] for i in range(rx)])
request["latest"]["traces"] = [trace for trace in request["latest"]["traces"]
if trace["output"] < tx and trace["input"] < rx]
requests.append(request)
np.testing.assert_allclose(run(requests)["gpr_accumulator"]["image"],
run(requests[-1])["gpr_accumulator"]["image"])
def test_coherent_ground_bp_matches_horns_and_notebook_equalizer(self) -> None:
with np.load(REFERENCE.with_name("gpr_reference.npz"), allow_pickle=False) as reference:
for i in range(3):
with self.subTest(case=i):
request = json.loads(reference[f"case_{i}_request"].item())
payload = run(request)["gpr_accumulator"]
expected = reference[f"case_{i}_image"]
np.testing.assert_allclose(np.asarray(payload["image"]).reshape(expected.shape), expected,
rtol=2e-4, atol=3e-5)
np.testing.assert_allclose(payload["y"], np.linspace(.1, request["max_depth"], expected.shape[0]),
rtol=1e-6, atol=1e-7)
if __name__ == "__main__":
unittest.main()
+20
View File
@@ -273,6 +273,26 @@ runtime-бандлы пересобираются из NPZ при Start. Ста
не меняет.
RSL2 отвергается; Python и C++ обновляются одновременно после остановки процессов.
### Живые настройки backprojection
GUI публикует настройки через `processing_live.json`; формат кадров колец от
изменения настроек не меняется. Для coherent GPR и SAR `pair_depth_equalize`
(boolean, по умолчанию true) включает `equalize_pairs_by_depth` перед медианной
нормировкой пары. `gpr_background_subtract_enabled` и `gpr_background_mean_count`
действуют в обоих режимах: среднее предыдущих коллекций вычитается до IFFT.
Плоскость coherent GPR — земля с высотой `gpr_bp_ground_depth_m` и наклоном
`gpr_look_angle_deg`. SAR использует `sar3d_frame_radius` предыдущих кадров и
текущий. Размеры сцены: `sar3d_x_size_m`, `sar3d_z_size_m` (0 — автоматически),
`sar3d_y_min_m`, `sar3d_y_max_m` — наземные координаты, независимо от depth gate.
SAR публикует `sar3d_target_width` как TableF32 с тремя колонками [X,Y,Z]:
ширины непрерывного отклика на половине максимума по осевым сечениям через
совместный максимум сглаженного объёма (метры, не меньше одного вокселя).
`sar3d_peak` по-прежнему содержит [уровень,X,Y,Z]. GUI использует ширины для
квадратного контура цели. Зум и `SAR_CLOUD_THRESHOLD` — настройки отображения
desktop/веба; в live-файл не входят, ревизию и расчёт не меняют. RSL3 не меняется:
новая таблица использует существующий тип payload.
## 7. Почему поле мощности читается, но не используется
В парсере стоит:
+14 -15
View File
@@ -10,7 +10,7 @@ import math
from pathlib import Path
from PyQt6.QtCore import QSignalBlocker
from PyQt6.QtWidgets import QCheckBox, QComboBox, QDoubleSpinBox, QFormLayout, QLabel, QSpinBox
from PyQt6.QtWidgets import QCheckBox, QComboBox, QDoubleSpinBox, QFormLayout, QLabel, QSlider, QSpinBox
from python_app.storage.npz.paths import radar_config_filename_prefix
from python_app.gui.antenna_geometry_editor import AntennaGeometryEditor
@@ -61,7 +61,6 @@ _ADC_FIELDS = (
("radar.laser_control.variation.delay_time", "_optical_delay_time_input"),
)
_GEOMETRY_FIELDS = {
"relative_permittivity": "_gpr_relative_permittivity",
"tx_geometry": "_gpr_tx_geometry_input",
"rx_geometry": "_gpr_rx_geometry_input",
}
@@ -97,15 +96,15 @@ def _set_web_widget(widget: SettingWidget, value: object) -> None:
if isinstance(widget, QCheckBox):
if not isinstance(value, bool):
raise ValueError("Checkbox value must be true or false")
elif isinstance(widget, (QSpinBox, QDoubleSpinBox)):
elif isinstance(widget, (QSpinBox, QDoubleSpinBox, QSlider)):
if isinstance(value, bool) or not isinstance(value, (int, float, str)):
raise ValueError("Numeric setting requires a number")
numeric = float(value)
if not math.isfinite(numeric) or not widget.minimum() <= numeric <= widget.maximum():
raise ValueError("Numeric setting is outside its allowed range")
if isinstance(widget, QSpinBox) and numeric != int(numeric):
if isinstance(widget, (QSpinBox, QSlider)) and numeric != int(numeric):
raise ValueError("Setting requires an integer")
value = int(numeric) if isinstance(widget, QSpinBox) else numeric
value = int(numeric) if isinstance(widget, (QSpinBox, QSlider)) else numeric
elif isinstance(widget, QComboBox):
if not isinstance(value, str) or widget.findText(value) < 0:
raise ValueError(f"Unknown setting option: {value}")
@@ -192,7 +191,7 @@ class ConfigController:
config.input_switch.positions <= 1 and config.output_switch.positions <= 1
or config.input_switch.driver_mode == config.output_switch.driver_mode == "mock"
)
if self._scalar("_processing_mode") not in {"gpr", "legacy_gpr", "sar3d"} and static:
if self._scalar("_processing_mode") not in {"gpr", "sar3d"} and static:
config.combos = [ComboModel(0, 0)]
for key in PREPROCESS_ASSET_NAMES:
asset = preprocess_asset_model(config, key)
@@ -224,7 +223,6 @@ class ConfigController:
def geometry(self) -> GprModel:
return GprModel(
relative_permittivity=float(self._scalar("_gpr_relative_permittivity")),
tx_geometry=[GprTxGeometryModel(**entry) for entry in self._geometry("tx_geometry").records()],
rx_geometry=[GprRxGeometryModel(**entry) for entry in self._geometry("rx_geometry").records()],
)
@@ -242,9 +240,10 @@ class ConfigController:
if binding.spec.live in {"gpr_input_positions", "gpr_output_positions"}:
value = parse_position_csv(str(value))
setattr(config, binding.spec.live, value)
config.relative_permittivity = float(self._scalar("_gpr_relative_permittivity"))
config.tx_geometry = self._geometry("tx_geometry").records()
config.rx_geometry = self._geometry("rx_geometry").records()
if config.sar3d_y_max_m <= config.sar3d_y_min_m:
raise ValueError("SAR_Y_RANGE_M max must be above min")
for axis in ("x", "z"):
lower_name, upper_name = f"gpr_visible_{axis}_min_m", f"gpr_visible_{axis}_max_m"
lower, upper = sorted((getattr(config, lower_name), getattr(config, upper_name)))
@@ -279,7 +278,6 @@ class ConfigController:
if binding.spec.widget:
write_setting(_scalar_widget(self.fields[binding.spec.widget]), binding.read(gui))
write_setting(_scalar_widget(self.fields["_log_level_combo"]), config.logging.level.capitalize())
write_setting(_scalar_widget(self.fields["_gpr_relative_permittivity"]), config.gpr.relative_permittivity)
for name, count, records in (
("tx_geometry", config.output_switch.positions, config.gpr.tx_geometry),
("rx_geometry", config.input_switch.positions, config.gpr.rx_geometry),
@@ -292,8 +290,8 @@ class ConfigController:
widgets = {binding.spec.web: (binding.spec.group, self.fields[binding.spec.widget])
for binding in active_web_fields(mode)}
for name, widget_id in _GEOMETRY_FIELDS.items():
if mode in ({"legacy_gpr"} if name == "relative_permittivity" else {"gpr", "legacy_gpr", "sar3d"}):
widgets[name] = ("Geometry & medium", self.fields[widget_id])
if mode in {"gpr", "sar3d"}:
widgets[name] = ("Geometry", self.fields[widget_id])
return widgets
def web_schema(self) -> list[dict[str, object]]:
@@ -315,9 +313,10 @@ class ConfigController:
entry.update(kind="select", options=[widget.itemText(i) for i in range(widget.count())])
elif isinstance(widget, QCheckBox):
entry.update(kind="bool")
elif isinstance(widget, (QSpinBox, QDoubleSpinBox)):
entry.update(kind="int" if isinstance(widget, QSpinBox) else "float",
min=widget.minimum(), max=widget.maximum(), step=widget.singleStep())
elif isinstance(widget, (QSpinBox, QDoubleSpinBox, QSlider)):
entry.update(kind="int" if isinstance(widget, (QSpinBox, QSlider)) else "float",
min=widget.minimum(), max=widget.maximum(), step=widget.singleStep(),
slider=isinstance(widget, QSlider))
if isinstance(widget, QDoubleSpinBox):
entry["decimals"] = widget.decimals()
elif isinstance(widget, AntennaGeometryEditor):
@@ -357,7 +356,7 @@ class ConfigController:
geometry_snapshots.append((widget, widget.records()))
else:
scalar_snapshots.append((widget, read_setting(widget)))
if isinstance(widget, (QSpinBox, QDoubleSpinBox)):
if isinstance(widget, (QSpinBox, QDoubleSpinBox, QSlider)):
ranges.append((widget, widget.minimum(), widget.maximum()))
try:
if "processor_mode" in fields:
@@ -17,8 +17,7 @@ from python_app.models.run_config_schema import RunConfigModel
from python_app.models.setting_fields import active_live_fields
from python_app.orchestration.live_processing_config import ProcessingLiveConfig, ProcessingLiveConfigWriter
_GPR_MODES = {"gpr", "legacy_gpr"}
_GEOMETRY_MODES = _GPR_MODES | {"sar3d"}
_GEOMETRY_MODES = {"gpr", "sar3d"}
class LiveSettingsController:
@@ -84,7 +83,7 @@ class LiveSettingsController:
def commit() -> None:
self.validate_mode(self._mode.currentText())
live_fields = {binding.spec.web for binding in active_live_fields(self._mode.currentText())}
if settings.keys() & (live_fields | {"tx_geometry", "rx_geometry", "relative_permittivity"}):
if settings.keys() & (live_fields | {"tx_geometry", "rx_geometry"}):
self.publish()
self.config.apply_web_fields(settings, commit)
@@ -171,12 +170,12 @@ class LiveSettingsController:
self._log.show_exception("Failed to update plot display settings", exc)
def locator_changed(self, *_args: object) -> None:
if self._mode.currentText() in _GPR_MODES and self._publish_changes():
if self._mode.currentText() == "gpr" and self._publish_changes():
self.visual_changed()
def set_mode_page(self, mode: str) -> None:
pages = self._processing.pages
pages.setCurrentIndex({"pass_through": 0, "bscan": 1, "gpr": 2, "legacy_gpr": 3, "sar3d": 4}.get(mode, 0))
pages.setCurrentIndex({"pass_through": 0, "bscan": 1, "gpr": 2, "sar3d": 3}.get(mode, 0))
self._processing.geometry_page.setVisible(mode in _GEOMETRY_MODES)
page = pages.currentWidget()
if page is not None:
@@ -55,7 +55,7 @@ class PlotController:
return self.sar3d.grid if current is self.sar3d.widget else current
def set_mode(self, mode: str) -> None:
renderer = {"bscan": self.bscan, "gpr": self.gpr, "legacy_gpr": self.gpr, "sar3d": self.sar3d}.get(mode, self.trace)
renderer = {"bscan": self.bscan, "gpr": self.gpr, "sar3d": self.sar3d}.get(mode, self.trace)
self.stack.setCurrentWidget(renderer.widget)
self.sync_display_combos()
@@ -114,15 +114,15 @@ class PlotController:
def draw(self, collection: ResultCollection) -> bool:
settings = self._settings()
mode = settings.selected_mode
if mode in {"gpr", "legacy_gpr", "sar3d"} and self.required_revision is not None:
if mode in {"gpr", "sar3d"} and self.required_revision is not None:
if collection.settings_revision != self.required_revision:
return False
if mode == "bscan":
return self.draw_bscan()
if mode in {"gpr", "legacy_gpr"}:
return self.gpr.draw(collection, settings.legacy_gpr if mode == "legacy_gpr" else settings.gpr, self._geometry())
if mode == "gpr":
return self.gpr.draw(collection, settings.gpr, self._geometry())
if mode == "sar3d":
return self.sar3d.draw(collection)
return self.sar3d.draw(collection, settings.sar3d, self._geometry())
return self.trace.draw(collection, settings.pass_through)
def redraw(self, *_args: object) -> None:
@@ -144,14 +144,13 @@ class PlotController:
self.sync_display_combos()
def clear_gpr(self) -> None:
settings = self._settings()
self.gpr.clear(settings.legacy_gpr if settings.selected_mode == "legacy_gpr" else settings.gpr)
self.gpr.clear()
def clear_current(self) -> None:
mode = self._settings().selected_mode
if mode == "bscan":
self.clear_bscan()
elif mode in {"gpr", "legacy_gpr"}:
elif mode == "gpr":
self.clear_gpr()
elif mode == "sar3d":
self.sar3d.clear()
@@ -3,6 +3,8 @@
import numpy as np
import pyqtgraph as pg
from python_app.models.run_config_schema import GprModel
from python_app.models.run_config_validation import parse_position_csv
from python_app.gui.theme import PLOT_BACKGROUND
@@ -22,3 +24,16 @@ def build_lut(stops: list[str], *, size: int = 256) -> np.ndarray:
for channel in range(3):
lut[:, channel] = np.interp(sample_positions, stop_positions, stop_colors[:, channel]).astype(np.uint8)
return lut
# Same Tx/Rx markers on ground BP and the three planar SAR views.
ANTENNA_STYLES = (("t", "#ff595e", "#ffca3a"), ("t1", "#4cc9f0", "#e0fbfc"))
def selected_antenna_coordinates(geometry: GprModel, inputs: str, outputs: str) -> tuple[np.ndarray, np.ndarray]:
selected_inputs, selected_outputs = parse_position_csv(inputs), parse_position_csv(outputs)
tx = [[row.x_m, row.y_m, row.z_m] for row in geometry.tx_geometry
if not selected_outputs or row.output_pos in selected_outputs]
rx = [[row.x_m, row.y_m, row.z_m] for row in geometry.rx_geometry
if not selected_inputs or row.input_pos in selected_inputs]
return np.asarray(tx, dtype=float).reshape(-1, 3), np.asarray(rx, dtype=float).reshape(-1, 3)
+28 -89
View File
@@ -6,18 +6,14 @@ from PyQt6.QtCore import QRectF
import numpy as np
import pyqtgraph as pg
from python_app.gui.controllers.renderers.common import build_lut, create_plot
from python_app.gui.controllers.renderers.common import ANTENNA_STYLES, build_lut, create_plot, selected_antenna_coordinates
from python_app.gui.result_payloads import (
collection_payload_by_name, collection_payloads_by_prefix,
filter_object_rows, gpr_object_rows,
collection_payload_by_name, gpr_object_rows,
)
from python_app.models.run_config_validation import parse_position_csv
from python_app.models.dataset_model import ResultCollection, ResultPayload
from python_app.models.gui_profile_schema import GuiGprStateModel, GuiLegacyGprStateModel
from python_app.models.gui_profile_schema import GuiGprStateModel
from python_app.models.run_config_schema import GprModel
GprSettings = GuiGprStateModel | GuiLegacyGprStateModel
def normalized_display_range(start: float, stop: float, *, minimum_span: float = 0.1) -> tuple[float, float]:
"""Order display bounds and expand an empty interval around its center."""
@@ -54,8 +50,8 @@ def object_label_candidates(
]
def _title(settings: GprSettings, *, objects_only: bool = False) -> str:
title = f"Legacy GPR {settings.mode}" if isinstance(settings, GuiLegacyGprStateModel) else "GPR coherent BP"
def _title(*, objects_only: bool = False) -> str:
title = "GPR coherent BP — ground plane"
return f"{title} Objects Only" if objects_only else title
@@ -63,7 +59,7 @@ class GprRenderer:
"""Draw one collection from explicit display settings and antenna geometry."""
def __init__(self) -> None:
self.widget = create_plot("GPR coherent BP")
self.widget = create_plot("GPR coherent BP — ground plane")
plot = self.widget.getPlotItem()
assert plot is not None
self._plot = plot
@@ -75,17 +71,14 @@ class GprRenderer:
self._tx = pg.ScatterPlotItem()
self._rx = pg.ScatterPlotItem()
self._points = pg.ScatterPlotItem()
self._region_centers = pg.ScatterPlotItem()
self._markers = (self._tx, self._rx, self._points, self._region_centers)
for item, z_value in zip((self._image, *self._markers), (0, 20, 20, 30, 30)):
self._markers = (self._tx, self._rx, self._points)
for item, z_value in zip((self._image, *self._markers), (0, 20, 20, 30)):
item.setZValue(z_value)
item.hide()
self._plot.addItem(item)
self._labels: list[pg.TextItem] = []
self._masks: list[pg.ImageItem] = []
self._contours: list[pg.IsocurveItem] = []
self._geometry_signature: tuple[object, ...] | None = None
self._selected_geometry = (np.zeros(0, dtype=np.float32), np.zeros(0, dtype=np.float32))
self._selected_geometry = (np.empty((0, 3)), np.empty((0, 3)))
def _configure_axes(self) -> None:
plot = self._plot
@@ -103,13 +96,11 @@ class GprRenderer:
view.setMenuEnabled(False)
def _clear_annotations(self) -> None:
for item in (*self._labels, *self._masks):
for item in self._labels:
self._plot.removeItem(item)
self._labels.clear()
self._masks.clear()
self._contours.clear()
def clear(self, settings: GprSettings) -> None:
def clear(self) -> None:
"""Clear displayed data while retaining reusable graphics items."""
self._configure_axes()
self._clear_annotations()
@@ -117,41 +108,25 @@ class GprRenderer:
for marker in self._markers:
marker.setData(x=[], y=[])
marker.hide()
self._plot.setTitle(_title(settings))
self._plot.setTitle(_title())
def _geometry(self, settings: GprSettings, geometry: GprModel) -> tuple[np.ndarray, np.ndarray]:
inputs = tuple(parse_position_csv(settings.input_positions))
outputs = tuple(parse_position_csv(settings.output_positions))
def _geometry(self, settings: GuiGprStateModel, geometry: GprModel) -> tuple[np.ndarray, np.ndarray]:
signature = (
tuple((entry.output_pos, entry.x_m, entry.y_m, entry.z_m) for entry in geometry.tx_geometry),
tuple((entry.input_pos, entry.x_m, entry.y_m, entry.z_m) for entry in geometry.rx_geometry),
inputs, outputs,
settings.input_positions, settings.output_positions,
)
if signature != self._geometry_signature:
self._selected_geometry = (
np.asarray([
entry.x_m for entry in sorted(geometry.tx_geometry, key=lambda entry: entry.output_pos)
if not outputs or entry.output_pos in outputs
], dtype=np.float32),
np.asarray([
entry.x_m for entry in sorted(geometry.rx_geometry, key=lambda entry: entry.input_pos)
if not inputs or entry.input_pos in inputs
], dtype=np.float32),
)
self._selected_geometry = selected_antenna_coordinates(geometry, settings.input_positions, settings.output_positions)
self._geometry_signature = signature
return self._selected_geometry
def _draw_geometry(self, settings: GprSettings, geometry: GprModel) -> None:
def _draw_geometry(self, settings: GuiGprStateModel, geometry: GprModel) -> None:
tx, rx = self._geometry(settings, geometry)
for marker, xs, symbol, brush, pen in (
(self._tx, tx, "t", "#ff595e", "#ffca3a"),
(self._rx, rx, "t1", "#4cc9f0", "#e0fbfc"),
):
marker.setData(
x=xs, y=np.zeros_like(xs), symbol=symbol, size=13,
brush=pg.mkBrush(brush), pen=pg.mkPen(pen, width=1.0),
)
marker.setVisible(xs.size > 0)
for marker, coordinates, (symbol, brush, pen) in zip((self._tx, self._rx), (tx, rx), ANTENNA_STYLES):
marker.setData(x=coordinates[:, 0], y=coordinates[:, 2], symbol=symbol, size=13,
brush=pg.mkBrush(brush), pen=pg.mkPen(pen, width=1.0))
marker.setVisible(len(coordinates) > 0)
def _label(self, text: str, x: float, y: float, color: str) -> pg.TextItem:
label = pg.TextItem(text=text, color=color, anchor=(0.0, 1.0))
@@ -161,34 +136,6 @@ class GprRenderer:
self._labels.append(label)
return label
def _draw_regions(self, collection: ResultCollection, rect: QRectF) -> None:
centers_payload = collection_payload_by_name(collection, "gpr_region_centers", kind=4)
if centers_payload is not None and np.asarray(centers_payload.table).size > 0:
centers = np.asarray(centers_payload.table, dtype=np.float32)
self._region_centers.setData(
x=centers[:, 0], y=centers[:, 1], symbol="o", size=10,
brush=pg.mkBrush("#80ed99"), pen=pg.mkPen("#081c15", width=1.1),
)
self._region_centers.show()
for row in centers:
self._label(f"{float(row[2]):.0f}", float(row[0]), float(row[1]), "#d8f3dc")
for payload in collection_payloads_by_prefix(collection, "gpr_region_mask_", kind=3):
mask = np.asarray(payload.image, dtype=np.float32)
if mask.ndim != 2 or mask.size == 0:
continue
mask_image = pg.ImageItem(axisOrder="row-major")
mask_image.setZValue(5)
mask_image.setImage(mask, autoLevels=False)
mask_image.setRect(rect)
mask_image.setOpacity(0.0)
self._plot.addItem(mask_image)
contour = pg.IsocurveItem(data=mask)
contour.setLevel(0.5)
contour.setPen("#4cc9f0", width=1.3)
contour.setParentItem(mask_image)
self._masks.append(mask_image)
self._contours.append(contour)
@staticmethod
def _place_object_label(
label: pg.TextItem, x: float, z: float, x_span: float, z_span: float, occupied: list[QRectF],
@@ -203,7 +150,7 @@ class GprRenderer:
occupied.append(label.sceneBoundingRect().adjusted(-4.0, -4.0, 4.0, 4.0))
def _draw_points(
self, rows: np.ndarray, settings: GprSettings, objects_only: bool, x_span: float, z_span: float,
self, rows: np.ndarray, objects_only: bool, x_span: float, z_span: float,
) -> None:
if rows.size == 0:
return
@@ -217,39 +164,33 @@ class GprRenderer:
occupied: list[QRectF] = []
for x_value, z_value, score in rows:
x, z = float(x_value), float(z_value)
text = f"{float(score):.0f}" if isinstance(settings, GuiLegacyGprStateModel) else f"{float(score):.2f}"
text = f"{float(score):.2f}"
if objects_only:
text += f" | x={x:.1f} | z={z:.1f}"
label = self._label(text, x, z, "#ffd6d9" if objects_only else "#ffffff")
if objects_only:
self._place_object_label(label, x, z, x_span, z_span, occupied)
def draw(self, collection: ResultCollection, settings: GprSettings, geometry: GprModel) -> bool:
def draw(self, collection: ResultCollection, settings: GuiGprStateModel, geometry: GprModel) -> bool:
"""Render a heatmap or object-only view, returning whether data is present."""
objects_only = settings.render_mode == "objects_only"
accumulator = collection_payload_by_name(collection, "gpr_accumulator", kind=3)
x_min, x_max = normalized_display_range(settings.visible_x_min_m, settings.visible_x_max_m)
z_min, z_max = normalized_display_range(settings.visible_z_min_m, settings.visible_z_max_m)
rows = gpr_object_rows(collection)
# Coherent BP rows are finalized in C++; only legacy GPR uses GUI filtering.
if isinstance(settings, GuiLegacyGprStateModel):
rows = filter_object_rows(
rows, min_score=float(settings.min_visible_pair_count),
x_bounds=(x_min, x_max), z_bounds=(z_min, z_max),
)
if objects_only:
if accumulator is None and rows.size == 0:
self.clear(settings)
self.clear()
return False
elif accumulator is None or not self._valid_accumulator(accumulator):
self.clear(settings)
self.clear()
return False
self.widget.setUpdatesEnabled(False)
try:
self._clear_annotations()
self._image.hide()
for marker in (self._points, self._region_centers):
for marker in (self._points,):
marker.setData(x=[], y=[])
marker.hide()
self._view.invertY(False)
@@ -274,13 +215,11 @@ class GprRenderer:
points = collection_payload_by_name(collection, "gpr_points", kind=4)
if points is None or np.asarray(points.table).size == 0:
rows = np.zeros((0, 3), dtype=np.float32)
self._draw_points(rows, settings, objects_only, x_max - x_min, z_max - z_min)
if rect is not None:
self._draw_regions(collection, rect)
self._draw_points(rows, objects_only, x_max - x_min, z_max - z_min)
if objects_only:
self._view.setXRange(x_min, x_max, padding=0.0)
self._view.setYRange(display_y_min(z_min, z_max), z_max, padding=0.0)
self._plot.setTitle(_title(settings, objects_only=objects_only))
self._plot.setTitle(_title(objects_only=objects_only))
finally:
self.widget.setUpdatesEnabled(True)
return True
+52 -18
View File
@@ -9,6 +9,7 @@ away from the Y boundaries:
- ``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_target_width``: axial FWHM [X, Y, Z] in metres through the joint peak.
- ``sar3d_peak``: [level, X, Y, Z] of that maximum before normalization.
- ``sar3d_status``: [code, UTF-8 bytes...] with ready CUDA (0), no signal (1),
processing error (2), or CUDA unavailable (3), including the explanation.
@@ -23,13 +24,11 @@ import pyqtgraph as pg
from PyQt6.QtCore import QRectF, Qt
from PyQt6.QtWidgets import QGridLayout, QLabel, QVBoxLayout, QWidget
from python_app.gui.controllers.renderers.common import build_lut, create_plot
from python_app.gui.controllers.renderers.common import ANTENNA_STYLES, build_lut, create_plot, selected_antenna_coordinates
from python_app.gui.result_payloads import collection_payload_by_name
from python_app.models.dataset_model import ResultCollection, ResultPayload
# Colours start at this fraction of the normalization reference; weaker voxels stay transparent,
# like those under the notebook's point-cloud threshold (SAR_CLOUD_THRESHOLD).
DISPLAY_THRESHOLD = 0.70
from python_app.models.gui_profile_schema import GuiSar3dStateModel
from python_app.models.run_config_schema import GprModel
# Status codes published by the sar3d processor in its "sar3d_status" table.
SAR3D_STATES = {0: "ready", 1: "no signal", 2: "error", 3: "warning"}
@@ -93,11 +92,19 @@ class Sar3dRenderer:
self.plots[name] = plot
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
lut[0, 3] = 0 # below SAR_CLOUD_THRESHOLD
self.antennas: dict[str, tuple[pg.ScatterPlotItem, pg.ScatterPlotItem]] = {}
self.images: dict[str, pg.ImageItem] = {}
self.peaks: dict[str, pg.ScatterPlotItem] = {}
self.targets: dict[str, pg.PlotCurveItem] = {}
for name, (_title, x_label, y_label, _axes) in SAR3D_VIEWS.items():
plot = self.plots[name]
plot.setAspectLocked(True)
if _axes is not None:
markers = (pg.ScatterPlotItem(), pg.ScatterPlotItem())
for marker in markers:
marker.setZValue(4)
plot.addItem(marker)
self.antennas[name] = markers
item = plot.getPlotItem()
assert item is not None
view = item.getViewBox()
@@ -108,11 +115,11 @@ class Sar3dRenderer:
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 = pg.PlotCurveItem(pen=pg.mkPen("w", width=1.5))
peak.setZValue(3)
plot.addItem(peak)
self.images[name] = image
self.peaks[name] = peak
self.targets[name] = peak
if x_label:
plot.showGrid(x=True, y=True, alpha=0.2)
plot.setLabel("bottom", x_label, units="m")
@@ -146,15 +153,18 @@ class Sar3dRenderer:
"""Clear all four 3D SAR views."""
for name, (title, *_rest) in SAR3D_VIEWS.items():
self.images[name].hide()
self.peaks[name].setData([], [])
self.targets[name].setData([], [])
self.plots[name].setTitle(title)
for markers in self.antennas.values():
for marker in markers:
marker.setData([], [])
self.box.setData([], [])
self.ground.setData([], [])
for label in self.axis_labels.values():
label.hide()
self.status_label.setText(message)
def draw(self, collection: ResultCollection) -> bool:
def draw(self, collection: ResultCollection, settings: GuiSar3dStateModel, geometry: GprModel) -> bool:
"""Draw four maximum projections of the volume; return success."""
status = sar3d_status(collection)
if status is not None and status[0] != 0:
@@ -174,20 +184,44 @@ class Sar3dRenderer:
peak = collection_payload_by_name(collection, "sar3d_peak", kind=4)
level, *peak_xyz = peak.table[0] if peak is not None else (None,)
antennas = selected_antenna_coordinates(geometry, settings.input_positions, settings.output_positions)
pitch = np.radians(settings.look_angle_deg)
for coordinates in antennas:
y, z = coordinates[:, 1].copy(), coordinates[:, 2].copy()
coordinates[:, 1] = settings.bp_ground_depth_m + y*np.cos(pitch) - z*np.sin(pitch)
coordinates[:, 2] = y*np.sin(pitch) + z*np.cos(pitch)
width_payload = collection_payload_by_name(collection, "sar3d_target_width", kind=4)
widths = width_payload.table[0] if width_payload is not None else np.full(3, settings.voxel_m)
zoom = settings.zoom_percent / 100.0
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.images[name]
image.setImage(payload.image, autoLevels=False, levels=(DISPLAY_THRESHOLD, 1.0))
image.setImage(np.where(payload.image >= settings.cloud_threshold, payload.image, 0),
autoLevels=False, levels=(0, 1))
image.setRect(rect)
image.show()
plot = self.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.peaks[name].setData([position[0]], [position[1]])
position = (basis.table @ np.asarray(peak_xyz) if axes is None else np.asarray(peak_xyz)[list(axes)]) if peak_xyz else None
if axes is not None:
for marker, coordinates, (symbol, brush, pen) in zip(self.antennas[name], antennas, ANTENNA_STYLES):
xx, yy = coordinates[:, axes[0]], coordinates[:, axes[1]]
marker.setData(x=xx, y=yy, symbol=symbol, size=13, brush=pg.mkBrush(brush), pen=pg.mkPen(pen))
if len(xx):
pad = settings.voxel_m
rect = rect.united(QRectF(xx.min()-pad, yy.min()-pad, np.ptp(xx)+2*pad, np.ptp(yy)+2*pad))
if position is not None:
extent = np.abs(basis.table) @ widths if axes is None else widths[list(axes)]
half = .5 * max(extent)
self.targets[name].setData(position[0]+np.array([-half, half, half, -half, -half]),
position[1]+np.array([-half, -half, half, half, -half]))
if zoom > 1.0:
center = position + (np.array([rect.center().x(), rect.center().y()])-position) / zoom**2
rect = QRectF(center[0]-rect.width()/(2*zoom), center[1]-rect.height()/(2*zoom),
rect.width()/zoom, rect.height()/zoom)
else:
self.peaks[name].setData([], [])
self.targets[name].setData([], [])
plot.setRange(rect, padding=0.04)
plot.setTitle(title)
# Scene box and ground on the oblique view, from the extents of the other views.
@@ -213,5 +247,5 @@ class Sar3dRenderer:
if level is not None:
x, y, z = peak_xyz
summary += f" | max X {x:+.2f}, Y {y:+.2f}, Z {z:.2f} m | level {level:.3g}"
self.status_label.setText(summary)
self.status_label.setText(f"{summary} | zoom {settings.zoom_percent}%")
return True
@@ -2,7 +2,7 @@
from __future__ import annotations
from PyQt6.QtCore import QRegularExpression
from PyQt6.QtCore import QRegularExpression, Qt
from PyQt6.QtGui import QRegularExpressionValidator
from PyQt6.QtWidgets import (
QCheckBox,
@@ -10,6 +10,7 @@ from PyQt6.QtWidgets import (
QFormLayout,
QGroupBox,
QLineEdit,
QSlider,
QSizePolicy,
QStackedWidget,
QVBoxLayout,
@@ -51,7 +52,7 @@ def build_processing_group(config: RunConfigModel, gui: GuiStateModel) -> Proces
group = view.group
form = QFormLayout(group)
form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
processing_mode = view.add("_processing_mode", choice(['pass_through', 'bscan', 'gpr', 'legacy_gpr', 'sar3d']))
processing_mode = view.add("_processing_mode", choice(['pass_through', 'bscan', 'gpr', 'sar3d']))
processing_mode_pages = QStackedWidget(group)
processing_mode_pages.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
show_magnitude_checkbox = view.add("_show_magnitude_checkbox", QCheckBox("Show magnitude"))
@@ -112,10 +113,6 @@ def build_processing_group(config: RunConfigModel, gui: GuiStateModel) -> Proces
gpr_defaults = config.gpr
# Medium permittivity is a legacy-GPR-only knob (shown on the legacy page below).
# Coherent BP fixes the medium to eps_r = 1, so it is not offered there.
gpr_relative_permittivity = view.add("_gpr_relative_permittivity", double_spin(0.0001, 1000.0, 4, 0.05))
gpr_relative_permittivity.setValue(float(gpr_defaults.relative_permittivity))
gpr_tx_geometry_input = view.add("_gpr_tx_geometry_input", AntennaGeometryEditor(
"output_pos", gpr_defaults.tx_geometry, position_count=config.output_switch.positions,
))
@@ -178,10 +175,9 @@ def build_processing_group(config: RunConfigModel, gui: GuiStateModel) -> Proces
gpr_visible_x_max_m = view.add("_gpr_visible_x_max_m", double_spin(-100.0, 100.0, 2, 0.1))
gpr_visible_z_min_m = view.add("_gpr_visible_z_min_m", double_spin(0.0, 100.0, 2, 0.1))
gpr_visible_z_max_m = view.add("_gpr_visible_z_max_m", double_spin(0.0, 100.0, 2, 0.1))
gpr_imaging_plane_y_m = view.add("_gpr_imaging_plane_y_m", double_spin(-50.0, 50.0, 3, 0.05))
gpr_imaging_plane_y_m.setToolTip(
"Y coordinate of the BP imaging slice (m). Use 0 for legacy 1D antenna layouts."
)
gpr_bp_ground_depth_m = view.add("_gpr_bp_ground_depth_m", double_spin(0.0, 50.0, 3, 0.05))
gpr_bp_ground_depth_m.setToolTip("Height of the radar origin above the ground; the BP slice follows the ground and radar tilt.")
gpr_pair_depth_equalize = view.add("_gpr_pair_depth_equalize", QCheckBox("PAIR_DEPTH_EQUALIZE"))
gpr_page = _build_processing_mode_page(
processing_mode_pages,
@@ -204,89 +200,22 @@ def build_processing_group(config: RunConfigModel, gui: GuiStateModel) -> Proces
("Approach min frames", gpr_object_approach_min_frames),
("Start (MHz)", gpr_start_freq_mhz),
("Stop (MHz)", gpr_stop_freq_mhz),
("Imaging plane Y (m)", gpr_imaging_plane_y_m),
("Radar height (m)", gpr_bp_ground_depth_m),
("Visible X min (m)", gpr_visible_x_min_m),
("Visible X max (m)", gpr_visible_x_max_m),
("Visible Z min (m)", gpr_visible_z_min_m),
("Visible Z max (m)", gpr_visible_z_max_m),
gpr_background_subtract_enabled,
("Mean count", gpr_background_mean_count),
gpr_pair_depth_equalize,
gpr_remove_sidelobe_objects_enabled,
],
split_index=8,
)
processing_mode_pages.addWidget(gpr_page)
legacy_gpr_config_mode = view.add("_legacy_gpr_config_mode", choice(['point', 'extended']))
legacy_gpr_input_positions_input = view.add("_legacy_gpr_input_positions_input", QLineEdit())
legacy_gpr_input_positions_input.setPlaceholderText("0,1,2")
legacy_gpr_output_positions_input = view.add("_legacy_gpr_output_positions_input", QLineEdit())
legacy_gpr_output_positions_input.setPlaceholderText("0,1")
legacy_gpr_min_depth_m = view.add("_legacy_gpr_min_depth_m", double_spin(0.0, 50.0, 2, 0.1))
legacy_gpr_max_depth_m = view.add("_legacy_gpr_max_depth_m", double_spin(0.1, 50.0, 2, 0.1))
legacy_gpr_comp_power = view.add("_legacy_gpr_comp_power", double_spin(0.0, 5.0, 3, 0.05))
legacy_gpr_snr_thresh = view.add("_legacy_gpr_snr_thresh", double_spin(0.0, 1000.0, 2, 0.1))
legacy_gpr_snr_comp_max = view.add("_legacy_gpr_snr_comp_max", double_spin(0.0, 1000.0, 2, 0.5))
legacy_gpr_start_freq_mhz = view.add("_legacy_gpr_start_freq_mhz", double_spin(100.0, 8800.0, 1, 10.0))
legacy_gpr_stop_freq_mhz = view.add("_legacy_gpr_stop_freq_mhz", double_spin(100.0, 8800.0, 1, 10.0))
legacy_gpr_speed_m_s = view.add("_legacy_gpr_speed_m_s", double_spin(-100.0, 100.0, 3, 0.01))
legacy_gpr_ignore_socket_speed_enabled = view.add("_legacy_gpr_ignore_socket_speed_enabled", QCheckBox("Ignore socket speed"))
legacy_gpr_ignore_socket_speed_enabled.setToolTip(_IGNORE_SOCKET_SPEED_TOOLTIP)
legacy_gpr_look_angle_deg = view.add("_legacy_gpr_look_angle_deg", double_spin(-90.0, 90.0, 2, 0.1))
# Intra-sweep phase compensation toggle. When checked, S21 phase is
# corrected per frequency point before the IFFT so the radar's motion
# during one sweep is removed at the spectrum stage. Only matters when
# speed != 0; otherwise has no effect by construction.
legacy_gpr_apply_freq_phase_correction = view.add("_legacy_gpr_apply_freq_phase_correction", QCheckBox("Apply intra-sweep phase correction"))
# Anchor point for the motion model's per-event `dt_ref`. `frame_center`
# spreads motion symmetrically around the frame midpoint; `first_tx_event`
# accumulates it forward from the first event center.
legacy_gpr_reference_mode = view.add("_legacy_gpr_reference_mode", choice(['frame_center', 'first_tx_event']))
legacy_gpr_background_subtract_enabled = view.add("_legacy_gpr_background_subtract_enabled", QCheckBox("Subtract mean of previous collections"))
legacy_gpr_background_mean_count = view.add("_legacy_gpr_background_mean_count", integer_spin(0, 10000))
legacy_gpr_render_mode = view.add("_legacy_gpr_render_mode", choice(['heatmap', 'objects_only']))
legacy_gpr_min_visible_pair_count = view.add("_legacy_gpr_min_visible_pair_count", integer_spin(1, 10000))
legacy_gpr_visible_x_min_m = view.add("_legacy_gpr_visible_x_min_m", double_spin(-100.0, 100.0, 2, 0.1))
legacy_gpr_visible_x_max_m = view.add("_legacy_gpr_visible_x_max_m", double_spin(-100.0, 100.0, 2, 0.1))
legacy_gpr_visible_z_min_m = view.add("_legacy_gpr_visible_z_min_m", double_spin(0.0, 100.0, 2, 0.1))
legacy_gpr_visible_z_max_m = view.add("_legacy_gpr_visible_z_max_m", double_spin(0.0, 100.0, 2, 0.1))
legacy_gpr_page = _build_processing_mode_page(
processing_mode_pages,
[
("Config mode", legacy_gpr_config_mode),
("Relative permittivity", gpr_relative_permittivity),
("Input positions", legacy_gpr_input_positions_input),
("Output positions", legacy_gpr_output_positions_input),
("Min depth (m)", legacy_gpr_min_depth_m),
("Max depth (m)", legacy_gpr_max_depth_m),
("Compensation power", legacy_gpr_comp_power),
("SNR threshold", legacy_gpr_snr_thresh),
("Max SNR compensation", legacy_gpr_snr_comp_max),
("Speed (m/s)", legacy_gpr_speed_m_s),
legacy_gpr_ignore_socket_speed_enabled,
("Render mode", legacy_gpr_render_mode),
("Min visible pairs", legacy_gpr_min_visible_pair_count),
("Start (MHz)", legacy_gpr_start_freq_mhz),
("Stop (MHz)", legacy_gpr_stop_freq_mhz),
("Look angle (°)", legacy_gpr_look_angle_deg),
("Reference mode", legacy_gpr_reference_mode),
legacy_gpr_apply_freq_phase_correction,
("Visible X min (m)", legacy_gpr_visible_x_min_m),
("Visible X max (m)", legacy_gpr_visible_x_max_m),
("Visible Z min (m)", legacy_gpr_visible_z_min_m),
("Visible Z max (m)", legacy_gpr_visible_z_max_m),
legacy_gpr_background_subtract_enabled,
("Mean count", legacy_gpr_background_mean_count),
],
split_index=10,
)
processing_mode_pages.addWidget(legacy_gpr_page)
# 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.
# Multi_antenna_demonstrate_depthnorm.ipynb; the shared gpr_* fields get their own
# widgets here, like on the GPR page.
sar3d_input_positions_input = view.add("_sar3d_input_positions_input", QLineEdit())
sar3d_input_positions_input.setPlaceholderText("0,1,2")
sar3d_output_positions_input = view.add("_sar3d_output_positions_input", QLineEdit())
@@ -311,8 +240,8 @@ def build_processing_group(config: RunConfigModel, gui: GuiStateModel) -> Proces
sar3d_speed_m_s = view.add("_sar3d_speed_m_s", double_spin(-100.0, 100.0, 3, 0.05))
sar3d_frame_radius = view.add("_sar3d_frame_radius", integer_spin(0, 24))
sar3d_frame_radius.setToolTip(
"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."
"Causal aperture: current and r previous frames. 0: the current frame only (needs a vertical antenna "
"baseline); >0 adds aperture from motion and needs an accurate speed. The scene follows the current frame."
)
sar3d_bp_ground_depth_m = view.add("_sar3d_bp_ground_depth_m", double_spin(0.0, 10.0, 2, 0.05))
sar3d_bp_ground_depth_m.setToolTip("Height of the lower antenna row above the ground (m); scene Y = 0 is the ground.")
@@ -321,6 +250,17 @@ def build_processing_group(config: RunConfigModel, gui: GuiStateModel) -> Proces
sar3d_use_compensation = view.add("_sar3d_use_compensation", QCheckBox("SAR_USE_COMPENSATION"))
sar3d_normalize_each_frame = view.add("_sar3d_normalize_each_frame", QCheckBox("SAR_NORMALIZE_EACH_FRAME"))
sar3d_normalize_each_frame.setToolTip("Scale every Tx/Rx A-scan by its median inside the depth gate.")
sar3d_pair_depth_equalize = view.add("_sar3d_pair_depth_equalize", QCheckBox("PAIR_DEPTH_EQUALIZE"))
sar3d_background_subtract_enabled = view.add("_sar3d_background_subtract_enabled", QCheckBox("Subtract mean of previous collections"))
sar3d_background_mean_count = view.add("_sar3d_background_mean_count", integer_spin(0, 10000))
sar3d_x_size_m = view.add("_sar3d_x_size_m", double_spin(0.0, 100.0, 2, 0.1))
sar3d_y_min_m = view.add("_sar3d_y_min_m", double_spin(-50.0, 50.0, 2, 0.1))
sar3d_y_max_m = view.add("_sar3d_y_max_m", double_spin(-50.0, 50.0, 2, 0.1))
sar3d_z_size_m = view.add("_sar3d_z_size_m", double_spin(0.0, 100.0, 2, 0.1))
sar3d_cloud_threshold = view.add("_sar3d_cloud_threshold", double_spin(0.0, 1.0, 2, 0.05))
sar3d_zoom_percent = view.add("_sar3d_zoom_percent", QSlider(Qt.Orientation.Horizontal))
sar3d_zoom_percent.setRange(100, 1000)
sar3d_zoom_percent.setToolTip("100% shows the full scene; higher values zoom towards its brightest object.")
sar3d_page = _build_processing_mode_page(
processing_mode_pages,
@@ -340,6 +280,15 @@ def build_processing_group(config: RunConfigModel, gui: GuiStateModel) -> Proces
("COMP_RANGE_POWER", sar3d_comp_range_power),
sar3d_use_compensation,
sar3d_normalize_each_frame,
sar3d_pair_depth_equalize,
sar3d_background_subtract_enabled,
("Mean count", sar3d_background_mean_count),
("SAR_X_SIZE_M (0 = auto)", sar3d_x_size_m),
("SAR_Y_RANGE_M min", sar3d_y_min_m),
("SAR_Y_RANGE_M max", sar3d_y_max_m),
("SAR_Z_SIZE_M (0 = auto)", sar3d_z_size_m),
("SAR_CLOUD_THRESHOLD", sar3d_cloud_threshold),
("Zoom (%)", sar3d_zoom_percent),
],
split_index=8,
)
@@ -367,17 +316,14 @@ def build_processing_group(config: RunConfigModel, gui: GuiStateModel) -> Proces
geometry_page=gpr_common_page, geometries=(gpr_tx_geometry_input, gpr_rx_geometry_input),
fixed_y=pass_through_fixed_y_enabled, y_bounds=(pass_through_y_min_db, pass_through_y_max_db),
speed_controls=((gpr_ignore_socket_speed_enabled, gpr_speed_m_s),
(legacy_gpr_ignore_socket_speed_enabled, legacy_gpr_speed_m_s),
(sar3d_ignore_socket_speed_enabled, sar3d_speed_m_s)),
trace_controls=(show_magnitude_checkbox, show_phase_checkbox, unwrap_phase_checkbox,
pass_through_combo_filter_input, pass_through_fixed_y_enabled,
pass_through_y_min_db, pass_through_y_max_db),
visual_controls=(gpr_render_mode, legacy_gpr_render_mode),
visual_controls=(gpr_render_mode, sar3d_cloud_threshold, sar3d_zoom_percent),
locator_controls=(gpr_max_detected_objects_to_draw, gpr_draw_top_m_objects,
legacy_gpr_min_visible_pair_count, gpr_visible_x_min_m, gpr_visible_x_max_m,
gpr_visible_z_min_m, gpr_visible_z_max_m, legacy_gpr_visible_x_min_m,
legacy_gpr_visible_x_max_m, legacy_gpr_visible_z_min_m, legacy_gpr_visible_z_max_m),
gpr_visible_x_min_m, gpr_visible_x_max_m,
gpr_visible_z_min_m, gpr_visible_z_max_m),
frequency_controls=(bscan_start_freq_mhz, bscan_stop_freq_mhz, gpr_start_freq_mhz, gpr_stop_freq_mhz,
legacy_gpr_start_freq_mhz, legacy_gpr_stop_freq_mhz,
sar3d_start_freq_mhz, sar3d_stop_freq_mhz),
)
+5 -5
View File
@@ -6,13 +6,13 @@ from typing import TypeAlias, TypeVar
from PyQt6.QtWidgets import (
QCheckBox, QComboBox, QDoubleSpinBox, QGroupBox, QLineEdit, QPlainTextEdit,
QSpinBox, QWidget, QLabel, QPushButton, QStackedWidget,
QSpinBox, QSlider, QWidget, QLabel, QPushButton, QStackedWidget,
)
from python_app.gui.antenna_geometry_editor import AntennaGeometryEditor
from python_app.models.setting_fields import Scalar
ScalarWidget: TypeAlias = QLineEdit | QPlainTextEdit | QComboBox | QCheckBox | QSpinBox | QDoubleSpinBox
ScalarWidget: TypeAlias = QLineEdit | QPlainTextEdit | QComboBox | QCheckBox | QSpinBox | QDoubleSpinBox | QSlider
SettingWidget: TypeAlias = ScalarWidget | AntennaGeometryEditor
_Widget = TypeVar("_Widget", bound=SettingWidget)
@@ -102,7 +102,7 @@ class RadarView(SectionView):
def read_setting(widget: ScalarWidget) -> Scalar:
if isinstance(widget, QCheckBox):
return widget.isChecked()
if isinstance(widget, (QSpinBox, QDoubleSpinBox)):
if isinstance(widget, (QSpinBox, QDoubleSpinBox, QSlider)):
return widget.value()
if isinstance(widget, QComboBox):
return widget.currentText()
@@ -114,7 +114,7 @@ def read_setting(widget: ScalarWidget) -> Scalar:
def write_setting(widget: ScalarWidget, value: Scalar) -> None:
if isinstance(widget, QCheckBox):
widget.setChecked(bool(value))
elif isinstance(widget, QSpinBox):
elif isinstance(widget, (QSpinBox, QSlider)):
widget.setValue(int(value))
elif isinstance(widget, QDoubleSpinBox):
widget.setValue(float(value))
@@ -134,7 +134,7 @@ def write_setting(widget: ScalarWidget, value: Scalar) -> None:
def connect_setting(widget: ScalarWidget, callback: Callable[..., object]) -> None:
if isinstance(widget, QCheckBox):
signal = widget.toggled
elif isinstance(widget, (QSpinBox, QDoubleSpinBox)):
elif isinstance(widget, (QSpinBox, QDoubleSpinBox, QSlider)):
signal = widget.valueChanged
elif isinstance(widget, QComboBox):
signal = widget.currentTextChanged
+1 -46
View File
@@ -1,4 +1,4 @@
"""Result payload lookup and GPR display filtering."""
"""Result payload lookup and coherent-GPR objects."""
from __future__ import annotations
@@ -22,22 +22,6 @@ def collection_payload_by_name(
return None
def collection_payloads_by_prefix(
collection: ResultCollection,
prefix: str,
kind: int | None = None,
) -> list[ResultPayload]:
"""Return collection payloads matching a processing-name prefix."""
payloads: list[ResultPayload] = []
for payload in collection.collection_payloads:
if not str(payload.processing_name).startswith(prefix):
continue
if kind is not None and int(payload.kind) != int(kind):
continue
payloads.append(payload)
return payloads
def gpr_object_rows(collection: ResultCollection) -> np.ndarray:
"""Return object rows as `[x_m, z_m, score]` from a GPR collection."""
points_payload = collection_payload_by_name(collection, "gpr_points", kind=4)
@@ -46,33 +30,4 @@ def gpr_object_rows(collection: ResultCollection) -> np.ndarray:
if points.ndim == 2 and points.shape[1] >= 3:
return points[:, :3]
centers_payload = collection_payload_by_name(collection, "gpr_region_centers", kind=4)
if centers_payload is not None:
centers = np.asarray(centers_payload.table, dtype=np.float32)
if centers.ndim == 2 and centers.shape[1] >= 3:
return centers[:, :3]
return np.zeros((0, 3), dtype=np.float32)
def filter_object_rows(
rows: np.ndarray,
*,
min_score: float,
x_bounds: tuple[float, float],
z_bounds: tuple[float, float],
) -> np.ndarray:
"""Keep finite objects inside the visible window and above the score threshold."""
if rows.size == 0:
return rows
x_min, x_max = x_bounds
z_min, z_max = z_bounds
visible_mask = (
np.all(np.isfinite(rows[:, :3]), axis=1)
& (rows[:, 2] >= min_score)
& (rows[:, 0] >= x_min)
& (rows[:, 0] <= x_max)
& (rows[:, 1] >= z_min)
& (rows[:, 1] <= z_max)
)
return rows[visible_mask]
+1 -1
View File
@@ -26,7 +26,7 @@ def validate_processing_mode_constraints(
# sar3d shares the GPR combo/geometry requirements: it images from the same
# multi-Tx/Rx pair set selected through the gpr_* live-config fields.
if processing_mode not in {"gpr", "legacy_gpr", "sar3d"}:
if processing_mode not in {"gpr", "sar3d"}:
return
configured_combos = {(int(combo.input), int(combo.output)) for combo in config.combos}
+1 -7
View File
@@ -5,7 +5,7 @@ from pathlib import Path
from python_app.models.run_config_schema import RunConfigModel, ComboModel
from python_app.models.gui_profile_schema import (
GuiStateModel, GuiSwitchStateModel, GuiProcessingStateModel, GuiGprStateModel,
GuiLegacyGprStateModel, GuiSar3dStateModel, GuiDataActionsStateModel,
GuiSar3dStateModel, GuiDataActionsStateModel,
)
GUI_SAVE_HISTORY_LIMIT = 1000
@@ -91,12 +91,6 @@ def default_gui_state_for_config(config: RunConfigModel, project_root: Path) ->
visible_x_min_m=default_gpr_x_min_m,
visible_x_max_m=default_gpr_x_max_m,
),
legacy_gpr=GuiLegacyGprStateModel(
input_positions=inputs,
output_positions=outputs,
visible_x_min_m=default_gpr_x_min_m,
visible_x_max_m=default_gpr_x_max_m,
),
sar3d=GuiSar3dStateModel(
input_positions=inputs,
output_positions=outputs,
+4 -21
View File
@@ -10,7 +10,7 @@ from python_app.models.json_fields import finite_number, json_object
from python_app.models.run_config_schema import RunConfigModel
def _decode_state(model: object, payload: dict[str, Any], context: str, *, check_constraints: bool = True) -> None:
def _decode_state(model: object, payload: dict[str, Any], context: str) -> None:
"""Read known fields recursively; unknown fields do not survive re-encoding."""
if not is_dataclass(model) or isinstance(model, type):
raise TypeError("GUI state must be a dataclass instance")
@@ -19,14 +19,14 @@ def _decode_state(model: object, payload: dict[str, Any], context: str, *, check
value = payload.get(member.name, default)
name = f"{context}.{member.name}"
if is_dataclass(default):
_decode_state(default, json_object(payload.get(member.name), name), name, check_constraints=check_constraints)
_decode_state(default, json_object(payload.get(member.name), name), name)
continue
if isinstance(default, float):
value = finite_number(value, name)
elif type(value) is not type(default):
raise ValueError(f"{name} must be a JSON {type(default).__name__}")
spec = member.metadata.get("setting")
if check_constraints and isinstance(spec, Setting):
if isinstance(spec, Setting):
if spec.choices and value not in spec.choices:
raise ValueError(f"{name} must be one of: {', '.join(spec.choices)}")
if spec.minimum is not None:
@@ -39,33 +39,16 @@ def _decode_state(model: object, payload: dict[str, Any], context: str, *, check
def gui_profile_from_dict(payload: dict[str, Any]) -> GuiProfileModel:
"""Read the pipeline config and optional GUI state, preserving old GPR profiles."""
"""Read the pipeline config and optional GUI state; ignore unknown fields."""
profile = GuiProfileModel(run_config=RunConfigModel.from_dict(payload))
if payload.get("gui") is None:
return profile
gui_object = json_object(payload["gui"], "gui")
processing = json_object(gui_object.get("processing"), "gui.processing")
old_gpr = json_object(processing.get("gpr"), "gui.processing.gpr")
gui = GuiStateModel()
# Older profiles kept both algorithms under gpr. Root mode and the short-lived
# algorithm selector choose the legacy page; explicit legacy values win.
root_gpr = payload.get("gpr")
root_mode = root_gpr.get("mode") if isinstance(root_gpr, dict) else None
algorithm_mode = {"legacy_point": "point", "legacy_extended": "extended"}.get(
str(old_gpr.get("algorithm", "")))
if root_mode in ("point", "extended"):
gui.processing.legacy_gpr.mode = root_mode
if algorithm_mode is not None:
gui.processing.legacy_gpr.mode = algorithm_mode
_decode_state(gui.processing.legacy_gpr, old_gpr, "gui.processing.gpr", check_constraints=False)
_decode_state(gui, gui_object, "gui")
if gui.version != 1:
raise ValueError(f"Unsupported gui.version: {gui.version}")
if gui.processing.selected_mode == "gpr" and (
algorithm_mode is not None or root_mode in ("point", "extended")
):
gui.processing.selected_mode = "legacy_gpr"
gui.preprocess_dialog.median_sweep_count = max(1, gui.preprocess_dialog.median_sweep_count)
profile.gui = gui
return profile
+13 -41
View File
@@ -121,7 +121,8 @@ class GuiGprStateModel:
remove_sidelobe_objects_enabled: bool = setting(
True, '_gpr_remove_sidelobe_objects_enabled', live='gpr_remove_sidelobe_objects_enabled', group='Imaging',
)
imaging_plane_y_m: float = setting(0.0, '_gpr_imaging_plane_y_m', live='gpr_imaging_plane_y_m', group='Geometry & depth')
bp_ground_depth_m: float = setting(1.25, '_gpr_bp_ground_depth_m', live='gpr_bp_ground_depth_m', group='Geometry & depth', minimum=0)
pair_depth_equalize: bool = setting(True, '_gpr_pair_depth_equalize', live='pair_depth_equalize', group='Imaging')
render_mode: str = setting("heatmap", '_gpr_render_mode', web='render_mode', group='Display', choices=('heatmap', 'objects_only'))
visible_x_min_m: float = setting(-2.0, '_gpr_visible_x_min_m', live='gpr_visible_x_min_m', group='Visible window')
visible_x_max_m: float = setting(2.0, '_gpr_visible_x_max_m', live='gpr_visible_x_max_m', group='Visible window')
@@ -129,48 +130,11 @@ class GuiGprStateModel:
visible_z_max_m: float = setting(14.0, '_gpr_visible_z_max_m', live='gpr_visible_z_max_m', group='Visible window')
@dataclass(slots=True)
class GuiLegacyGprStateModel:
"""UI-only defaults for legacy point/extended GPR live settings."""
mode: str = setting("point", '_legacy_gpr_config_mode', live='legacy_gpr_mode', group='Mode', choices=('point', 'extended'))
input_positions: str = setting("", '_legacy_gpr_input_positions_input', live='gpr_input_positions', group='Geometry & depth')
output_positions: str = setting("", '_legacy_gpr_output_positions_input', live='gpr_output_positions', group='Geometry & depth')
min_depth_m: float = setting(2.0, '_legacy_gpr_min_depth_m', live='gpr_min_depth_m', group='Geometry & depth')
max_depth_m: float = setting(14.0, '_legacy_gpr_max_depth_m', live='gpr_max_depth_m', group='Geometry & depth')
comp_power: float = setting(0.2, '_legacy_gpr_comp_power', live='gpr_comp_power', group='Detection', minimum=0)
start_freq_mhz: float = setting(3000.0, '_legacy_gpr_start_freq_mhz', live='gpr_start_freq_mhz', group='Geometry & depth')
stop_freq_mhz: float = setting(6000.0, '_legacy_gpr_stop_freq_mhz', live='gpr_stop_freq_mhz', group='Geometry & depth')
speed_m_s: float = setting(0.0, '_legacy_gpr_speed_m_s', live='gpr_speed_m_s', group='Motion')
ignore_socket_speed_enabled: bool = setting(False, '_legacy_gpr_ignore_socket_speed_enabled', live='ignore_socket_speed', group='Motion')
look_angle_deg: float = setting(0.0, '_legacy_gpr_look_angle_deg', live='gpr_look_angle_deg', group='Motion')
apply_freq_phase_correction: bool = setting(
True, '_legacy_gpr_apply_freq_phase_correction', live='gpr_apply_freq_phase_correction', group='Motion',
)
reference_mode: str = setting(
"frame_center", '_legacy_gpr_reference_mode', live='gpr_reference_mode', group='Motion', choices=('frame_center', 'first_tx_event'),
)
snr_thresh: float = setting(4.5, '_legacy_gpr_snr_thresh', live='gpr_snr_thresh', group='Detection', minimum=0)
snr_comp_max: float = setting(25.0, '_legacy_gpr_snr_comp_max', live='gpr_snr_comp_max', group='Detection', minimum=0)
background_subtract_enabled: bool = setting(
True, '_legacy_gpr_background_subtract_enabled', live='gpr_background_subtract_enabled', group='Imaging',
)
background_mean_count: int = setting(10, '_legacy_gpr_background_mean_count', live='gpr_background_mean_count', group='Imaging')
render_mode: str = setting("heatmap", '_legacy_gpr_render_mode', web='render_mode', group='Display', choices=('heatmap', 'objects_only'))
min_visible_pair_count: int = setting(
1, '_legacy_gpr_min_visible_pair_count', live='legacy_gpr_min_visible_pair_count', group='Detection', minimum=1,
)
visible_x_min_m: float = setting(-2.0, '_legacy_gpr_visible_x_min_m', live='gpr_visible_x_min_m', group='Visible window')
visible_x_max_m: float = setting(2.0, '_legacy_gpr_visible_x_max_m', live='gpr_visible_x_max_m', group='Visible window')
visible_z_min_m: float = setting(0.0, '_legacy_gpr_visible_z_min_m', live='gpr_visible_z_min_m', group='Visible window')
visible_z_max_m: float = setting(14.0, '_legacy_gpr_visible_z_max_m', live='gpr_visible_z_max_m', group='Visible window')
@dataclass(slots=True)
class GuiSar3dStateModel:
"""UI-only defaults for the 3D SAR (sar3d) live settings.
Values are the reference notebook's (Multi_antenna_3D_clean_v1.ipynb), whose
Values are the reference notebook's (Multi_antenna_demonstrate_depthnorm.ipynb), whose
names the GUI shows: INPUT_IDX, F_START, MIN_DEPTH, LOOK_ANGLE_DEG, ...
"""
@@ -189,6 +153,15 @@ class GuiSar3dStateModel:
comp_range_power: float = setting(0.3, '_sar3d_comp_range_power', live='sar3d_comp_range_power', group='3D SAR')
use_compensation: bool = setting(True, '_sar3d_use_compensation', live='sar3d_use_compensation', group='3D SAR')
normalize_each_frame: bool = setting(True, '_sar3d_normalize_each_frame', live='sar3d_normalize_each_frame', group='3D SAR')
pair_depth_equalize: bool = setting(True, '_sar3d_pair_depth_equalize', live='pair_depth_equalize', group='3D SAR')
background_subtract_enabled: bool = setting(True, '_sar3d_background_subtract_enabled', live='gpr_background_subtract_enabled', group='3D SAR')
background_mean_count: int = setting(10, '_sar3d_background_mean_count', live='gpr_background_mean_count', group='3D SAR', minimum=0)
x_size_m: float = setting(0.0, '_sar3d_x_size_m', live='sar3d_x_size_m', group='Scene', minimum=0)
y_min_m: float = setting(-0.5, '_sar3d_y_min_m', live='sar3d_y_min_m', group='Scene')
y_max_m: float = setting(2.0, '_sar3d_y_max_m', live='sar3d_y_max_m', group='Scene')
z_size_m: float = setting(0.0, '_sar3d_z_size_m', live='sar3d_z_size_m', group='Scene', minimum=0)
cloud_threshold: float = setting(0.65, '_sar3d_cloud_threshold', web='sar3d_cloud_threshold', group='Display', minimum=0, maximum=1)
zoom_percent: int = setting(100, '_sar3d_zoom_percent', web='sar3d_zoom_percent', group='Display', minimum=100, maximum=1000)
@dataclass(slots=True)
@@ -196,12 +169,11 @@ class GuiProcessingStateModel:
"""UI-only processing-section defaults."""
selected_mode: str = setting(
"pass_through", '_processing_mode', live='processor_mode', group='Processor', choices=('pass_through', 'bscan', 'gpr', 'legacy_gpr', 'sar3d'),
"pass_through", '_processing_mode', live='processor_mode', group='Processor', choices=('pass_through', 'bscan', 'gpr', 'sar3d'),
)
pass_through: GuiPassThroughStateModel = field(default_factory=GuiPassThroughStateModel)
bscan: GuiBscanStateModel = field(default_factory=GuiBscanStateModel)
gpr: GuiGprStateModel = field(default_factory=GuiGprStateModel)
legacy_gpr: GuiLegacyGprStateModel = field(default_factory=GuiLegacyGprStateModel)
sar3d: GuiSar3dStateModel = field(default_factory=GuiSar3dStateModel)
-3
View File
@@ -343,9 +343,6 @@ def run_config_from_dict(payload: object) -> RunConfigModel:
if model.preprocess.notch.taper_type not in ("cosine", "hard"):
raise ValueError("preprocess.notch.taper_type must be cosine or hard")
model.gpr.relative_permittivity = _read_float(
gpr_payload, "relative_permittivity", model.gpr.relative_permittivity
)
model.gpr.tx_geometry = []
for entry in _as_list(gpr_payload.get("tx_geometry"), "gpr.tx_geometry"):
entry_payload = _as_dict(entry, "gpr.tx_geometry[]")
-1
View File
@@ -304,7 +304,6 @@ class GprRxGeometryModel:
class GprModel:
"""Stable GPR configuration saved in run_config.json."""
relative_permittivity: float = 1.0
tx_geometry: list[GprTxGeometryModel] = field(default_factory=list)
rx_geometry: list[GprRxGeometryModel] = field(default_factory=list)
+1 -5
View File
@@ -288,8 +288,7 @@ def validate_gpr_model(
) -> None:
"""Validate stable GPR config against current switch dimensions.
Checks that the relative permittivity is positive and that every tx/rx
geometry entry indexes a valid, non-duplicate switch position. When ``sweep``
Checks that every tx/rx geometry entry indexes a valid, non-duplicate switch position. When ``sweep``
is supplied (the load and GUI/save paths share this chokepoint), its bounds
are validated here too so sweep failures surface alongside the GPR checks
instead of as a C++ boot crash.
@@ -297,9 +296,6 @@ def validate_gpr_model(
if sweep is not None:
validate_sweep_model(sweep)
if not math.isfinite(gpr.relative_permittivity) or float(gpr.relative_permittivity) <= 0.0:
raise ValueError("gpr.relative_permittivity must be > 0")
normalize_antenna_geometry(gpr.tx_geometry, "output_pos", output_switch_positions)
normalize_antenna_geometry(gpr.rx_geometry, "input_pos", input_switch_positions)
@@ -21,8 +21,6 @@ class ProcessingLiveConfig:
bscan_gain: float = 1.0
bscan_start_freq_mhz: float = 100.0
bscan_stop_freq_mhz: float = 8800.0
legacy_gpr_mode: str = "point"
relative_permittivity: float | None = None
tx_geometry: list[dict] | None = None
rx_geometry: list[dict] | None = None
gpr_input_positions: list[int] | None = None
@@ -30,7 +28,6 @@ class ProcessingLiveConfig:
gpr_min_depth_m: float = 2.0
gpr_max_depth_m: float = 14.0
gpr_range_comp_power: float = 0.1
gpr_comp_power: float = 0.2
gpr_object_min_frac: float = 0.7
gpr_score_mode: str = "combined"
# Backprojection intra-sweep speed-correction mode: "int_minus" (full
@@ -41,28 +38,14 @@ class ProcessingLiveConfig:
gpr_object_approach_min_frames: int = 3
gpr_speed_m_s: float = 0.0
gpr_look_angle_deg: float = 0.0
# Motion-model knobs for the legacy GPR pipeline. `direction_sign` flips
# which way later events appear deeper (+1) vs shallower (-1) along Z.
# `apply_freq_phase_correction` enables intra-sweep frequency-domain phase
# compensation that fires *before* the IFFT — needed when the radar moves
# appreciably during one sweep.
gpr_direction_sign: float = 1.0
gpr_apply_freq_phase_correction: bool = True
# Anchor for the motion model's per-event `dt_ref`: 'frame_center' (default)
# or 'first_tx_event'. Mirrors Python `MOTION_CONFIG.reference_mode`.
gpr_reference_mode: str = "frame_center"
gpr_snr_thresh: float = 4.5
gpr_snr_comp_max: float = 25.0
gpr_start_freq_mhz: float = 3000.0
gpr_stop_freq_mhz: float = 6000.0
gpr_background_subtract_enabled: bool = True
gpr_background_mean_count: int = 10
gpr_remove_sidelobe_objects_enabled: bool = True
gpr_imaging_plane_y_m: float = 0.0
# Locator filter parameter consumed by the C++ TCP locator server. Coherent BP
# objects are already finalized in the processor (no score threshold); only legacy
# GPR still thresholds, on a pair count.
legacy_gpr_min_visible_pair_count: float = 0.0
gpr_bp_ground_depth_m: float = 1.25
pair_depth_equalize: bool = True
# Visible X/Z window (metres). The locator and the desktop plot both clip
# detected objects to this window, so the socket broadcasts only what is shown.
gpr_visible_x_min_m: float = -2.0
@@ -70,7 +53,7 @@ class ProcessingLiveConfig:
gpr_visible_z_min_m: float = 0.0
gpr_visible_z_max_m: float = 14.0
# 3D SAR (sar3d processor) parameters, named after the reference notebook
# Multi_antenna_3D_clean_v1.ipynb (SAR_FRAME_RADIUS, BP_GROUND_DEPTH_M, ...).
# Multi_antenna_demonstrate_depthnorm.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
@@ -79,6 +62,10 @@ class ProcessingLiveConfig:
sar3d_comp_range_power: float = 0.3
sar3d_use_compensation: bool = True
sar3d_normalize_each_frame: bool = True
sar3d_x_size_m: float = 0.0
sar3d_y_min_m: float = -0.5
sar3d_y_max_m: float = 2.0
sar3d_z_size_m: float = 0.0
# 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
@@ -100,7 +87,7 @@ class ProcessingLiveConfig:
def to_dict(self) -> dict[str, object]:
"""Convert live config to JSON-serializable dictionary."""
optional_geometry = {"relative_permittivity", "tx_geometry", "rx_geometry"}
optional_geometry = {"tx_geometry", "rx_geometry"}
return {
name: value for name, value in asdict(self).items()
if name not in optional_geometry or value is not None
@@ -15,7 +15,7 @@ import unittest
from unittest import mock
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PyQt6.QtWidgets import QApplication, QCheckBox, QComboBox, QDoubleSpinBox, QLineEdit, QSpinBox # noqa: E402
from PyQt6.QtWidgets import QApplication, QCheckBox, QComboBox, QDoubleSpinBox, QLineEdit, QSlider, QSpinBox # noqa: E402
from PyQt6.QtCore import QSignalBlocker, QTimer # noqa: E402
from python_app.gui.app_window import AppWindow # noqa: E402
@@ -532,11 +532,11 @@ class GuiProfileWriteTest(unittest.TestCase):
value = not widget.isChecked()
elif isinstance(widget, QComboBox):
value = widget.itemText((widget.currentIndex() + 1) % widget.count())
elif isinstance(widget, (QSpinBox, QDoubleSpinBox)):
elif isinstance(widget, (QSpinBox, QDoubleSpinBox, QSlider)):
value = widget.value() + widget.singleStep()
if value > widget.maximum():
value = widget.value() - widget.singleStep()
value = int(value) if isinstance(widget, QSpinBox) else round(value, widget.decimals())
value = int(value) if isinstance(widget, (QSpinBox, QSlider)) else round(value, widget.decimals())
elif isinstance(widget, QLineEdit):
value = "0:0" if binding.spec.web == "combo_filter" else "0" if "positions" in binding.path[-1] else "1"
else:
+11 -5
View File
@@ -110,14 +110,20 @@ class GuiProfileCodecTest(unittest.TestCase):
with self.assertRaises(ValueError): # combos_text must be a JSON string, not a number
GuiProfileModel.from_dict({"gui": {"switches": {"combos_text": 123}}})
def test_legacy_gpr_payload_migrates_selected_mode(self) -> None:
# An old payload that selects 'gpr' but carries a legacy root gpr.mode is migrated.
def test_obsolete_inactive_settings_are_ignored(self) -> None:
decoded = GuiProfileModel.from_dict({
"gui": {"processing": {"selected_mode": "gpr"}},
"gpr": {"relative_permittivity": 4.0, "mode": "point"},
"gui": {"processing": {"selected_mode": "gpr", "legacy_gpr": "obsolete",
"gpr": {"algorithm": "legacy_point"}}},
"gpr": {"relative_permittivity": "obsolete", "mode": "point"},
})
assert decoded.gui is not None
self.assertEqual(decoded.gui.processing.selected_mode, "legacy_gpr")
self.assertEqual(decoded.gui.processing.selected_mode, "gpr")
self.assertNotIn("legacy_gpr", decoded.to_dict()["gui"]["processing"])
self.assertNotIn("relative_permittivity", decoded.to_dict()["gpr"])
def test_removed_processing_mode_is_rejected(self) -> None:
with self.assertRaisesRegex(ValueError, "selected_mode must be one of"):
GuiProfileModel.from_dict({"gui": {"processing": {"selected_mode": "legacy_gpr"}}})
def test_modern_gpr_selection_is_not_migrated(self) -> None:
decoded = GuiProfileModel.from_dict({"gui": {"processing": {"selected_mode": "gpr"}}})
@@ -25,11 +25,11 @@ class LiveSettingsRegressionTest(unittest.TestCase):
config, gui = RunConfigModel(), GuiStateModel()
config.radar.sweep.start_hz, config.radar.sweep.stop_hz = 1e6, 9.5e9
for settings in (gui.processing.bscan, gui.processing.gpr,
gui.processing.legacy_gpr, gui.processing.sar3d):
gui.processing.sar3d):
settings.start_freq_mhz, settings.stop_freq_mhz = 20, 9200
section = build_processing_group(config, gui)
self.addCleanup(section.group.deleteLater)
for mode in ("bscan", "gpr", "legacy_gpr", "sar3d"):
for mode in ("bscan", "gpr", "sar3d"):
for bound, expected in (("start", 20), ("stop", 9200)):
with self.subTest(mode=mode, bound=bound):
editor = widget(section, f"_{mode}_{bound}_freq_mhz", QDoubleSpinBox)
@@ -11,7 +11,7 @@ class LiveSettingsSchemaCoverageTest(unittest.TestCase):
def test_bindings_cover_live_config_and_select_each_page_once(self) -> None:
config_names = {field.name for field in fields(ProcessingLiveConfig)}
runtime = {"reprocess_current_result", "history_clear_before_ns", "history_removed", "settings_revision"}
geometry = {"relative_permittivity", "tx_geometry", "rx_geometry"}
geometry = {"tx_geometry", "rx_geometry"}
modes = next(binding.spec.choices for binding in GUI_FIELDS if binding.spec.live == "processor_mode")
widget_names = [binding.spec.widget for binding in GUI_FIELDS if binding.spec.widget]
self.assertEqual(len(widget_names), len(set(widget_names)))
+2 -53
View File
@@ -1,4 +1,4 @@
"""GPR object filtering, B-scan transforms and live-setting normalization."""
"""GPR payload lookup, B-scan transforms and live-setting normalization."""
from __future__ import annotations
@@ -26,8 +26,6 @@ from python_app.models.dataset_model import (
)
from python_app.gui.result_payloads import (
collection_payload_by_name,
collection_payloads_by_prefix,
filter_object_rows,
gpr_object_rows,
)
from python_app.orchestration.live_processing_config import ProcessingLiveConfig
@@ -43,32 +41,16 @@ def _collection(payloads: Iterable[ResultPayload]) -> ResultCollection:
class CollectionLookupTest(unittest.TestCase):
def test_payload_by_name_matches_name_and_optional_kind(self) -> None:
col = _collection([_table("gpr_points", [[0, 0, 1]]), _table("gpr_region_centers", [[1, 1, 2, 9]])])
col = _collection([_table("gpr_points", [[0, 0, 1]])])
self.assertIs(collection_payload_by_name(col, "gpr_points"), col.collection_payloads[0])
self.assertIsNone(collection_payload_by_name(col, "missing"))
self.assertIsNone(collection_payload_by_name(col, "gpr_points", kind=3)) # wrong kind
def test_payloads_by_prefix_returns_all_in_order(self) -> None:
col = _collection([
ResultPayload(processing_name="gpr_region_mask_0", kind=3, image=np.zeros((1, 1), dtype=np.float32)),
ResultPayload(processing_name="gpr_region_mask_1", kind=3, image=np.zeros((1, 1), dtype=np.float32)),
_table("gpr_points", [[0, 0, 1]]),
])
masks = collection_payloads_by_prefix(col, "gpr_region_mask_", kind=3)
self.assertEqual([p.processing_name for p in masks], ["gpr_region_mask_0", "gpr_region_mask_1"])
self.assertEqual(collection_payloads_by_prefix(col, "nope_"), [])
class GprObjectRowsTest(unittest.TestCase):
def test_extracts_first_three_columns_of_points(self) -> None:
rows = gpr_object_rows(_collection([_table("gpr_points", [[1, 2, 3], [4, 5, 6]])]))
self.assertTrue(np.array_equal(rows, np.array([[1, 2, 3], [4, 5, 6]], dtype=np.float32)))
def test_falls_back_to_region_centers_and_drops_extra_columns(self) -> None:
# region_centers is [x, z, score, pixel_count]; only the first 3 cols are used.
rows = gpr_object_rows(_collection([_table("gpr_region_centers", [[1, 2, 3, 99]])]))
self.assertTrue(np.array_equal(rows, np.array([[1, 2, 3]], dtype=np.float32)))
def test_empty_when_no_object_payloads(self) -> None:
self.assertEqual(gpr_object_rows(_collection([_table("gpr_accumulator", [[1, 2, 3]])])).shape, (0, 3))
@@ -76,39 +58,6 @@ class GprObjectRowsTest(unittest.TestCase):
self.assertEqual(gpr_object_rows(_collection([_table("gpr_points", [[1, 2]])])).shape, (0, 3))
class FilterObjectRowsTest(unittest.TestCase):
BOUNDS = {"x_bounds": (-2.0, 2.0), "z_bounds": (0.0, 10.0)}
def _filter(self, rows: Sequence[Sequence[float]], *, min_score: float) -> np.ndarray:
return filter_object_rows(np.asarray(rows, dtype=np.float32), min_score=min_score,
**self.BOUNDS)
def test_keeps_in_window_and_at_or_above_threshold(self) -> None:
out = self._filter([[0.0, 5.0, 0.5], [1.0, 1.0, 0.9]], min_score=0.5) # score==threshold kept (inclusive)
self.assertEqual(out.shape[0], 2)
def test_drops_below_threshold(self) -> None:
out = self._filter([[0.0, 5.0, 0.4]], min_score=0.5)
self.assertEqual(out.shape[0], 0)
def test_window_bounds_are_inclusive(self) -> None:
out = self._filter([[2.0, 10.0, 1.0], [-2.0, 0.0, 1.0]], min_score=0.0) # exactly on each edge
self.assertEqual(out.shape[0], 2)
def test_drops_outside_window(self) -> None:
out = self._filter([[2.001, 5.0, 1.0], [0.0, 10.001, 1.0]], min_score=0.0)
self.assertEqual(out.shape[0], 0)
def test_drops_non_finite_rows(self) -> None:
out = self._filter([[np.nan, 5.0, 1.0], [0.0, np.inf, 1.0], [0.0, 5.0, 1.0]], min_score=0.0)
self.assertEqual(out.shape[0], 1)
def test_legacy_pair_count_threshold_uses_same_compare(self) -> None:
# legacy GPR passes an integer pair-count threshold; the >= compare is identical.
out = self._filter([[0.0, 5.0, 3.0], [0.0, 5.0, 2.0]], min_score=3)
self.assertTrue(np.array_equal(out, np.array([[0.0, 5.0, 3.0]], dtype=np.float32)))
class BscanTransformTest(unittest.TestCase):
def test_mean_ascan_subtraction(self) -> None:
history = deque([np.array([1.0, 1.0], dtype=np.float32), np.array([3.0, 3.0], dtype=np.float32)])
@@ -130,6 +130,27 @@ class _ProcessorTestCase(unittest.TestCase):
class FedBackSweepsTest(_ProcessorTestCase):
def test_mean_history_is_not_limited_by_transport_ring_capacity(self) -> None:
self._live.processor_mode = "gpr"
self._live.gpr_input_positions = self._live.gpr_output_positions = [0]
self._live.gpr_background_subtract_enabled = True
self._live.gpr_background_mean_count = 60
self._live.gpr_min_depth_m, self._live.gpr_max_depth_m = .1, 1.5
self._live.gpr_bp_ground_depth_m = 0.
self._live_writer.write(self._live)
start = time.monotonic_ns()
for cid in range(1, 61):
sweep = _sweep(cid, start + cid*1_000_000)
sweep.traces[0].s21 *= (-1.)**np.arange(len(sweep.traces[0].s21))
self._push([sweep])
self._result_for_revision(0, cid)
current = _sweep(61, start + 61_000_000)
current.traces[0].s21[:] = 30.5 * (-1.)**np.arange(len(current.traces[0].s21))
self._push([current])
result = self._result_for_revision(0, 61)
image = next(payload.image for payload in result.collection_payloads if payload.processing_name == "gpr_accumulator")
self.assertFalse(np.any(image)) # Exact cancellation of the 60 preceding spectra (1…60).
def test_remove_targets_exact_sweep_with_reused_ids_and_preserves_missing_targets(self) -> None:
start_ns = time.monotonic_ns()
sweeps = [_sweep(cid, start_ns + index * 1_000_000)
@@ -150,7 +150,7 @@ class ReviewGuiRegressions(unittest.TestCase):
deadline.assert_called_once()
window = isolated_window(self.addCleanup)
window._pipeline.timer.stop()
for mode in ("gpr", "legacy_gpr", "sar3d"):
for mode in ("gpr", "sar3d"):
field = widget(window._view.processing, f"_{mode}_input_positions_input", QLineEdit)
validator = field.validator()
self.assertIsNotNone(validator)
@@ -161,7 +161,8 @@ class ReviewGuiRegressions(unittest.TestCase):
# A trailing comma during editing is harmless to the shared CSV parser.
for table in (np.empty((0, 0)), np.array([[99.]])):
result = ResultCollection(1, 2, collection_payloads=[ResultPayload(processing_name="sar3d_status", kind=4, table=table)])
self.assertFalse(window._plots.sar3d.draw(result))
self.assertFalse(window._plots.sar3d.draw(result, window._config.processing_settings().sar3d,
window._config.geometry()))
self.assertIn("error", window._plots.sar3d.status_label.text().lower())
def test_stop_or_close_cancels_paused_actions_without_error_dialog(self) -> None:
+1 -5
View File
@@ -363,7 +363,7 @@ class RingValidationTest(unittest.TestCase):
class GprValidationTest(unittest.TestCase):
@staticmethod
def _gpr(**overrides: object) -> GprModel:
model = GprModel(relative_permittivity=4.0)
model = GprModel()
for key, value in overrides.items():
setattr(model, key, value)
return model
@@ -371,10 +371,6 @@ class GprValidationTest(unittest.TestCase):
def _validate(self, gpr: GprModel) -> None:
validate_gpr_model(gpr, input_switch_positions=4, output_switch_positions=2)
def test_permittivity_must_be_positive(self) -> None:
with self.assertRaisesRegex(ValueError, "relative_permittivity"):
self._validate(self._gpr(relative_permittivity=0.0))
def test_tx_output_pos_out_of_range(self) -> None:
with self.assertRaisesRegex(ValueError, "output_pos is out of range"):
self._validate(self._gpr(tx_geometry=[GprTxGeometryModel(output_pos=5, x_m=0.0, y_m=0.0, z_m=0.0)]))
+93 -10
View File
@@ -9,11 +9,13 @@ from unittest import mock
import numpy as np
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PyQt6.QtCore import Qt, QSignalBlocker # noqa: E402
from PyQt6.QtWidgets import QApplication, QCheckBox, QComboBox, QDoubleSpinBox # noqa: E402
from PyQt6.QtCore import QPointF, QRectF, Qt, QSignalBlocker # noqa: E402
from PyQt6.QtWidgets import QApplication, QCheckBox, QComboBox, QDoubleSpinBox, QSlider # noqa: E402
from python_app.gui.controllers.renderers.sar3d import SAR3D_STATES, SAR3D_VIEWS, Sar3dRenderer # noqa: E402
from python_app.models.dataset_model import ComboKey, ResultCollection, ResultPayload, TraceData # noqa: E402
from python_app.models.gui_profile_schema import GuiSar3dStateModel # noqa: E402
from python_app.models.run_config_schema import GprModel, GprTxGeometryModel, GprRxGeometryModel # noqa: E402
from python_app.tests.gui_test_support import widget
@@ -47,12 +49,13 @@ class Sar3dPlotTest(unittest.TestCase):
def setUp(self) -> None:
self.renderer = Sar3dRenderer()
self.settings, self.geometry = GuiSar3dStateModel(), GprModel()
self.addCleanup(self.renderer.widget.close)
def test_all_four_projections_have_visible_images_and_correct_peak_axes(self) -> None:
self.assertTrue(self.renderer.draw(_projections()))
self.assertTrue(self.renderer.draw(_projections(), self.settings, self.geometry))
self.assertEqual(self.renderer.status_label.text(),
"SAR3D: ready — CUDA | max X +0.50, Y -0.20, Z 2.00 m | level 2")
"SAR3D: ready — CUDA | max X +0.50, Y -0.20, Z 2.00 m | level 2 | zoom 100%")
positions = {"sar3d_front": (0.5, -0.2), "sar3d_side": (2, -0.2),
"sar3d_top": (0.5, 2), "sar3d_oblique": (0.5, -0.2)}
for name, position in positions.items():
@@ -60,27 +63,83 @@ class Sar3dPlotTest(unittest.TestCase):
self.assertTrue(image.isVisible(), name)
self.assertGreater(image.boundingRect().width(), 0)
self.assertGreater(image.boundingRect().height(), 0)
xx, yy = self.renderer.peaks[name].getData()
np.testing.assert_allclose([xx[0], yy[0]], position)
xx, yy = self.renderer.targets[name].getData()
assert xx is not None and yy is not None
np.testing.assert_allclose([.5*(xx.min()+xx.max()), .5*(yy.min()+yy.max())], position)
self.assertAlmostEqual(float(np.ptp(xx)), float(np.ptp(yy)))
self.assertTrue(all(label.isVisible() for label in self.renderer.axis_labels.values()))
self.renderer.clear()
self.assertEqual(self.renderer.status_label.text(), "SAR3D: waiting for measurements")
def test_unavailable_warning_clears_previous_images_and_labels_and_shows_reason(self) -> None:
self.renderer.draw(_projections())
self.renderer.draw(_projections(), self.settings, self.geometry)
message = "CUDA unavailable: драйвер <libcuda>"
self.assertFalse(self.renderer.draw(_collection(_status(3, message))))
self.assertFalse(self.renderer.draw(_collection(_status(3, message)), self.settings, self.geometry))
self.assertEqual(self.renderer.status_label.text(), f"SAR3D: warning — {message}")
self.assertEqual(self.renderer.status_label.textFormat(), Qt.TextFormat.PlainText)
self.assertTrue(all(not image.isVisible() for image in self.renderer.images.values()))
self.assertTrue(all(not label.isVisible() for label in self.renderer.axis_labels.values()))
def test_no_signal_and_missing_projections_explain_empty_panels(self) -> None:
self.assertFalse(self.renderer.draw(_collection(_status(1, "No signal in selected range"))))
self.assertFalse(self.renderer.draw(_collection(_status(1, "No signal in selected range")), self.settings, self.geometry))
self.assertIn("no signal", self.renderer.status_label.text())
self.assertFalse(self.renderer.draw(_collection()))
self.assertFalse(self.renderer.draw(_collection(), self.settings, self.geometry))
self.assertEqual(self.renderer.status_label.text(), "SAR3D: waiting for projection data")
def test_response_size_antenna_positions_and_zoom_use_scene_coordinates(self) -> None:
result = _projections()
result.collection_payloads.append(ResultPayload("sar3d_target_width", 4, table=np.array([[.4, .6, .8]])))
self.geometry = GprModel(tx_geometry=[GprTxGeometryModel(0, .7, .3, .4)],
rx_geometry=[GprRxGeometryModel(0, -.7, -.3, .2)])
self.settings.look_angle_deg = 30
self.settings.bp_ground_depth_m = 1.25
self.renderer.widget.resize(800, 600)
self.renderer.widget.show()
self.renderer.draw(result, self.settings, self.geometry)
self.app.processEvents()
baseline = {}
expected_antenna = [.7, 1.25 + .3*np.cos(np.pi/6) - .4*.5, .3*.5 + .4*np.cos(np.pi/6)]
for name, (_, _, _, axes) in SAR3D_VIEWS.items():
xx, yy = self.renderer.targets[name].getData()
assert xx is not None and yy is not None
side = .6 if name in {"sar3d_front", "sar3d_oblique"} else .8
self.assertAlmostEqual(float(np.ptp(xx)), side)
if axes is not None:
tx, _ = self.renderer.antennas[name]
ax, ay = tx.getData()
np.testing.assert_allclose([ax[0], ay[0]], np.asarray(expected_antenna)[list(axes)])
view = self.renderer.plots[name].getViewBox()
for marker in self.renderer.antennas[name]:
ax, ay = marker.getData()
for x, y in zip(ax, ay):
point = view.mapViewToScene(QPointF(float(x), float(y)))
self.assertTrue(view.sceneBoundingRect().contains(QRectF(point.x()-7, point.y()-7, 14, 14)), name)
rect = self.renderer.plots[name].getViewBox().targetRect()
baseline[name] = np.array([[rect.left(), rect.right()], [rect.top(), rect.bottom()]])
self.assertNotIn("sar3d_oblique", self.renderer.antennas)
self.settings.zoom_percent = 250
self.renderer.draw(result, self.settings, self.geometry)
self.app.processEvents()
for name, ranges in baseline.items():
rect = self.renderer.plots[name].getViewBox().targetRect()
zoomed = np.array([[rect.left(), rect.right()], [rect.top(), rect.bottom()]])
np.testing.assert_allclose(np.ptp(zoomed, axis=1), np.ptp(ranges, axis=1)/2.5)
xx, yy = self.renderer.targets[name].getData()
assert xx is not None and yy is not None
peak = np.array([.5*(xx.min()+xx.max()), .5*(yy.min()+yy.max())])
self.assertLess(np.linalg.norm(zoomed.mean(axis=1)-peak), np.linalg.norm(ranges.mean(axis=1)-peak))
def test_cloud_threshold_masks_weak_response_without_changing_levels(self) -> None:
result = _projections()
for payload in result.collection_payloads:
if payload.kind == 3:
payload.image.flat[:2] = [.2, .8]
for threshold, expected in ((.65, [0, .8, 1]), (1., [0, 0, 1])):
self.settings.cloud_threshold = threshold
self.renderer.draw(result, self.settings, self.geometry)
for image in self.renderer.images.values():
np.testing.assert_allclose(np.asarray(image.image).flat[:3], expected)
np.testing.assert_allclose(np.asarray(image.levels), [0, 1])
class Sar3dIntegrationTest(unittest.TestCase):
@@ -100,6 +159,30 @@ class Sar3dIntegrationTest(unittest.TestCase):
self.view._plots.required_revision = None
self.renderer = self.view._plots.sar3d
def test_display_controls_redraw_without_processing_revision_or_live_fields(self) -> None:
result = _projections()
self.view._history.record_result(result)
revision = self.view._live.revision
widget(self.view._view.processing, "_sar3d_zoom_percent", QSlider).setValue(247)
widget(self.view._view.processing, "_sar3d_cloud_threshold", QDoubleSpinBox).setValue(1.)
self.assertEqual(self.view._live.revision, revision)
self.assertIn("zoom 247%", self.renderer.status_label.text())
live = self.view._live.build().to_dict()
self.assertNotIn("sar3d_zoom_percent", live)
self.assertNotIn("sar3d_cloud_threshold", live)
schema = {entry["name"]: entry for entry in self.view._config.web_schema()}
self.assertEqual(schema["sar3d_zoom_percent"]["value"], 247)
self.assertEqual(schema["sar3d_zoom_percent"]["step"], 1)
self.assertEqual(schema["sar3d_cloud_threshold"]["value"], 1.)
def test_inactive_sar_scene_is_validated_before_publishing_gpr_settings(self) -> None:
with QSignalBlocker(self.mode):
self.mode.setCurrentText("gpr")
with QSignalBlocker(widget(self.view._view.processing, "_sar3d_y_min_m", QDoubleSpinBox)):
widget(self.view._view.processing, "_sar3d_y_min_m", QDoubleSpinBox).setValue(3.)
with self.assertRaisesRegex(ValueError, "SAR_Y_RANGE_M"):
self.view._config.build_live_config()
def test_capture_preview_keeps_sar_page_and_uses_saved_trace_settings(self) -> None:
self.view._plots.set_mode("sar3d")
fields = self.view._view.processing
@@ -2,7 +2,7 @@
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
GPR-like page (gpr, sar3d) has its own checkbox and field, and the
field is editable only while it is the one in force.
"""
@@ -31,7 +31,7 @@ class SocketSpeedControlsTest(unittest.TestCase):
def test_socket_authority_controls_editability_and_published_speed(self) -> None:
assert _window is not None
section = _window._view.processing
for mode, (checkbox, speed) in zip(("gpr", "legacy_gpr", "sar3d"), section.speed_controls, strict=True):
for mode, (checkbox, speed) in zip(("gpr", "sar3d"), section.speed_controls, strict=True):
section.mode.setCurrentText(mode)
speed.setValue(1.25)
for ignore in (True, False):
+1 -1
View File
@@ -218,7 +218,7 @@ function makeFieldRow(entry) {
el.value = String(entry.value);
} else if (entry.kind === "int" || entry.kind === "float") {
el = document.createElement("input");
el.type = "number";
el.type = entry.slider ? "range" : "number";
if (entry.min != null) el.min = entry.min;
if (entry.max != null) el.max = entry.max;
el.step = entry.kind === "float" ? (entry.step || "any") : (entry.step || 1);
-1
View File
@@ -184,7 +184,6 @@
}
},
"gpr": {
"relative_permittivity": 1.0,
"tx_geometry": [
{
"output_pos": 0,
@@ -142,7 +142,6 @@
}
},
"gpr": {
"relative_permittivity": 1.0,
"tx_geometry": [
{
"output_pos": 0,
@@ -145,7 +145,6 @@
}
},
"gpr": {
"relative_permittivity": 1.0,
"tx_geometry": [
{
"output_pos": 0,
@@ -145,7 +145,6 @@
}
},
"gpr": {
"relative_permittivity": 1.0,
"tx_geometry": [
{
"output_pos": 0,
@@ -188,7 +188,6 @@
}
},
"gpr": {
"relative_permittivity": 1.0,
"tx_geometry": [
{
"output_pos": 0,
@@ -326,31 +325,6 @@
"visible_z_min_m": 0.0,
"visible_z_max_m": 8.0
},
"legacy_gpr": {
"mode": "point",
"input_positions": "0,1,2,3",
"output_positions": "0,1,2,3",
"min_depth_m": 2.0,
"max_depth_m": 14.0,
"comp_power": 0.2,
"start_freq_mhz": 3000.0,
"stop_freq_mhz": 6000.0,
"speed_m_s": 0.0,
"ignore_socket_speed_enabled": false,
"look_angle_deg": 0.0,
"apply_freq_phase_correction": true,
"reference_mode": "frame_center",
"snr_thresh": 4.5,
"snr_comp_max": 25.0,
"background_subtract_enabled": true,
"background_mean_count": 10,
"render_mode": "heatmap",
"min_visible_pair_count": 1,
"visible_x_min_m": -2.91,
"visible_x_max_m": 2.91,
"visible_z_min_m": 0.0,
"visible_z_max_m": 14.0
},
"sar3d": {
"input_positions": "",
"output_positions": "",
@@ -184,7 +184,6 @@
}
},
"gpr": {
"relative_permittivity": 1.0,
"tx_geometry": [
{
"output_pos": 0,
@@ -175,7 +175,6 @@
}
},
"gpr": {
"relative_permittivity": 1.0,
"tx_geometry": [
{
"output_pos": 0,
@@ -102,7 +102,6 @@
}
},
"gpr": {
"relative_permittivity": 1.0,
"tx_geometry": [
{
"output_pos": 0,
@@ -142,7 +142,6 @@
}
},
"gpr": {
"relative_permittivity": 1.0,
"tx_geometry": [
{
"output_pos": 0,
@@ -265,31 +264,6 @@
"visible_x_max_m": 2.0,
"visible_z_min_m": 0.0,
"visible_z_max_m": 14.0
},
"legacy_gpr": {
"mode": "point",
"input_positions": "0,1,2,3",
"output_positions": "0,1",
"min_depth_m": 2.0,
"max_depth_m": 14.0,
"comp_power": 0.2,
"start_freq_mhz": 3000.0,
"stop_freq_mhz": 6000.0,
"speed_m_s": 0.0,
"ignore_socket_speed_enabled": false,
"look_angle_deg": 0.0,
"apply_freq_phase_correction": true,
"reference_mode": "frame_center",
"snr_thresh": 4.5,
"snr_comp_max": 25.0,
"background_subtract_enabled": true,
"background_mean_count": 10,
"render_mode": "heatmap",
"min_visible_pair_count": 1,
"visible_x_min_m": -2.0,
"visible_x_max_m": 2.0,
"visible_z_min_m": 0.0,
"visible_z_max_m": 14.0
}
},
"data_actions": {
-26
View File
@@ -142,7 +142,6 @@
}
},
"gpr": {
"relative_permittivity": 1.0,
"tx_geometry": [
{
"output_pos": 0,
@@ -265,31 +264,6 @@
"visible_x_max_m": 2.0,
"visible_z_min_m": 0.0,
"visible_z_max_m": 14.0
},
"legacy_gpr": {
"mode": "point",
"input_positions": "0,1,2,3",
"output_positions": "0,1",
"min_depth_m": 2.0,
"max_depth_m": 14.0,
"comp_power": 0.2,
"start_freq_mhz": 3000.0,
"stop_freq_mhz": 6000.0,
"speed_m_s": 0.0,
"ignore_socket_speed_enabled": false,
"look_angle_deg": 0.0,
"apply_freq_phase_correction": true,
"reference_mode": "frame_center",
"snr_thresh": 4.5,
"snr_comp_max": 25.0,
"background_subtract_enabled": true,
"background_mean_count": 10,
"render_mode": "heatmap",
"min_visible_pair_count": 1,
"visible_x_min_m": -2.0,
"visible_x_max_m": 2.0,
"visible_z_min_m": 0.0,
"visible_z_max_m": 14.0
}
},
"data_actions": {