From 42532c98682fd5902360b17d596be9bb86f0f2d5 Mon Sep 17 00:00:00 2001 From: Ayzen Date: Tue, 23 Jun 2026 21:55:40 +0300 Subject: [PATCH] added new filtration and fixed processing parameters --- .../common_cpp/ipc/src/shm_ring.cpp | 6 +- .../include/processing_live_config.hpp | 14 +- .../data_processor/src/data_processor.cpp | 36 ++-- .../src/processing_live_config.cpp | 16 +- .../processors/include/gpr_processor.hpp | 6 + .../include/object_approach_filter.hpp | 172 ++++++++++++++++++ .../src/gpr_backprojection_processor.ipp | 76 ++++++-- .../processors/src/gpr_processor.cpp | 2 +- .../src/sweep_orchestrator.cpp | 4 +- .../live_processing_mixin.py | 11 +- .../app_window_config/profile_io_mixin.py | 8 +- .../app_window_config/state_builders.py | 8 +- .../app_window_plot/gpr_plot_mixin.py | 35 +--- .../sections/processing_section.py | 44 +++-- python_app/models/gui_profile_codec.py | 30 +-- python_app/models/gui_profile_schema.py | 9 +- .../orchestration/live_processing_config.py | 12 +- python_app/tests/test_storage_webui.py | 4 +- run_config.json | 4 +- .../run_config_simulator.example.json | 4 +- run_configs/run_config.json | 4 +- 21 files changed, 365 insertions(+), 140 deletions(-) create mode 100644 data_acq_and_processing/processing/processors/include/object_approach_filter.hpp diff --git a/data_acq_and_processing/common_cpp/ipc/src/shm_ring.cpp b/data_acq_and_processing/common_cpp/ipc/src/shm_ring.cpp index 673eb8b..4d94610 100644 --- a/data_acq_and_processing/common_cpp/ipc/src/shm_ring.cpp +++ b/data_acq_and_processing/common_cpp/ipc/src/shm_ring.cpp @@ -188,7 +188,7 @@ auto ShmRing::open_or_create( const bool geometry_ok = header->capacity == capacity && header->slot_size_bytes == slot_size_bytes; if (magic_ok && version_ok && geometry_ok) { - // Fix #50: the requested mapped_size matched the header geometry, but the + // the requested mapped_size matched the header geometry, but the // backing file may have been created undersized by another process. Confirm // st_size covers the geometry before trusting the mapping. struct stat info {}; @@ -257,7 +257,7 @@ auto ShmRing::open_existing(const std::string& name) -> ShmRing { if (header->version != kRingVersion) { throw std::runtime_error("Shared memory ring version mismatch for " + name); } - // Fix #50: ensure the mapping actually spans every slot the header describes. + // ensure the mapping actually spans every slot the header describes. validate_geometry(*header, mapped_size, name); ShmRing ring{}; @@ -411,7 +411,7 @@ void ShmRing::validate_name(const std::string& name) { } void ShmRing::validate_geometry(const Header& header, std::size_t mapped_size, const std::string& name) { - // Fix #50: derive the expected size from the header's own geometry fields and + // derive the expected size from the header's own geometry fields and // require the real mapping to cover it. A bogus capacity/slot_size or a truncated // mapping would otherwise yield out-of-bounds slot offsets and a SIGSEGV. const std::uint32_t capacity = header.capacity; diff --git a/data_acq_and_processing/processing/data_processor/include/processing_live_config.hpp b/data_acq_and_processing/processing/data_processor/include/processing_live_config.hpp index 8f64985..7d9c73e 100644 --- a/data_acq_and_processing/processing/data_processor/include/processing_live_config.hpp +++ b/data_acq_and_processing/processing/data_processor/include/processing_live_config.hpp @@ -45,8 +45,10 @@ struct ProcessingLiveConfig { float gpr_min_depth_m = 2.0F; float gpr_max_depth_m = 14.0F; float gpr_range_comp_power = 0.1F; - float gpr_angle_comp_power = 0.0F; float gpr_comp_power = 0.2F; + // BP object-detection stop level, as a fraction of the global peak (Python + // Horns_motion_3libre.py BP_OBJECT_MIN_FRAC); peaks below it are not objects. + float gpr_object_min_frac = 0.7F; std::string gpr_score_mode = "combined"; // Backprojection intra-sweep speed-correction mode: "int_minus" (full // correction) or "int_focus" (focusing residual only). Mirrors the Python @@ -73,13 +75,15 @@ struct ProcessingLiveConfig { // 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; - // Locator filter parameters. Mode-dependent threshold (legacy_gpr uses - // `legacy_gpr_min_visible_pair_count`, everything else uses - // `gpr_min_visible_score`). Draw limits apply only to non-legacy modes. - float gpr_min_visible_score = 0.0F; + // Coherent BP object visibility (window + the draw limits below) is applied in + // the processor itself, matching Horns_motion_3libre.py — there is NO score + // threshold for it. Only legacy GPR still thresholds, on a pair count. float legacy_gpr_min_visible_pair_count = 0.0F; 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 + // motion-consistent track over this many consecutive frames (<= 1 disables it). + std::uint32_t gpr_object_approach_min_frames = 3; // Visible X/Z window (metres). The locator clips broadcast objects to this // window so the socket emits only what the desktop plot actually shows. float gpr_visible_x_min_m = -2.0F; diff --git a/data_acq_and_processing/processing/data_processor/src/data_processor.cpp b/data_acq_and_processing/processing/data_processor/src/data_processor.cpp index 23ae05f..e111083 100644 --- a/data_acq_and_processing/processing/data_processor/src/data_processor.cpp +++ b/data_acq_and_processing/processing/data_processor/src/data_processor.cpp @@ -65,7 +65,7 @@ void DataProcessor::run(const std::atomic& stop_requested) { std::uint64_t last_applied_history_command_seq = 0; std::uint64_t error_count = 0; std::uint64_t consecutive_errors = 0; - // Fix #55: track the socket-fed speed used for the last reprocess so a change + // track the socket-fed speed used for the last reprocess so a change // arriving without a live-config revision bump still triggers a reprocess of // the current result (gated below by reprocess_current_result). std::optional last_reprocessed_socket_speed = std::nullopt; @@ -124,7 +124,7 @@ void DataProcessor::run(const std::atomic& stop_requested) { publish_locator(replay_result, live_config); } last_replayed_revision = live_revision; - // Fix #55: record the speed we just reprocessed with so an + //record the speed we just reprocessed with so an // unchanged socket value does not retrigger every iteration. last_reprocessed_socket_speed = current_socket_speed; } @@ -232,29 +232,23 @@ auto DataProcessor::build_locator_filter(const ProcessingLiveConfig& live_config live_config.processor_mode.empty() ? default_processor_mode_ : live_config.processor_mode; radar::locator::FilterParams filter{}; - // Clip broadcast objects to the same visible X/Z window the desktop plot uses, - // so the socket emits only the objects the operator actually sees. - 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, - }; 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; - // The GUI deliberately disables the "draw top N" capping for legacy - // GPR, so we also skip it on the wire to match observation semantics. filter.draw_limits.reset(); - } else { - filter.min_score = live_config.gpr_min_visible_score; - if (live_config.gpr_max_detected_objects_to_draw > 0U - && live_config.gpr_draw_top_m_objects > 0U) { - filter.draw_limits = radar::locator::DrawLimits{ - .max_detected_objects = live_config.gpr_max_detected_objects_to_draw, - .draw_top_objects = live_config.gpr_draw_top_m_objects, - }; - } } + // Coherent BP already emits the FINAL visible object set from the processor + // (window + N/M, no score threshold — matching Horns_motion_3libre.py), 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; } diff --git a/data_acq_and_processing/processing/data_processor/src/processing_live_config.cpp b/data_acq_and_processing/processing/data_processor/src/processing_live_config.cpp index b54e582..90d6d5b 100644 --- a/data_acq_and_processing/processing/data_processor/src/processing_live_config.cpp +++ b/data_acq_and_processing/processing/data_processor/src/processing_live_config.cpp @@ -255,11 +255,11 @@ void apply_legacy_gpr_algorithm_alias(ProcessingLiveConfig& config, const std::s } config.gpr_range_comp_power = static_cast(found->get()); } - if (const auto found = root.find("gpr_angle_comp_power"); found != root.end()) { + if (const auto found = root.find("gpr_object_min_frac"); found != root.end()) { if (!found->is_number()) { - throw std::runtime_error("processing.gpr_angle_comp_power must be number"); + throw std::runtime_error("processing.gpr_object_min_frac must be number"); } - config.gpr_angle_comp_power = static_cast(found->get()); + config.gpr_object_min_frac = static_cast(found->get()); } if (const auto found = root.find("gpr_comp_power"); found != root.end()) { if (!found->is_number()) { @@ -355,12 +355,6 @@ void apply_legacy_gpr_algorithm_alias(ProcessingLiveConfig& config, const std::s } config.gpr_imaging_plane_y_m = static_cast(found->get()); } - if (const auto found = root.find("gpr_min_visible_score"); found != root.end()) { - if (!found->is_number()) { - throw std::runtime_error("processing.gpr_min_visible_score must be number"); - } - config.gpr_min_visible_score = static_cast(found->get()); - } 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}, @@ -388,6 +382,10 @@ void apply_legacy_gpr_algorithm_alias(ProcessingLiveConfig& config, const std::s config.gpr_draw_top_m_objects = parse_u32_number(*found, "processing.gpr_draw_top_m_objects"); } + if (const auto found = root.find("gpr_object_approach_min_frames"); found != root.end()) { + config.gpr_object_approach_min_frames = + parse_u32_number(*found, "processing.gpr_object_approach_min_frames"); + } if (const auto found = root.find("ignore_socket_speed"); found != root.end()) { if (!found->is_boolean()) { throw std::runtime_error("processing.ignore_socket_speed must be bool"); diff --git a/data_acq_and_processing/processing/processors/include/gpr_processor.hpp b/data_acq_and_processing/processing/processors/include/gpr_processor.hpp index dc8a9ed..18567b6 100644 --- a/data_acq_and_processing/processing/processors/include/gpr_processor.hpp +++ b/data_acq_and_processing/processing/processors/include/gpr_processor.hpp @@ -1,5 +1,6 @@ #pragma once +#include "object_approach_filter.hpp" #include "processor_interface.hpp" namespace radar::processing { @@ -13,6 +14,11 @@ class GprProcessor final : public ProcessorInterface { std::span previous_collections, const ProcessingLiveConfig& live_config ) -> ipc::ResultCollection override; + + private: + // Cross-frame "approach" track filter. Persists across collections because the + // owning processor instance is long-lived (one per data_processor run). + ObjectApproachFilter approach_filter_{}; }; class LegacyGprProcessor final : public ProcessorInterface { diff --git a/data_acq_and_processing/processing/processors/include/object_approach_filter.hpp b/data_acq_and_processing/processing/processors/include/object_approach_filter.hpp new file mode 100644 index 0000000..7495def --- /dev/null +++ b/data_acq_and_processing/processing/processors/include/object_approach_filter.hpp @@ -0,0 +1,172 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +namespace radar::processing { + +// Temporal "approach" filter for coherent-BP detections — a port of the objects-only +// filter in Horns_motion_3libre's demonstrate notebook. +// +// It keeps only objects that persist as a *motion-consistent track* across at least +// `min_frames` consecutive frames: as the radar moves, a real target reappears at a +// predictable, shifting (x, z), whereas a one-frame noise spike forms no track and is +// dropped. The expected per-frame change in range is `speed * dt * cos(look_angle)`, +// where `dt` is the real interval between consecutive frames (so dropped frames and a +// varying frame period are handled naturally). +// +// CAUSAL: unlike the offline notebook (which can look forward over the whole sequence), +// this confirms a track by looking *backward* — an object is kept once it ends a track +// of `min_frames` frames seen so far. A target therefore first appears after it has +// persisted `min_frames` frames; the early frames of its track are not shown +// retroactively. +// +// Stateless across pipeline restarts is approximated by breaking tracks across a large +// inter-frame gap (`kMaxFrameGapSeconds`), so a stale history from a previous run cannot +// spuriously confirm objects. +// +// IDEMPOTENT under reprocessing: the data_processor re-runs the last collection (or +// replays the whole window) whenever live settings or the socket speed change, with no +// new sweep. The history is therefore keyed by `frame_id` (the strictly increasing +// collection id): the same id replaces its entry (reprocess of the current frame), a +// smaller id rebuilds from scratch (a replay restart), so repeated reprocessing never +// duplicates frames or falsely confirms a track. +class ObjectApproachFilter { + public: + struct Point { + double x_m; + double z_m; + }; + + // Record `objects` as frame `frame_id` and return, per object, whether it is + // confirmed (ends a >= `min_frames` motion-consistent track). `frame_id` is the + // collection id (identity/order, survives reprocessing); `frame_time_seconds` is the + // frame's wall-clock timestamp (drives the inter-frame interval); `speed_m_s`/ + // `look_angle_deg` are the live motion estimate. `min_frames <= 1` disables filtering. + [[nodiscard]] auto confirm( + const std::vector& objects, + std::uint64_t frame_id, + double frame_time_seconds, + double speed_m_s, + double look_angle_deg, + std::size_t min_frames + ) -> std::vector { + record_frame(Frame{frame_id, frame_time_seconds, objects}); + const std::size_t history_depth = std::max(min_frames, 1U); + while (history_.size() > history_depth) { + history_.pop_front(); + } + + std::vector confirmed(objects.size(), min_frames <= 1U); + if (min_frames <= 1U || history_.size() < min_frames) { + return confirmed; // disabled, or not enough history yet to confirm anything + } + + const double range_step_per_second = std::abs(speed_m_s) * std::cos(to_radians(look_angle_deg)); + for (std::size_t object_index = 0U; object_index < objects.size(); ++object_index) { + confirmed[object_index] = has_backward_track(objects[object_index], min_frames, range_step_per_second); + } + return confirmed; + } + + void reset() { history_.clear(); } + + private: + struct Frame { + std::uint64_t id; + double time_seconds; + std::vector objects; + }; + + // Append a genuinely new frame, replace the current one on reprocessing (same id), + // or rebuild from scratch when the id steps backward (a replay restart). This keeps + // the history one entry per distinct collection no matter how often settings change. + void record_frame(Frame frame) { + if (history_.empty() || frame.id > history_.back().id) { + history_.push_back(std::move(frame)); + } else if (frame.id == history_.back().id) { + history_.back() = std::move(frame); + } else { + history_.clear(); + history_.push_back(std::move(frame)); + } + } + + // Fixed matching tolerances (Horns_motion notebook 0.2 block). Range decreases as the + // radar approaches the target, hence the negative Z sign. + static constexpr double kXToleranceM = 0.45; + static constexpr double kRangeToleranceFraction = 0.85; + static constexpr double kRangeToleranceFloorM = 0.12; + static constexpr double kRangeSign = -1.0; + static constexpr double kMaxFrameGapSeconds = 2.0; + + [[nodiscard]] static auto to_radians(double degrees) -> double { + return degrees * (M_PI / 180.0); + } + + // Walk back from the current object through the history, matching a motion-consistent + // predecessor in each earlier frame. Confirmed iff a full chain of `min_frames` frames + // (the current one plus `min_frames - 1` predecessors) is found. + [[nodiscard]] auto has_backward_track( + const Point& object, + std::size_t min_frames, + double range_step_per_second + ) const -> bool { + const std::size_t newest = history_.size() - 1U; + Point current = object; + for (std::size_t step = 1U; step < min_frames; ++step) { + const std::size_t earlier_index = newest - step; + const Frame& earlier = history_[earlier_index]; + const double dt = history_[earlier_index + 1U].time_seconds - earlier.time_seconds; + if (!(dt > 0.0) || dt > kMaxFrameGapSeconds) { + return false; // non-monotonic time, or a gap that breaks the track + } + const double expected_range_shift = range_step_per_second * dt; + const Point* predecessor = match_predecessor(current, earlier.objects, expected_range_shift); + if (predecessor == nullptr) { + return false; + } + current = *predecessor; + } + return true; + } + + // The best earlier-frame object consistent with `object` having moved by one frame: + // its range was larger by `expected_range_shift` (radar since approached), within the + // cross-range and range tolerances. Returns nullptr when nothing matches. + [[nodiscard]] static auto match_predecessor( + const Point& object, + const std::vector& candidates, + double expected_range_shift + ) -> const Point* { + const double target_z = object.z_m - (kRangeSign * expected_range_shift); + const double range_tolerance = + std::max(kRangeToleranceFloorM, kRangeToleranceFraction * expected_range_shift); + + const Point* best = nullptr; + double best_cost = std::numeric_limits::infinity(); + for (const Point& candidate : candidates) { + const double dx = std::abs(candidate.x_m - object.x_m); + const double dz = std::abs(candidate.z_m - target_z); + if (dx > kXToleranceM || dz > range_tolerance) { + continue; + } + const double cost = (dx / kXToleranceM) * (dx / kXToleranceM) + + (dz / range_tolerance) * (dz / range_tolerance); + if (cost < best_cost) { + best = &candidate; + best_cost = cost; + } + } + return best; + } + + std::deque history_{}; // recent frames, newest at the back; capped to min_frames +}; + +} // namespace radar::processing diff --git a/data_acq_and_processing/processing/processors/src/gpr_backprojection_processor.ipp b/data_acq_and_processing/processing/processors/src/gpr_backprojection_processor.ipp index 2c98186..868f63f 100644 --- a/data_acq_and_processing/processing/processors/src/gpr_backprojection_processor.ipp +++ b/data_acq_and_processing/processing/processors/src/gpr_backprojection_processor.ipp @@ -21,7 +21,6 @@ constexpr double kSmoothSigma = 1.5; // same default 'reflect' (half-sample symmetric) extension — see reflect_index. constexpr double kGaussianTruncate = 4.0; constexpr std::size_t kMaxObjects = 10U; -constexpr double kObjectMinFrac = 0.7; constexpr double kRegionThresholdFrac = 0.75; constexpr double kSuppressThresholdFrac = 0.20; constexpr double kSuppressRadiusXM = 0.80; @@ -1405,7 +1404,8 @@ void apply_depth_gate( [[nodiscard]] auto find_bp_objects( const std::vector& bp_image, - const GridDefinition& grid + const GridDefinition& grid, + double min_frac ) -> std::vector { std::vector objects{}; if (bp_image.empty() || grid.x_grid.size() < 2U || grid.z_grid.size() < 2U) { @@ -1414,7 +1414,7 @@ void apply_depth_gate( std::vector work = bp_image; const double global_peak = max_value(work); - const double stop_level = kObjectMinFrac * global_peak; + const double stop_level = min_frac * global_peak; if (!(global_peak > 0.0)) { return objects; } @@ -1740,7 +1740,15 @@ void add_bp_score_metrics( } } -[[nodiscard]] auto output_objects_sorted( +// Select the FINAL visible objects exactly as Horns_motion_3libre.py does, so the +// processor is the single source of truth: the GUI plot and the locator socket both +// consume this set verbatim (no second, duplicated filter). Steps, in order: +// 1. drop sidelobe candidates (when enabled), then sort by score (peak tie-break); +// 2. clip to the visible X/Z window (display window doubles as an object gate); +// 3. apply the N/M draw rule (BP_MAX_DETECTED_OBJECTS_TO_DRAW / BP_DRAW_TOP_M_OBJECTS): +// if more than N survive, show none; otherwise keep the top M. +// There is deliberately NO score threshold (the Python reference has none). +[[nodiscard]] auto select_visible_objects( const std::vector& objects, const ProcessingLiveConfig& live_config ) -> std::vector { @@ -1750,6 +1758,12 @@ void add_bp_score_metrics( if (live_config.gpr_remove_sidelobe_objects_enabled && object.sidelobe_candidate) { continue; } + if (object.x_m < live_config.gpr_visible_x_min_m + || object.x_m > live_config.gpr_visible_x_max_m + || object.z_m < live_config.gpr_visible_z_min_m + || object.z_m > live_config.gpr_visible_z_max_m) { + continue; + } visible.push_back(&object); } @@ -1759,6 +1773,17 @@ void add_bp_score_metrics( } return left->selected_score > right->selected_score; }); + + // N/M draw rule (0 on either disables limiting, mirroring the GUI/locator default). + const auto max_detected = live_config.gpr_max_detected_objects_to_draw; + const auto draw_top = live_config.gpr_draw_top_m_objects; + if (max_detected > 0U && draw_top > 0U) { + if (visible.size() > max_detected) { + visible.clear(); + } else if (visible.size() > draw_top) { + visible.resize(draw_top); + } + } return visible; } @@ -1815,7 +1840,8 @@ void add_bp_score_metrics( const config::RunConfig& run_config, const ipc::PreprocessedCollection& collection, std::span previous_collections, - const ProcessingLiveConfig& live_config + const ProcessingLiveConfig& live_config, + ObjectApproachFilter& approach_filter ) -> ipc::ResultCollection { ipc::ResultCollection results{}; results.collection_id = collection.collection_id; @@ -1833,8 +1859,10 @@ void add_bp_score_metrics( return results; } - const double velocity_mps = - kSpeedOfLightMetersPerSec / std::sqrt(std::max(1e-6, static_cast(run_config.gpr.relative_permittivity))); + // Coherent BP fixes the medium to vacuum/air (eps_r = 1), matching the Python + // reference Horns_motion_3libre.py (its 0.3 block hardcodes eps_r = 1.0). The + // configurable relative_permittivity stays a legacy-GPR-only knob. + const double velocity_mps = kSpeedOfLightMetersPerSec; const double start_hz = static_cast(live_config.gpr_start_freq_mhz) * 1'000'000.0; const double stop_hz = static_cast(live_config.gpr_stop_freq_mhz) * 1'000'000.0; const double min_depth_m = static_cast(live_config.gpr_min_depth_m); @@ -1920,7 +1948,7 @@ void add_bp_score_metrics( min_depth_m, max_depth_m, std::max(0.0, static_cast(live_config.gpr_range_comp_power)), - std::max(0.0, static_cast(live_config.gpr_angle_comp_power)) + 0.0 // angle compensation fixed off (Python Horns_motion_3libre.py COMP_ANGLE_POWER = 0.0) ); if (bp.image.empty()) { return results; @@ -1930,7 +1958,7 @@ void add_bp_score_metrics( const auto incoherent_display_map = normalize_bp_map(bp.incoherent, grid, min_depth_m, max_depth_m, kSmoothSigma); - auto objects = find_bp_objects(display_map, grid); + auto objects = find_bp_objects(display_map, grid, static_cast(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); @@ -1951,14 +1979,34 @@ void add_bp_score_metrics( results.collection_payloads.push_back(build_image_payload("gpr_accumulator", grid.x_grid, grid.z_grid, display_map)); + // Final visible set (window + N/M), then the cross-frame approach filter: keep only + // objects confirmed as a >= min_frames motion-consistent track (Horns_motion notebook). + const auto visible = select_visible_objects(objects, live_config); + std::vector visible_points{}; + visible_points.reserve(visible.size()); + for (const auto* object : visible) { + visible_points.push_back({object->x_m, object->z_m}); + } + const auto confirmed = approach_filter.confirm( + visible_points, + collection.collection_id, + static_cast(collection.monotonic_ns) * 1e-9, + static_cast(live_config.gpr_speed_m_s), + static_cast(live_config.gpr_look_angle_deg), + static_cast(live_config.gpr_object_approach_min_frames) + ); + std::vector> point_rows{}; - point_rows.reserve(objects.size()); - for (const auto* object : output_objects_sorted(objects, live_config)) { + point_rows.reserve(visible.size()); + for (std::size_t index = 0U; index < visible.size(); ++index) { + if (!confirmed[index]) { + continue; + } point_rows.push_back( { - static_cast(object->x_m), - static_cast(object->z_m), - static_cast(object->selected_score), + static_cast(visible[index]->x_m), + static_cast(visible[index]->z_m), + static_cast(visible[index]->selected_score), } ); } diff --git a/data_acq_and_processing/processing/processors/src/gpr_processor.cpp b/data_acq_and_processing/processing/processors/src/gpr_processor.cpp index 3374a0c..43172ce 100644 --- a/data_acq_and_processing/processing/processors/src/gpr_processor.cpp +++ b/data_acq_and_processing/processing/processors/src/gpr_processor.cpp @@ -36,7 +36,7 @@ auto GprProcessor::process_collection( std::span previous_collections, const ProcessingLiveConfig& live_config ) -> ipc::ResultCollection { - return process_backprojection_gpr(run_config, collection, previous_collections, live_config); + return process_backprojection_gpr(run_config, collection, previous_collections, live_config, approach_filter_); } auto LegacyGprProcessor::name() const -> std::string { diff --git a/data_acq_and_processing/sweep_orchestrator/src/sweep_orchestrator.cpp b/data_acq_and_processing/sweep_orchestrator/src/sweep_orchestrator.cpp index d03ff72..8a44b3c 100644 --- a/data_acq_and_processing/sweep_orchestrator/src/sweep_orchestrator.cpp +++ b/data_acq_and_processing/sweep_orchestrator/src/sweep_orchestrator.cpp @@ -207,7 +207,7 @@ SweepOrchestrator::SweepOrchestrator( void SweepOrchestrator::run(const std::atomic& stop_requested) { // Fail fast on a config error: a slot that is too small for the worst-case payload can // never carry a full collection, so report it clearly at startup instead of dropping - // every collection at runtime (fix #27). + // every collection at runtime. const auto worst_case_bytes = worst_case_serialized_bytes(config_.run_combos.size(), config_.radar.sweep.points); if (worst_case_bytes > raw_ring_.slot_size_bytes()) { throw std::runtime_error( @@ -219,7 +219,7 @@ void SweepOrchestrator::run(const std::atomic& stop_requested) { } DriverLifecycleGuard lifecycle_guard(radar_driver_, input_switch_driver_, output_switch_driver_); - // Wait for the devices to become available before starting (fix #8/#9): an absent device + // Wait for the devices to become available before starting: an absent device // makes the orchestrator wait, not exit. if (!lifecycle_guard.open_all_with_retry(stop_requested)) { return; // stop requested before any device became available diff --git a/python_app/gui/controllers/app_window_config/live_processing_mixin.py b/python_app/gui/controllers/app_window_config/live_processing_mixin.py index 2d4c3b3..3bdafa6 100644 --- a/python_app/gui/controllers/app_window_config/live_processing_mixin.py +++ b/python_app/gui/controllers/app_window_config/live_processing_mixin.py @@ -58,14 +58,14 @@ _WEB_LIVE_SCHEMA = [ ("gpr_stop_freq_mhz", "Geometry & depth", _GPR_MODES, _dual("_gpr_stop_freq_mhz", "_legacy_gpr_stop_freq_mhz")), ("gpr_imaging_plane_y_m", "Geometry & depth", ("gpr",), _attr("_gpr_imaging_plane_y_m")), ("gpr_range_comp_power", "Imaging", ("gpr",), _attr("_gpr_range_comp_power")), - ("gpr_angle_comp_power", "Imaging", ("gpr",), _attr("_gpr_angle_comp_power")), ("gpr_score_mode", "Imaging", ("gpr",), _attr("_gpr_score_mode")), ("gpr_background_subtract_enabled", "Imaging", _GPR_MODES, _dual("_gpr_background_subtract_enabled", "_legacy_gpr_background_subtract_enabled")), ("gpr_background_mean_count", "Imaging", _GPR_MODES, _dual("_gpr_background_mean_count", "_legacy_gpr_background_mean_count")), ("gpr_remove_sidelobe_objects_enabled", "Imaging", ("gpr",), _attr("_gpr_remove_sidelobe_objects_enabled")), - ("gpr_min_visible_score", "Detection", ("gpr",), _attr("_gpr_min_visible_score")), + ("gpr_object_min_frac", "Detection", ("gpr",), _attr("_gpr_object_min_frac")), ("gpr_max_detected_objects_to_draw", "Detection", ("gpr",), _attr("_gpr_max_detected_objects_to_draw")), ("gpr_draw_top_m_objects", "Detection", ("gpr",), _attr("_gpr_draw_top_m_objects")), + ("gpr_object_approach_min_frames", "Detection", ("gpr",), _attr("_gpr_object_approach_min_frames")), ("gpr_comp_power", "Detection", ("legacy_gpr",), _attr("_legacy_gpr_comp_power")), ("gpr_snr_thresh", "Detection", ("legacy_gpr",), _attr("_legacy_gpr_snr_thresh")), ("gpr_snr_comp_max", "Detection", ("legacy_gpr",), _attr("_legacy_gpr_snr_comp_max")), @@ -110,7 +110,9 @@ _WEB_DISPLAY_SCHEMA = [ # take effect only when the pipeline (re)starts — not hot-reloaded — so the web marks them # "applies on Start" and editing them just updates the widget for the next start. _WEB_STABLE_SCHEMA = [ - ("relative_permittivity", "Geometry & medium", _GPR_MODES, _attr("_gpr_relative_permittivity")), + # Coherent BP fixes the medium to eps_r = 1 (Horns_motion_3libre.py), so relative + # permittivity is a legacy-GPR-only knob; BP ignores it. + ("relative_permittivity", "Geometry & medium", ("legacy_gpr",), _attr("_gpr_relative_permittivity")), ("tx_geometry", "Geometry & medium", _GPR_MODES, _attr("_gpr_tx_geometry_input")), ("rx_geometry", "Geometry & medium", _GPR_MODES, _attr("_gpr_rx_geometry_input")), ] @@ -475,14 +477,13 @@ class AppWindowLiveProcessingMixin: f"depth={self._gpr_min_depth_m.value():g}..{self._gpr_max_depth_m.value():g} m, " f"freq={self._gpr_start_freq_mhz.value():g}..{self._gpr_stop_freq_mhz.value():g} MHz, " f"range_comp={self._gpr_range_comp_power.value():g}, " - f"angle_comp={self._gpr_angle_comp_power.value():g}, " + f"object_min_frac={self._gpr_object_min_frac.value():g}, " f"score_mode={self._gpr_score_mode.currentText()}, " f"background_subtract={self._gpr_background_subtract_enabled.isChecked()}, " f"mean_count={self._gpr_background_mean_count.value()}, " f"remove_sidelobes={self._gpr_remove_sidelobe_objects_enabled.isChecked()}, " f"imaging_plane_y={self._gpr_imaging_plane_y_m.value():g} m, " f"render_mode={self._gpr_render_mode.currentText()}, " - f"min_score={self._gpr_min_visible_score.value():g}, " f"max_draw={self._gpr_max_detected_objects_to_draw.value()}, " f"draw_top={self._gpr_draw_top_m_objects.value()})" ) diff --git a/python_app/gui/controllers/app_window_config/profile_io_mixin.py b/python_app/gui/controllers/app_window_config/profile_io_mixin.py index bfdd418..ac08b9e 100644 --- a/python_app/gui/controllers/app_window_config/profile_io_mixin.py +++ b/python_app/gui/controllers/app_window_config/profile_io_mixin.py @@ -291,7 +291,7 @@ class AppWindowConfigProfileIOMixin: self._gpr_min_depth_m, self._gpr_max_depth_m, self._gpr_range_comp_power, - self._gpr_angle_comp_power, + self._gpr_object_min_frac, self._gpr_score_mode, self._gpr_motion_mode, self._gpr_look_angle_deg, @@ -300,6 +300,7 @@ class AppWindowConfigProfileIOMixin: self._gpr_speed_m_s, self._gpr_max_detected_objects_to_draw, self._gpr_draw_top_m_objects, + self._gpr_object_approach_min_frames, self._gpr_start_freq_mhz, self._gpr_stop_freq_mhz, self._gpr_background_subtract_enabled, @@ -307,7 +308,6 @@ class AppWindowConfigProfileIOMixin: self._gpr_remove_sidelobe_objects_enabled, self._gpr_imaging_plane_y_m, self._gpr_render_mode, - self._gpr_min_visible_score, self._gpr_visible_x_min_m, self._gpr_visible_x_max_m, self._gpr_visible_z_min_m, @@ -457,7 +457,7 @@ class AppWindowConfigProfileIOMixin: self._gpr_min_depth_m.setValue(float(gui_state.processing.gpr.min_depth_m)) self._gpr_max_depth_m.setValue(float(gui_state.processing.gpr.max_depth_m)) self._gpr_range_comp_power.setValue(float(gui_state.processing.gpr.range_comp_power)) - self._gpr_angle_comp_power.setValue(float(gui_state.processing.gpr.angle_comp_power)) + self._gpr_object_min_frac.setValue(float(gui_state.processing.gpr.object_min_frac)) self._set_combo_current_text(self._gpr_score_mode, gui_state.processing.gpr.score_mode) self._set_combo_current_text(self._gpr_motion_mode, gui_state.processing.gpr.motion_mode) self._gpr_look_angle_deg.setValue(float(gui_state.processing.gpr.look_angle_deg)) @@ -470,6 +470,7 @@ class AppWindowConfigProfileIOMixin: int(gui_state.processing.gpr.max_detected_objects_to_draw) ) self._gpr_draw_top_m_objects.setValue(int(gui_state.processing.gpr.draw_top_m_objects)) + self._gpr_object_approach_min_frames.setValue(int(gui_state.processing.gpr.object_approach_min_frames)) self._gpr_start_freq_mhz.setValue(float(gui_state.processing.gpr.start_freq_mhz)) self._gpr_stop_freq_mhz.setValue(float(gui_state.processing.gpr.stop_freq_mhz)) self._gpr_background_subtract_enabled.setChecked( @@ -481,7 +482,6 @@ class AppWindowConfigProfileIOMixin: ) self._gpr_imaging_plane_y_m.setValue(float(gui_state.processing.gpr.imaging_plane_y_m)) self._set_combo_current_text(self._gpr_render_mode, gui_state.processing.gpr.render_mode) - self._gpr_min_visible_score.setValue(float(gui_state.processing.gpr.min_visible_score)) self._gpr_visible_x_min_m.setValue(float(gui_state.processing.gpr.visible_x_min_m)) self._gpr_visible_x_max_m.setValue(float(gui_state.processing.gpr.visible_x_max_m)) self._gpr_visible_z_min_m.setValue(float(gui_state.processing.gpr.visible_z_min_m)) diff --git a/python_app/gui/controllers/app_window_config/state_builders.py b/python_app/gui/controllers/app_window_config/state_builders.py index 8a0776d..08286df 100644 --- a/python_app/gui/controllers/app_window_config/state_builders.py +++ b/python_app/gui/controllers/app_window_config/state_builders.py @@ -224,7 +224,7 @@ class AppWindowConfigStateBuildersMixin: min_depth_m=2.0, max_depth_m=14.0, range_comp_power=0.1, - angle_comp_power=0.0, + object_min_frac=0.7, score_mode="combined", motion_mode="int_minus", look_angle_deg=0.0, @@ -233,6 +233,7 @@ class AppWindowConfigStateBuildersMixin: ignore_socket_speed_enabled=False, max_detected_objects_to_draw=5, draw_top_m_objects=2, + object_approach_min_frames=3, start_freq_mhz=3000.0, stop_freq_mhz=6000.0, background_subtract_enabled=True, @@ -240,7 +241,6 @@ class AppWindowConfigStateBuildersMixin: remove_sidelobe_objects_enabled=True, imaging_plane_y_m=0.0, render_mode="heatmap", - min_visible_score=0.0, visible_x_min_m=default_gpr_x_min_m, visible_x_max_m=default_gpr_x_max_m, visible_z_min_m=0.0, @@ -351,7 +351,7 @@ class AppWindowConfigStateBuildersMixin: min_depth_m=float(self._gpr_min_depth_m.value()), max_depth_m=float(self._gpr_max_depth_m.value()), range_comp_power=float(self._gpr_range_comp_power.value()), - angle_comp_power=float(self._gpr_angle_comp_power.value()), + object_min_frac=float(self._gpr_object_min_frac.value()), score_mode=self._gpr_score_mode.currentText(), motion_mode=self._gpr_motion_mode.currentText(), look_angle_deg=float(self._gpr_look_angle_deg.value()), @@ -360,6 +360,7 @@ class AppWindowConfigStateBuildersMixin: ignore_socket_speed_enabled=bool(self._gpr_ignore_socket_speed_enabled.isChecked()), max_detected_objects_to_draw=int(self._gpr_max_detected_objects_to_draw.value()), draw_top_m_objects=int(self._gpr_draw_top_m_objects.value()), + object_approach_min_frames=int(self._gpr_object_approach_min_frames.value()), start_freq_mhz=float(self._gpr_start_freq_mhz.value()), stop_freq_mhz=float(self._gpr_stop_freq_mhz.value()), background_subtract_enabled=bool(self._gpr_background_subtract_enabled.isChecked()), @@ -367,7 +368,6 @@ class AppWindowConfigStateBuildersMixin: remove_sidelobe_objects_enabled=bool(self._gpr_remove_sidelobe_objects_enabled.isChecked()), imaging_plane_y_m=float(self._gpr_imaging_plane_y_m.value()), render_mode=self._gpr_render_mode.currentText(), - min_visible_score=float(self._gpr_min_visible_score.value()), visible_x_min_m=float(self._gpr_visible_x_min_m.value()), visible_x_max_m=float(self._gpr_visible_x_max_m.value()), visible_z_min_m=float(self._gpr_visible_z_min_m.value()), diff --git a/python_app/gui/controllers/app_window_plot/gpr_plot_mixin.py b/python_app/gui/controllers/app_window_plot/gpr_plot_mixin.py index 4115c1b..6947feb 100644 --- a/python_app/gui/controllers/app_window_plot/gpr_plot_mixin.py +++ b/python_app/gui/controllers/app_window_plot/gpr_plot_mixin.py @@ -8,7 +8,6 @@ import pyqtgraph as pg from python_app.models.dataset_model import ResultCollection from python_app.orchestration.gpr_locator import ( - apply_object_draw_limits as gpr_apply_object_draw_limits, collection_payload_by_name as gpr_collection_payload_by_name, collection_payloads_by_prefix as gpr_collection_payloads_by_prefix, filter_object_rows as gpr_filter_object_rows, @@ -371,26 +370,6 @@ class AppWindowGprPlotMixin: return self._legacy_gpr_render_mode.currentText() return self._gpr_render_mode.currentText() - def _gpr_locator_threshold(self) -> float: - """Return object threshold using the active GPR mode's score semantics.""" - if self._processing_mode.currentText() == "legacy_gpr": - return float(self._legacy_gpr_min_visible_pair_count.value()) - return float(self._gpr_min_visible_score.value()) - - def _gpr_draw_limits(self) -> tuple[int, int] | None: - """Return GPR object draw limits, or None for legacy GPR.""" - if self._processing_mode.currentText() == "legacy_gpr": - return None - return ( - int(self._gpr_max_detected_objects_to_draw.value()), - int(self._gpr_draw_top_m_objects.value()), - ) - - @staticmethod - def _apply_object_draw_limits(rows: np.ndarray, limits: tuple[int, int] | None) -> np.ndarray: - """Apply object count/top-M drawing rules to already-filtered rows.""" - return gpr_apply_object_draw_limits(rows, limits) - @staticmethod def _gpr_display_y_min(z_min: float, z_max: float) -> float: """Return lower display bound, preserving surface markers only when surface is visible.""" @@ -506,18 +485,24 @@ class AppWindowGprPlotMixin: return extract_gpr_object_rows(collection) def _filtered_gpr_object_rows(self, collection: ResultCollection) -> np.ndarray: - """Return object rows filtered by threshold, visible X/Z bounds, and active GPR draw limits.""" + """Return the object rows to draw for the active GPR mode. + + Coherent BP is already finalized by the processor (visible window + N/M draw + limits, no score threshold — exactly Horns_motion_3libre.py), so its rows are + drawn verbatim. Legacy GPR is still filtered here by its pair-count threshold + and the visible window. + """ rows = self._gpr_object_rows(collection) - if rows.size == 0: + if rows.size == 0 or self._processing_mode.currentText() != "legacy_gpr": return rows x_min, x_max, z_min, z_max = self._gpr_visible_object_bounds() return gpr_filter_object_rows( rows, - min_score=self._gpr_locator_threshold(), + min_score=float(self._legacy_gpr_min_visible_pair_count.value()), x_bounds=(x_min, x_max), z_bounds=(z_min, z_max), - draw_limits=self._gpr_draw_limits(), + draw_limits=None, ) def _draw_gpr_objects_only(self, collection: ResultCollection) -> bool: diff --git a/python_app/gui/controllers/sections/processing_section.py b/python_app/gui/controllers/sections/processing_section.py index 8e96f34..f231475 100644 --- a/python_app/gui/controllers/sections/processing_section.py +++ b/python_app/gui/controllers/sections/processing_section.py @@ -177,10 +177,14 @@ def build_processing_group(owner) -> QGroupBox: gpr_defaults = owner._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 (Horns_motion_3libre.py), so it is not + # offered there. Applies on the next pipeline start (a run_config field). owner._gpr_relative_permittivity = QDoubleSpinBox() owner._gpr_relative_permittivity.setDecimals(4) owner._gpr_relative_permittivity.setRange(0.0001, 1000.0) owner._gpr_relative_permittivity.setSingleStep(0.05) + owner._gpr_relative_permittivity.setToolTip("Applied on the next pipeline start (Save Config and restart).") owner._gpr_relative_permittivity.setValue(float(gpr_defaults.relative_permittivity)) owner._gpr_tx_geometry_input = QPlainTextEdit(_format_tx_geometry(owner)) @@ -194,14 +198,13 @@ def build_processing_group(owner) -> QGroupBox: owner._gpr_common_page = _build_processing_mode_page( group, [ - ("Relative permittivity", owner._gpr_relative_permittivity), ("Tx geometry", owner._gpr_tx_geometry_input), ("Rx geometry", owner._gpr_rx_geometry_input), ], split_index=1, ) - owner._gpr_geometry_hint = QLabel("To apply Tx/Rx geometry or permittivity changes: Save Config and restart the app") + owner._gpr_geometry_hint = QLabel("To apply Tx/Rx geometry changes: Save Config and restart the app") owner._gpr_geometry_hint.setWordWrap(True) owner._gpr_common_page.layout().addWidget(owner._gpr_geometry_hint) @@ -229,11 +232,15 @@ def build_processing_group(owner) -> QGroupBox: owner._gpr_range_comp_power.setSingleStep(0.01) owner._gpr_range_comp_power.setValue(float(gpr_live_defaults.range_comp_power)) - owner._gpr_angle_comp_power = QDoubleSpinBox() - owner._gpr_angle_comp_power.setDecimals(3) - owner._gpr_angle_comp_power.setRange(0.0, 5.0) - owner._gpr_angle_comp_power.setSingleStep(0.01) - owner._gpr_angle_comp_power.setValue(float(gpr_live_defaults.angle_comp_power)) + owner._gpr_object_min_frac = QDoubleSpinBox() + owner._gpr_object_min_frac.setDecimals(2) + owner._gpr_object_min_frac.setRange(0.0, 1.0) + owner._gpr_object_min_frac.setSingleStep(0.05) + owner._gpr_object_min_frac.setToolTip( + "Object detection stops once a peak falls below this fraction of the global " + "maximum (Horns_motion_3libre.py BP_OBJECT_MIN_FRAC)." + ) + owner._gpr_object_min_frac.setValue(float(gpr_live_defaults.object_min_frac)) owner._gpr_score_mode = QComboBox() owner._gpr_score_mode.addItems(["peak", "combined"]) @@ -289,6 +296,14 @@ def build_processing_group(owner) -> QGroupBox: owner._gpr_draw_top_m_objects.setRange(0, 10_000) owner._gpr_draw_top_m_objects.setValue(int(gpr_live_defaults.draw_top_m_objects)) + owner._gpr_object_approach_min_frames = QSpinBox() + owner._gpr_object_approach_min_frames.setRange(1, 100) + owner._gpr_object_approach_min_frames.setToolTip( + "Show an object only after it persists as a motion-consistent track this many " + "consecutive frames (1 disables the approach filter)." + ) + owner._gpr_object_approach_min_frames.setValue(int(gpr_live_defaults.object_approach_min_frames)) + owner._gpr_start_freq_mhz = QDoubleSpinBox() owner._gpr_start_freq_mhz.setDecimals(1) owner._gpr_start_freq_mhz.setRange(100.0, 8800.0) @@ -315,12 +330,6 @@ def build_processing_group(owner) -> QGroupBox: owner._gpr_render_mode.addItems(["heatmap", "objects_only"]) owner._set_combo_current_text(owner._gpr_render_mode, gpr_live_defaults.render_mode) - owner._gpr_min_visible_score = QDoubleSpinBox() - owner._gpr_min_visible_score.setDecimals(2) - owner._gpr_min_visible_score.setRange(0.0, 1.0) - owner._gpr_min_visible_score.setSingleStep(0.05) - owner._gpr_min_visible_score.setValue(float(gpr_live_defaults.min_visible_score)) - owner._gpr_visible_x_min_m = QDoubleSpinBox() owner._gpr_visible_x_min_m.setDecimals(2) owner._gpr_visible_x_min_m.setRange(-100.0, 100.0) @@ -362,7 +371,7 @@ def build_processing_group(owner) -> QGroupBox: ("Min depth m", owner._gpr_min_depth_m), ("Max depth m", owner._gpr_max_depth_m), ("Range comp power", owner._gpr_range_comp_power), - ("Angle comp power", owner._gpr_angle_comp_power), + ("Object min frac", owner._gpr_object_min_frac), ("Score mode", owner._gpr_score_mode), ("Motion mode", owner._gpr_motion_mode), ("Look angle deg", owner._gpr_look_angle_deg), @@ -370,9 +379,9 @@ def build_processing_group(owner) -> QGroupBox: owner._gpr_ignore_socket_speed_enabled, ("Speed m/s", owner._gpr_speed_m_s), ("Render mode", owner._gpr_render_mode), - ("Min visible score", owner._gpr_min_visible_score), ("Max detected objects", owner._gpr_max_detected_objects_to_draw), ("Draw top M objects", owner._gpr_draw_top_m_objects), + ("Approach min frames", owner._gpr_object_approach_min_frames), ("Start MHz", owner._gpr_start_freq_mhz), ("Stop MHz", owner._gpr_stop_freq_mhz), ("Imaging plane Y m", owner._gpr_imaging_plane_y_m), @@ -522,6 +531,7 @@ def build_processing_group(owner) -> QGroupBox: owner._processing_mode_pages, [ ("Config mode", owner._legacy_gpr_config_mode), + ("Relative permittivity", owner._gpr_relative_permittivity), ("Input positions", owner._legacy_gpr_input_positions_input), ("Output positions", owner._legacy_gpr_output_positions_input), ("Min depth m", owner._legacy_gpr_min_depth_m), @@ -574,7 +584,7 @@ def build_processing_group(owner) -> QGroupBox: owner._gpr_min_depth_m.valueChanged.connect(owner._on_processing_live_settings_changed) owner._gpr_max_depth_m.valueChanged.connect(owner._on_processing_live_settings_changed) owner._gpr_range_comp_power.valueChanged.connect(owner._on_processing_live_settings_changed) - owner._gpr_angle_comp_power.valueChanged.connect(owner._on_processing_live_settings_changed) + owner._gpr_object_min_frac.valueChanged.connect(owner._on_processing_live_settings_changed) owner._gpr_score_mode.currentTextChanged.connect(owner._on_processing_live_settings_changed) owner._gpr_motion_mode.currentTextChanged.connect(owner._on_processing_live_settings_changed) owner._gpr_look_angle_deg.valueChanged.connect(owner._on_processing_live_settings_changed) @@ -588,9 +598,9 @@ def build_processing_group(owner) -> QGroupBox: owner._gpr_remove_sidelobe_objects_enabled.toggled.connect(owner._on_processing_live_settings_changed) owner._gpr_imaging_plane_y_m.valueChanged.connect(owner._on_processing_live_settings_changed) owner._gpr_render_mode.currentTextChanged.connect(owner._on_gpr_visual_settings_changed) - owner._gpr_min_visible_score.valueChanged.connect(owner._on_gpr_locator_threshold_changed) owner._gpr_max_detected_objects_to_draw.valueChanged.connect(owner._on_gpr_locator_threshold_changed) owner._gpr_draw_top_m_objects.valueChanged.connect(owner._on_gpr_locator_threshold_changed) + owner._gpr_object_approach_min_frames.valueChanged.connect(owner._on_processing_live_settings_changed) owner._gpr_visible_x_min_m.valueChanged.connect(owner._on_gpr_locator_window_changed) owner._gpr_visible_x_max_m.valueChanged.connect(owner._on_gpr_locator_window_changed) owner._gpr_visible_z_min_m.valueChanged.connect(owner._on_gpr_locator_window_changed) diff --git a/python_app/models/gui_profile_codec.py b/python_app/models/gui_profile_codec.py index a25368f..0dab47a 100644 --- a/python_app/models/gui_profile_codec.py +++ b/python_app/models/gui_profile_codec.py @@ -276,10 +276,10 @@ def gui_profile_from_dict(payload: dict[str, Any]) -> GuiProfileModel: gui.processing.gpr.range_comp_power, "gui.processing.gpr", ), - angle_comp_power=_optional_float( + object_min_frac=_optional_float( gpr_object, - "angle_comp_power", - gui.processing.gpr.angle_comp_power, + "object_min_frac", + gui.processing.gpr.object_min_frac, "gui.processing.gpr", ), score_mode=_optional_string( @@ -330,6 +330,12 @@ def gui_profile_from_dict(payload: dict[str, Any]) -> GuiProfileModel: gui.processing.gpr.draw_top_m_objects, "gui.processing.gpr", ), + object_approach_min_frames=_optional_int( + gpr_object, + "object_approach_min_frames", + gui.processing.gpr.object_approach_min_frames, + "gui.processing.gpr", + ), start_freq_mhz=_optional_float( gpr_object, "start_freq_mhz", @@ -372,12 +378,6 @@ def gui_profile_from_dict(payload: dict[str, Any]) -> GuiProfileModel: gui.processing.gpr.render_mode, "gui.processing.gpr", ), - min_visible_score=_optional_float( - gpr_object, - "min_visible_score", - gui.processing.gpr.min_visible_score, - "gui.processing.gpr", - ), visible_x_min_m=_optional_float( gpr_object, "visible_x_min_m", @@ -467,14 +467,14 @@ def gui_profile_from_dict(payload: dict[str, Any]) -> GuiProfileModel: ) if gui.processing.gpr.range_comp_power < 0.0: raise ValueError("gui.processing.gpr.range_comp_power must be >= 0") - if gui.processing.gpr.angle_comp_power < 0.0: - raise ValueError("gui.processing.gpr.angle_comp_power must be >= 0") - if gui.processing.gpr.min_visible_score < 0.0: - raise ValueError("gui.processing.gpr.min_visible_score must be >= 0") + if not 0.0 <= gui.processing.gpr.object_min_frac <= 1.0: + raise ValueError("gui.processing.gpr.object_min_frac must be within [0, 1]") if gui.processing.gpr.max_detected_objects_to_draw < 0: raise ValueError("gui.processing.gpr.max_detected_objects_to_draw must be >= 0") if gui.processing.gpr.draw_top_m_objects < 0: raise ValueError("gui.processing.gpr.draw_top_m_objects must be >= 0") + if gui.processing.gpr.object_approach_min_frames < 1: + raise ValueError("gui.processing.gpr.object_approach_min_frames must be >= 1") if gui.processing.legacy_gpr.comp_power < 0.0: raise ValueError("gui.processing.legacy_gpr.comp_power must be >= 0") if gui.processing.legacy_gpr.snr_thresh < 0.0: @@ -586,7 +586,7 @@ def gui_profile_to_dict(model: GuiProfileModel) -> dict[str, Any]: "min_depth_m": gui.processing.gpr.min_depth_m, "max_depth_m": gui.processing.gpr.max_depth_m, "range_comp_power": gui.processing.gpr.range_comp_power, - "angle_comp_power": gui.processing.gpr.angle_comp_power, + "object_min_frac": gui.processing.gpr.object_min_frac, "score_mode": gui.processing.gpr.score_mode, "motion_mode": gui.processing.gpr.motion_mode, "look_angle_deg": gui.processing.gpr.look_angle_deg, @@ -595,6 +595,7 @@ def gui_profile_to_dict(model: GuiProfileModel) -> dict[str, Any]: "ignore_socket_speed_enabled": gui.processing.gpr.ignore_socket_speed_enabled, "max_detected_objects_to_draw": gui.processing.gpr.max_detected_objects_to_draw, "draw_top_m_objects": gui.processing.gpr.draw_top_m_objects, + "object_approach_min_frames": gui.processing.gpr.object_approach_min_frames, "start_freq_mhz": gui.processing.gpr.start_freq_mhz, "stop_freq_mhz": gui.processing.gpr.stop_freq_mhz, "background_subtract_enabled": gui.processing.gpr.background_subtract_enabled, @@ -602,7 +603,6 @@ def gui_profile_to_dict(model: GuiProfileModel) -> dict[str, Any]: "remove_sidelobe_objects_enabled": gui.processing.gpr.remove_sidelobe_objects_enabled, "imaging_plane_y_m": gui.processing.gpr.imaging_plane_y_m, "render_mode": gui.processing.gpr.render_mode, - "min_visible_score": gui.processing.gpr.min_visible_score, "visible_x_min_m": gui.processing.gpr.visible_x_min_m, "visible_x_max_m": gui.processing.gpr.visible_x_max_m, "visible_z_min_m": gui.processing.gpr.visible_z_min_m, diff --git a/python_app/models/gui_profile_schema.py b/python_app/models/gui_profile_schema.py index 185ec09..d55c80d 100644 --- a/python_app/models/gui_profile_schema.py +++ b/python_app/models/gui_profile_schema.py @@ -59,7 +59,10 @@ class GuiGprStateModel: min_depth_m: float = 2.0 max_depth_m: float = 14.0 range_comp_power: float = 0.1 - angle_comp_power: float = 0.0 + # BP object-detection stop level, fraction of the global peak (Horns_motion_3libre.py + # BP_OBJECT_MIN_FRAC). Angle compensation and permittivity are fixed for coherent BP + # (Python 0.3 block), so they are not exposed here. + object_min_frac: float = 0.7 score_mode: str = "combined" motion_mode: str = "int_minus" # Intra-sweep motion-correction inputs. Sweep time is derived from acquisition @@ -71,6 +74,9 @@ class GuiGprStateModel: ignore_socket_speed_enabled: bool = False max_detected_objects_to_draw: int = 5 draw_top_m_objects: int = 2 + # Cross-frame approach filter: show an object only after it persists as a + # motion-consistent track this many consecutive frames (<= 1 disables it). + object_approach_min_frames: int = 3 start_freq_mhz: float = 3000.0 stop_freq_mhz: float = 6000.0 background_subtract_enabled: bool = True @@ -78,7 +84,6 @@ class GuiGprStateModel: remove_sidelobe_objects_enabled: bool = True imaging_plane_y_m: float = 0.0 render_mode: str = "heatmap" - min_visible_score: float = 0.0 visible_x_min_m: float = -2.0 visible_x_max_m: float = 2.0 visible_z_min_m: float = 0.0 diff --git a/python_app/orchestration/live_processing_config.py b/python_app/orchestration/live_processing_config.py index 0ab6a89..c008351 100644 --- a/python_app/orchestration/live_processing_config.py +++ b/python_app/orchestration/live_processing_config.py @@ -32,8 +32,8 @@ class ProcessingLiveConfig: gpr_min_depth_m: float = 2.0 gpr_max_depth_m: float = 14.0 gpr_range_comp_power: float = 0.1 - gpr_angle_comp_power: float = 0.0 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 # correction) or "int_focus" (focusing residual only). Mirrors Python @@ -41,6 +41,7 @@ class ProcessingLiveConfig: gpr_motion_mode: str = "int_minus" gpr_max_detected_objects_to_draw: int = 5 gpr_draw_top_m_objects: int = 2 + 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 @@ -61,8 +62,9 @@ class ProcessingLiveConfig: gpr_background_mean_count: int = 10 gpr_remove_sidelobe_objects_enabled: bool = True gpr_imaging_plane_y_m: float = 0.0 - # Locator filter parameters consumed by the C++ TCP locator server. - gpr_min_visible_score: 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 # 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. @@ -109,12 +111,13 @@ class ProcessingLiveConfig: "gpr_min_depth_m": float(self.gpr_min_depth_m), "gpr_max_depth_m": float(self.gpr_max_depth_m), "gpr_range_comp_power": float(self.gpr_range_comp_power), - "gpr_angle_comp_power": float(self.gpr_angle_comp_power), "gpr_comp_power": float(self.gpr_comp_power), + "gpr_object_min_frac": float(self.gpr_object_min_frac), "gpr_score_mode": str(self.gpr_score_mode), "gpr_motion_mode": str(self.gpr_motion_mode), "gpr_max_detected_objects_to_draw": int(self.gpr_max_detected_objects_to_draw), "gpr_draw_top_m_objects": int(self.gpr_draw_top_m_objects), + "gpr_object_approach_min_frames": int(self.gpr_object_approach_min_frames), "gpr_speed_m_s": float(self.gpr_speed_m_s), "gpr_look_angle_deg": float(self.gpr_look_angle_deg), "gpr_direction_sign": float(self.gpr_direction_sign), @@ -128,7 +131,6 @@ class ProcessingLiveConfig: "gpr_background_mean_count": int(self.gpr_background_mean_count), "gpr_remove_sidelobe_objects_enabled": bool(self.gpr_remove_sidelobe_objects_enabled), "gpr_imaging_plane_y_m": float(self.gpr_imaging_plane_y_m), - "gpr_min_visible_score": float(self.gpr_min_visible_score), "legacy_gpr_min_visible_pair_count": float(self.legacy_gpr_min_visible_pair_count), "gpr_visible_x_min_m": float(self.gpr_visible_x_min_m), "gpr_visible_x_max_m": float(self.gpr_visible_x_max_m), diff --git a/python_app/tests/test_storage_webui.py b/python_app/tests/test_storage_webui.py index bc75c29..9b0e5df 100644 --- a/python_app/tests/test_storage_webui.py +++ b/python_app/tests/test_storage_webui.py @@ -161,8 +161,8 @@ class WebControllerTest(unittest.TestCase): def test_known_field_emits_and_returns_snapshot(self) -> None: received: list[dict] = [] self.controller.apply_settings_requested.connect(received.append) - out = self.controller.apply_live_settings({"gpr_min_visible_score": 0.5}) - self.assertEqual(received, [{"gpr_min_visible_score": 0.5}]) + out = self.controller.apply_live_settings({"gpr_object_min_frac": 0.5}) + self.assertEqual(received, [{"gpr_object_min_frac": 0.5}]) self.assertIsInstance(out, list) def test_snapshot_is_replaced_and_returned_as_copy(self) -> None: diff --git a/run_config.json b/run_config.json index fa3edaa..364621d 100644 --- a/run_config.json +++ b/run_config.json @@ -282,7 +282,7 @@ "min_depth_m": 2.0, "max_depth_m": 14.0, "range_comp_power": 0.1, - "angle_comp_power": 0.0, + "object_min_frac": 0.7, "score_mode": "combined", "motion_mode": "int_minus", "look_angle_deg": 0.0, @@ -291,6 +291,7 @@ "ignore_socket_speed_enabled": false, "max_detected_objects_to_draw": 5, "draw_top_m_objects": 2, + "object_approach_min_frames": 3, "start_freq_mhz": 3000.0, "stop_freq_mhz": 6000.0, "background_subtract_enabled": true, @@ -298,7 +299,6 @@ "remove_sidelobe_objects_enabled": false, "imaging_plane_y_m": 0.0, "render_mode": "heatmap", - "min_visible_score": 0.0, "visible_x_min_m": -2.0, "visible_x_max_m": 2.0, "visible_z_min_m": 0.0, diff --git a/run_config_examples/run_config_simulator.example.json b/run_config_examples/run_config_simulator.example.json index fa3edaa..364621d 100644 --- a/run_config_examples/run_config_simulator.example.json +++ b/run_config_examples/run_config_simulator.example.json @@ -282,7 +282,7 @@ "min_depth_m": 2.0, "max_depth_m": 14.0, "range_comp_power": 0.1, - "angle_comp_power": 0.0, + "object_min_frac": 0.7, "score_mode": "combined", "motion_mode": "int_minus", "look_angle_deg": 0.0, @@ -291,6 +291,7 @@ "ignore_socket_speed_enabled": false, "max_detected_objects_to_draw": 5, "draw_top_m_objects": 2, + "object_approach_min_frames": 3, "start_freq_mhz": 3000.0, "stop_freq_mhz": 6000.0, "background_subtract_enabled": true, @@ -298,7 +299,6 @@ "remove_sidelobe_objects_enabled": false, "imaging_plane_y_m": 0.0, "render_mode": "heatmap", - "min_visible_score": 0.0, "visible_x_min_m": -2.0, "visible_x_max_m": 2.0, "visible_z_min_m": 0.0, diff --git a/run_configs/run_config.json b/run_configs/run_config.json index fa3edaa..364621d 100644 --- a/run_configs/run_config.json +++ b/run_configs/run_config.json @@ -282,7 +282,7 @@ "min_depth_m": 2.0, "max_depth_m": 14.0, "range_comp_power": 0.1, - "angle_comp_power": 0.0, + "object_min_frac": 0.7, "score_mode": "combined", "motion_mode": "int_minus", "look_angle_deg": 0.0, @@ -291,6 +291,7 @@ "ignore_socket_speed_enabled": false, "max_detected_objects_to_draw": 5, "draw_top_m_objects": 2, + "object_approach_min_frames": 3, "start_freq_mhz": 3000.0, "stop_freq_mhz": 6000.0, "background_subtract_enabled": true, @@ -298,7 +299,6 @@ "remove_sidelobe_objects_enabled": false, "imaging_plane_y_m": 0.0, "render_mode": "heatmap", - "min_visible_score": 0.0, "visible_x_min_m": -2.0, "visible_x_max_m": 2.0, "visible_z_min_m": 0.0,