From 1b7d9e417d0aaf0d5ca7db20e71c6eba843f8afa Mon Sep 17 00:00:00 2001 From: Ayzen Date: Wed, 6 May 2026 11:54:59 +0300 Subject: [PATCH] added legacy gpr --- .../include/processing_live_config.hpp | 12 + .../src/processing_live_config.cpp | 51 + .../src/gpr_backprojection_processor.ipp | 1313 +++++++++++++++++ .../processors/src/gpr_legacy_processor.ipp | 827 +++++++++++ .../processors/src/gpr_processor.cpp | 1309 +--------------- docs/operation_modes.md | 19 +- .../live_processing_mixin.py | 11 +- .../app_window_config/profile_io_mixin.py | 32 + .../app_window_config/state_builders.py | 6 + .../controllers/app_window_pipeline_mixin.py | 17 +- .../sections/processing_section.py | 51 +- python_app/models/gui_profile_codec.py | 57 + python_app/models/gui_profile_schema.py | 6 + .../orchestration/live_processing_config.py | 12 + run_config.json | 55 +- run_config_simulator.example.json | 254 ++++ 16 files changed, 2697 insertions(+), 1335 deletions(-) create mode 100644 data_acq_and_processing/processing/processors/src/gpr_backprojection_processor.ipp create mode 100644 data_acq_and_processing/processing/processors/src/gpr_legacy_processor.ipp create mode 100644 run_config_simulator.example.json 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 03805f7..4d2abbb 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 @@ -13,6 +13,12 @@ enum class HistoryCommand { ClearAll, }; +enum class GprAlgorithm { + Backprojection, + LegacyPoint, + LegacyExtended, +}; + struct ProcessingLiveConfig { std::string processor_mode = "pass_through"; std::string pass_through_channel = "s21"; @@ -26,12 +32,18 @@ struct ProcessingLiveConfig { float bscan_gain = 1.0F; float bscan_start_freq_mhz = 100.0F; float bscan_stop_freq_mhz = 8800.0F; + GprAlgorithm gpr_algorithm = GprAlgorithm::Backprojection; std::vector gpr_input_positions{}; std::vector gpr_output_positions{}; float gpr_min_depth_m = 2.0F; float gpr_max_depth_m = 14.0F; float gpr_range_comp_power = 0.28F; float gpr_angle_comp_power = 0.10F; + float gpr_comp_power = 0.2F; + float gpr_speed_m_s = 0.0F; + float gpr_look_angle_deg = 0.0F; + 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; 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 82e7f32..438524e 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 @@ -31,6 +31,21 @@ using Json = nlohmann::json; throw std::runtime_error("processing.history_command must be one of: none, remove_last, clear_all"); } +[[nodiscard]] auto parse_gpr_algorithm(const std::string& value) -> GprAlgorithm { + if (value == "backprojection") { + return GprAlgorithm::Backprojection; + } + if (value == "legacy_point") { + return GprAlgorithm::LegacyPoint; + } + if (value == "legacy_extended") { + return GprAlgorithm::LegacyExtended; + } + throw std::runtime_error( + "processing.gpr_algorithm must be one of: backprojection, legacy_point, legacy_extended" + ); +} + [[nodiscard]] auto parse_s_parameter_channel(const std::string& value, const std::string& field_name) -> std::string { if (value == "s21" || value == "s11") { return value; @@ -164,6 +179,12 @@ using Json = nlohmann::json; } config.bscan_stop_freq_mhz = static_cast(found->get()); } + 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"); + } + config.gpr_algorithm = parse_gpr_algorithm(found->get()); + } if (const auto found = root.find("gpr_input_positions"); found != root.end()) { config.gpr_input_positions = parse_u32_array(*found, "processing.gpr_input_positions"); } @@ -194,6 +215,36 @@ using Json = nlohmann::json; } config.gpr_angle_comp_power = static_cast(found->get()); } + if (const auto found = root.find("gpr_comp_power"); found != root.end()) { + if (!found->is_number()) { + throw std::runtime_error("processing.gpr_comp_power must be number"); + } + config.gpr_comp_power = static_cast(found->get()); + } + if (const auto found = root.find("gpr_speed_m_s"); found != root.end()) { + if (!found->is_number()) { + throw std::runtime_error("processing.gpr_speed_m_s must be number"); + } + config.gpr_speed_m_s = static_cast(found->get()); + } + if (const auto found = root.find("gpr_look_angle_deg"); found != root.end()) { + if (!found->is_number()) { + throw std::runtime_error("processing.gpr_look_angle_deg must be number"); + } + config.gpr_look_angle_deg = static_cast(found->get()); + } + if (const auto found = root.find("gpr_snr_thresh"); found != root.end()) { + if (!found->is_number()) { + throw std::runtime_error("processing.gpr_snr_thresh must be number"); + } + config.gpr_snr_thresh = static_cast(found->get()); + } + if (const auto found = root.find("gpr_snr_comp_max"); found != root.end()) { + if (!found->is_number()) { + throw std::runtime_error("processing.gpr_snr_comp_max must be number"); + } + config.gpr_snr_comp_max = static_cast(found->get()); + } if (const auto found = root.find("gpr_start_freq_mhz"); found != root.end()) { if (!found->is_number()) { throw std::runtime_error("processing.gpr_start_freq_mhz must be number"); 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 new file mode 100644 index 0000000..6a2b738 --- /dev/null +++ b/data_acq_and_processing/processing/processors/src/gpr_backprojection_processor.ipp @@ -0,0 +1,1313 @@ +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; +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 = 3.0; + +constexpr double kSmoothSigma = 1.5; +constexpr std::size_t kMaxObjects = 10U; +constexpr double kObjectMinFrac = 0.35; +constexpr double kRegionThresholdFrac = 0.75; +constexpr double kSuppressThresholdFrac = 0.20; +constexpr double kSuppressRadiusXM = 0.80; +constexpr double kSuppressRadiusZM = 0.40; +constexpr double kMinRegionAreaCm2 = 10.0; + +constexpr double kCenterRadiusXM = 0.60; +constexpr double kCenterRadiusZM = 0.25; +constexpr double kCenterThresholdFrac = 0.88; +constexpr double kCenterWeightPower = 2.0; + +constexpr double kSidelobeRangeRmsToleranceM = 0.20; +constexpr double kSidelobeMinDxM = 0.35; +constexpr double kSidelobeMaxDzM = 0.70; +constexpr double kSidelobeMaxRelativePeak = 0.85; + +using PairKey = std::uint64_t; + +struct GeometrySelection { + std::vector input_positions{}; + std::vector output_positions{}; + std::vector x_tx{}; + std::vector x_rx{}; + std::unordered_map input_local_by_pos{}; + std::unordered_map output_local_by_pos{}; +}; + +struct BackgroundAccumulator { + std::vector> sum{}; + std::size_t count = 0U; +}; + +struct SelectedTrace { + ipc::ComboKey combo{}; + std::uint32_t tx_local_index = 0U; + std::uint32_t rx_local_index = 0U; + std::size_t run_order = 0U; + std::vector frequency_hz{}; + std::vector> s21{}; +}; + +struct AscanResult { + std::vector time_s{}; + std::vector> samples{}; + double dt_s = 0.0; + double bandwidth_hz = 0.0; +}; + +struct GridDefinition { + std::vector x_grid{}; + std::vector z_grid{}; + std::vector> tx_distance_grids{}; + std::vector> rx_distance_grids{}; +}; + +struct BpMap { + std::vector image{}; + std::vector> coherent{}; +}; + +struct ObjectRecord { + std::size_t index = 0U; + double x_peak_m = 0.0; + double z_peak_m = 0.0; + double x_m = 0.0; + double z_m = 0.0; + double x_region_m = 0.0; + double z_region_m = 0.0; + double peak = 0.0; + double area_cm2 = 0.0; + double center_area_cm2 = 0.0; + double mean_value = 0.0; + double sum_value = 0.0; + std::vector region_mask{}; + std::vector center_mask{}; + bool sidelobe_candidate = false; + std::size_t sidelobe_parent = 0U; + double sidelobe_range_rms_m = std::numeric_limits::quiet_NaN(); +}; + +[[nodiscard]] auto make_pair_key(std::uint32_t tx_local_index, std::uint32_t rx_local_index) -> PairKey { + return (static_cast(tx_local_index) << 32U) | static_cast(rx_local_index); +} + +[[nodiscard]] auto combo_to_string(const ipc::ComboKey& combo) -> std::string { + return "input=" + std::to_string(combo.input_pos) + " output=" + std::to_string(combo.output_pos); +} + +[[nodiscard]] auto next_power_of_two(std::size_t value) -> std::size_t { + if (value <= 1U) { + return 1U; + } + + std::size_t power = 1U; + while (power < value) { + if (power > (std::numeric_limits::max() >> 1U)) { + return value; + } + power <<= 1U; + } + return power; +} + +void fft_inplace(std::vector>& values, bool inverse) { + const std::size_t size = values.size(); + if (size <= 1U) { + return; + } + + for (std::size_t index = 1U, bit_reversed = 0U; index < size; ++index) { + std::size_t bit = size >> 1U; + while (bit_reversed & bit) { + bit_reversed ^= bit; + bit >>= 1U; + } + bit_reversed ^= bit; + if (index < bit_reversed) { + std::swap(values[index], values[bit_reversed]); + } + } + + for (std::size_t len = 2U; len <= size; len <<= 1U) { + const double angle = 2.0 * kPi * (inverse ? 1.0 : -1.0) / static_cast(len); + const std::complex twiddle_step(std::cos(angle), std::sin(angle)); + const std::size_t half_len = len >> 1U; + + for (std::size_t offset = 0U; offset < size; offset += len) { + std::complex twiddle(1.0, 0.0); + for (std::size_t inner = 0U; inner < half_len; ++inner) { + const auto even = values[offset + inner]; + const auto odd = values[offset + inner + half_len] * twiddle; + values[offset + inner] = even + odd; + values[offset + inner + half_len] = even - odd; + twiddle *= twiddle_step; + } + } + } + + if (!inverse) { + return; + } + + const double scale = 1.0 / static_cast(size); + for (auto& value : values) { + value *= scale; + } +} + +[[nodiscard]] auto median_copy(std::vector values) -> double { + if (values.empty()) { + return 0.0; + } + + const auto middle = values.begin() + static_cast(values.size() / 2U); + std::nth_element(values.begin(), middle, values.end()); + double median = *middle; + if ((values.size() % 2U) == 0U) { + const auto lower_middle = values.begin() + static_cast((values.size() / 2U) - 1U); + std::nth_element(values.begin(), lower_middle, values.end()); + median = 0.5 * (median + *lower_middle); + } + return median; +} + +[[nodiscard]] auto build_axis(double min_value, double max_value, std::size_t count) -> std::vector { + std::vector axis{}; + if (count == 0U) { + return axis; + } + + axis.resize(count, min_value); + if (count == 1U) { + return axis; + } + + const double step = (max_value - min_value) / static_cast(count - 1U); + for (std::size_t index = 0U; index < count; ++index) { + axis[index] = min_value + (step * static_cast(index)); + } + 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(limit - 1U); + if (value > max_index) { + return limit - 1U; + } + return static_cast(value); +} + +[[nodiscard]] auto max_value(const std::vector& values) -> double { + if (values.empty()) { + return 0.0; + } + return *std::max_element(values.begin(), values.end()); +} + +void normalize_in_place(std::vector& values) { + const double maximum = max_value(values); + if (!(maximum > 0.0)) { + return; + } + for (auto& value : values) { + value /= maximum; + } +} + +[[nodiscard]] auto build_gaussian_kernel(double sigma) -> std::vector { + if (!(sigma > 0.0)) { + return {1.0}; + } + + const auto radius = static_cast(std::ceil(sigma * 3.0)); + std::vector kernel(static_cast((radius * 2) + 1), 0.0); + double sum = 0.0; + for (std::ptrdiff_t offset = -radius; offset <= radius; ++offset) { + const double value = std::exp(-0.5 * std::pow(static_cast(offset) / sigma, 2.0)); + kernel[static_cast(offset + radius)] = value; + sum += value; + } + if (sum > 0.0) { + for (auto& value : kernel) { + value /= sum; + } + } + return kernel; +} + +[[nodiscard]] auto gaussian_filter_2d( + const std::vector& values, + std::size_t width, + std::size_t height, + double sigma +) -> std::vector { + if (values.empty() || width == 0U || height == 0U) { + return {}; + } + + const auto kernel = build_gaussian_kernel(sigma); + const auto radius = static_cast((kernel.size() - 1U) / 2U); + std::vector temp(values.size(), 0.0); + std::vector output(values.size(), 0.0); + + for (std::size_t row = 0U; row < height; ++row) { + for (std::size_t col = 0U; col < width; ++col) { + double sum = 0.0; + for (std::ptrdiff_t offset = -radius; offset <= radius; ++offset) { + const auto sample_col = clamp_index(static_cast(col) + offset, width); + sum += values[(row * width) + sample_col] * kernel[static_cast(offset + radius)]; + } + temp[(row * width) + col] = sum; + } + } + + for (std::size_t row = 0U; row < height; ++row) { + for (std::size_t col = 0U; col < width; ++col) { + double sum = 0.0; + for (std::ptrdiff_t offset = -radius; offset <= radius; ++offset) { + const auto sample_row = clamp_index(static_cast(row) + offset, height); + sum += temp[(sample_row * width) + col] * kernel[static_cast(offset + radius)]; + } + output[(row * width) + col] = sum; + } + } + + return output; +} + +[[nodiscard]] auto contains_u32(const std::vector& values, std::uint32_t value) -> bool { + return std::find(values.begin(), values.end(), value) != values.end(); +} + +[[nodiscard]] auto selected_positions( + const std::vector& requested, + std::vector available +) -> std::vector { + std::sort(available.begin(), available.end()); + available.erase(std::unique(available.begin(), available.end()), available.end()); + if (requested.empty()) { + return available; + } + + std::vector result{}; + for (const auto value : requested) { + if (contains_u32(available, value) && !contains_u32(result, value)) { + result.push_back(value); + } + } + std::sort(result.begin(), result.end()); + return result; +} + +[[nodiscard]] auto build_geometry_selection( + const config::RunConfig& run_config, + const ProcessingLiveConfig& live_config +) -> GeometrySelection { + std::unordered_map tx_x_by_pos{}; + for (const auto& entry : run_config.gpr.tx_geometry) { + tx_x_by_pos[entry.output_pos] = static_cast(entry.x_m); + } + + std::unordered_map rx_x_by_pos{}; + for (const auto& entry : run_config.gpr.rx_geometry) { + rx_x_by_pos[entry.input_pos] = static_cast(entry.x_m); + } + + std::vector available_outputs{}; + available_outputs.reserve(tx_x_by_pos.size()); + for (const auto& [position, _] : tx_x_by_pos) { + available_outputs.push_back(position); + } + + std::vector available_inputs{}; + available_inputs.reserve(rx_x_by_pos.size()); + for (const auto& [position, _] : rx_x_by_pos) { + available_inputs.push_back(position); + } + + GeometrySelection selection{}; + selection.output_positions = selected_positions(live_config.gpr_output_positions, std::move(available_outputs)); + selection.input_positions = selected_positions(live_config.gpr_input_positions, std::move(available_inputs)); + + selection.x_tx.reserve(selection.output_positions.size()); + for (std::size_t index = 0U; index < selection.output_positions.size(); ++index) { + const auto position = selection.output_positions[index]; + selection.output_local_by_pos[position] = static_cast(index); + selection.x_tx.push_back(tx_x_by_pos[position]); + } + + selection.x_rx.reserve(selection.input_positions.size()); + for (std::size_t index = 0U; index < selection.input_positions.size(); ++index) { + const auto position = selection.input_positions[index]; + selection.input_local_by_pos[position] = static_cast(index); + selection.x_rx.push_back(rx_x_by_pos[position]); + } + + return selection; +} + +void validate_collection_trace_order( + const config::RunConfig& run_config, + const ipc::PreprocessedCollection& collection +) { + if (collection.traces.size() != run_config.run_combos.size()) { + throw std::runtime_error( + "GPR requires collection trace order to match run.combos exactly: trace_count=" + + std::to_string(collection.traces.size()) + + ", run_combo_count=" + std::to_string(run_config.run_combos.size()) + ); + } + + for (std::size_t index = 0U; index < collection.traces.size(); ++index) { + const auto& actual = collection.traces[index].combo; + const auto& expected = run_config.run_combos[index]; + if (actual.input_pos == expected.input_pos && actual.output_pos == expected.output_pos) { + continue; + } + + throw std::runtime_error( + "GPR requires preprocessed trace order to match run.combos: index=" + std::to_string(index) + + ", expected=(" + combo_to_string(expected) + "), actual=(" + combo_to_string(actual) + ")" + ); + } +} + +[[nodiscard]] auto build_background_mean( + std::span previous_collections, + const GeometrySelection& selection, + const ProcessingLiveConfig& live_config +) -> std::unordered_map>> { + std::unordered_map>> 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(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 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(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(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(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>>& background_mean +) -> std::vector { + std::vector traces{}; + traces.reserve(collection.traces.size()); + std::unordered_set seen_keys{}; + + for (std::size_t trace_index = 0U; trace_index < collection.traces.size(); ++trace_index) { + const auto& trace = collection.traces[trace_index]; + 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; + } + if (trace.frequency_hz.size() != trace.s21.size()) { + continue; + } + + SelectedTrace selected{}; + selected.combo = trace.combo; + selected.tx_local_index = output_it->second; + selected.rx_local_index = input_it->second; + selected.run_order = trace_index; + selected.frequency_hz.reserve(trace.frequency_hz.size()); + for (const auto value : trace.frequency_hz) { + selected.frequency_hz.push_back(static_cast(value)); + } + + const auto key = make_pair_key(selected.tx_local_index, selected.rx_local_index); + if (!seen_keys.insert(key).second) { + 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 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); + } + + traces.push_back(std::move(selected)); + } + + return traces; +} + +[[nodiscard]] auto compute_ascan( + const SelectedTrace& trace, + double start_hz, + double stop_hz +) -> AscanResult { + AscanResult result{}; + if (trace.frequency_hz.size() != trace.s21.size()) { + return result; + } + + const double low_hz = std::min(start_hz, stop_hz); + const double high_hz = std::max(start_hz, stop_hz); + std::vector frequency_hz{}; + std::vector> s21{}; + frequency_hz.reserve(trace.frequency_hz.size()); + s21.reserve(trace.s21.size()); + + for (std::size_t index = 0U; index < trace.frequency_hz.size(); ++index) { + const double frequency_value = trace.frequency_hz[index]; + if (frequency_value < low_hz || frequency_value > high_hz) { + continue; + } + frequency_hz.push_back(frequency_value); + s21.push_back(trace.s21[index]); + } + + if (frequency_hz.size() < 2U) { + return result; + } + + std::vector df_values{}; + df_values.reserve(frequency_hz.size() - 1U); + for (std::size_t index = 1U; index < frequency_hz.size(); ++index) { + const double df = frequency_hz[index] - frequency_hz[index - 1U]; + if (!(df > 0.0)) { + return result; + } + df_values.push_back(df); + } + + const double df_hz = median_copy(std::move(df_values)); + if (!(df_hz > 0.0)) { + return result; + } + + const auto start_bin = static_cast(std::llround(frequency_hz.front() / df_hz)); + if (start_bin < 0) { + return result; + } + + const std::size_t point_count = frequency_hz.size(); + const auto start_index = static_cast(start_bin); + const std::size_t min_fft_len = 2U * (start_index + point_count - 1U); + const std::size_t base_fft_len = next_power_of_two(min_fft_len); + if (base_fft_len < min_fft_len || base_fft_len > (std::numeric_limits::max() / kAscanOversample)) { + return result; + } + + const std::size_t fft_len = base_fft_len * kAscanOversample; + if (start_index > fft_len || point_count > (fft_len - start_index)) { + return result; + } + + std::vector> spectrum(fft_len, std::complex(0.0, 0.0)); + for (std::size_t index = 0U; index < point_count; ++index) { + const double window = point_count > 1U + ? 0.5 - (0.5 * std::cos((2.0 * kPi * static_cast(index)) / static_cast(point_count - 1U))) + : 1.0; + spectrum[start_index + index] = s21[index] * window; + } + + fft_inplace(spectrum, true); + + result.bandwidth_hz = frequency_hz.back() - frequency_hz.front(); + result.dt_s = 1.0 / (static_cast(fft_len) * df_hz); + result.samples = std::move(spectrum); + result.time_s.resize(fft_len, 0.0); + for (std::size_t index = 0U; index < fft_len; ++index) { + result.time_s[index] = static_cast(index) * result.dt_s; + } + return result; +} + +[[nodiscard]] auto build_grid( + const std::vector& x_tx, + const std::vector& x_rx, + double max_depth_m, + double min_z_m +) -> GridDefinition { + GridDefinition grid{}; + if (x_tx.empty() || x_rx.empty() || !(max_depth_m > min_z_m)) { + return grid; + } + + const auto [tx_min_it, tx_max_it] = std::minmax_element(x_tx.begin(), x_tx.end()); + const auto [rx_min_it, rx_max_it] = std::minmax_element(x_rx.begin(), x_rx.end()); + const double x_min = std::min(*tx_min_it, *rx_min_it) - kXMarginM; + const double x_max = std::max(*tx_max_it, *rx_max_it) + kXMarginM; + + grid.x_grid = build_axis(x_min, x_max, kGridWidth); + grid.z_grid = build_axis(min_z_m, max_depth_m, kGridHeight); + + const std::size_t cell_count = grid.x_grid.size() * grid.z_grid.size(); + grid.tx_distance_grids.assign(x_tx.size(), std::vector(cell_count, 0.0)); + grid.rx_distance_grids.assign(x_rx.size(), std::vector(cell_count, 0.0)); + + for (std::size_t row = 0U; row < grid.z_grid.size(); ++row) { + const double z_value = grid.z_grid[row]; + for (std::size_t col = 0U; col < grid.x_grid.size(); ++col) { + const double x_value = grid.x_grid[col]; + const auto cell_index = (row * grid.x_grid.size()) + col; + + for (std::size_t tx_index = 0U; tx_index < x_tx.size(); ++tx_index) { + grid.tx_distance_grids[tx_index][cell_index] = + std::hypot(x_value - x_tx[tx_index], z_value); + } + for (std::size_t rx_index = 0U; rx_index < x_rx.size(); ++rx_index) { + grid.rx_distance_grids[rx_index][cell_index] = + std::hypot(x_value - x_rx[rx_index], z_value); + } + } + } + + return grid; +} + +[[nodiscard]] auto interpolate_complex(const AscanResult& ascan, double tau_s) -> std::complex { + if (ascan.samples.empty() || !(ascan.dt_s > 0.0) || tau_s < 0.0) { + return {0.0, 0.0}; + } + + const double position = tau_s / ascan.dt_s; + const auto index = static_cast(std::floor(position)); + if (index >= ascan.samples.size()) { + return {0.0, 0.0}; + } + if (index + 1U >= ascan.samples.size()) { + return ascan.samples[index]; + } + + const double frac = position - static_cast(index); + return (ascan.samples[index] * (1.0 - frac)) + (ascan.samples[index + 1U] * frac); +} + +[[nodiscard]] auto attenuation_components( + double r_tx, + double r_rx, + double z_m +) -> std::pair { + const double geo = 1.0 / ((r_tx * r_rx) + 1e-12); + const double angle = + std::pow(z_m / (r_tx + 1e-12), 2.0) * + std::pow(z_m / (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 std::vector& x_tx, + const std::vector& x_rx +) -> std::pair { + const double x_center = 0.5 * (x_tx[tx_index] + x_rx[rx_index]); + const double r_tx = std::hypot(x_center - x_tx[tx_index], kCompensationReferenceDepthM); + const double r_rx = std::hypot(x_center - x_rx[rx_index], kCompensationReferenceDepthM); + return attenuation_components(r_tx, r_rx, kCompensationReferenceDepthM); +} + +[[nodiscard]] auto compensation_weight( + double r_tx, + double r_rx, + double z_m, + double geo_ref, + double angle_ref, + double range_power, + double angle_power +) -> double { + const auto [geo, angle] = attenuation_components(r_tx, r_rx, z_m); + 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); +} + +[[nodiscard]] auto backproject_coherent( + const std::vector& selected_traces, + const std::unordered_map& ascans_by_pair, + const GridDefinition& grid, + const std::vector& x_tx, + const std::vector& x_rx, + double velocity_mps, + double min_depth_m, + double max_depth_m, + double range_power, + double angle_power +) -> BpMap { + const std::size_t width = grid.x_grid.size(); + const std::size_t height = grid.z_grid.size(); + const std::size_t cell_count = width * height; + + BpMap result{}; + result.image.assign(cell_count, 0.0); + result.coherent.assign(cell_count, std::complex(0.0, 0.0)); + std::vector contribution_count(cell_count, 0.0); + + for (const auto& trace : selected_traces) { + const auto key = make_pair_key(trace.tx_local_index, trace.rx_local_index); + const auto ascan_it = ascans_by_pair.find(key); + if (ascan_it == ascans_by_pair.end()) { + continue; + } + + const auto [geo_ref, angle_ref] = attenuation_components_at_ref_depth( + trace.tx_local_index, + trace.rx_local_index, + x_tx, + x_rx + ); + const auto& tx_distances = grid.tx_distance_grids[trace.tx_local_index]; + const auto& rx_distances = grid.rx_distance_grids[trace.rx_local_index]; + + for (std::size_t row = 0U; row < height; ++row) { + const double z_m = grid.z_grid[row]; + const bool in_depth_gate = z_m >= min_depth_m && z_m <= max_depth_m; + if (!in_depth_gate) { + continue; + } + + for (std::size_t col = 0U; col < width; ++col) { + const auto cell_index = (row * width) + col; + const double r_tx = tx_distances[cell_index]; + const double r_rx = rx_distances[cell_index]; + const double tau_s = (r_tx + r_rx) / velocity_mps; + const auto sample = interpolate_complex(ascan_it->second, tau_s); + if (sample == std::complex(0.0, 0.0)) { + continue; + } + + const double weight = compensation_weight( + r_tx, + r_rx, + z_m, + geo_ref, + angle_ref, + range_power, + angle_power + ); + result.coherent[cell_index] += sample * weight; + contribution_count[cell_index] += 1.0; + } + } + } + + for (std::size_t index = 0U; index < cell_count; ++index) { + if (!(contribution_count[index] > 0.0)) { + continue; + } + result.coherent[index] /= contribution_count[index]; + result.image[index] = std::abs(result.coherent[index]); + } + + return result; +} + +void apply_depth_gate( + std::vector& image, + const std::vector& z_grid, + std::size_t width, + double min_depth_m, + double max_depth_m +) { + for (std::size_t row = 0U; row < z_grid.size(); ++row) { + const double z_m = z_grid[row]; + if (z_m >= min_depth_m && z_m <= max_depth_m) { + continue; + } + const auto row_offset = row * width; + std::fill(image.begin() + static_cast(row_offset), + image.begin() + static_cast(row_offset + width), + 0.0); + } +} + +[[nodiscard]] auto neighbor_indices( + std::size_t index, + std::size_t width, + std::size_t height +) -> std::vector { + const auto row = index / width; + const auto col = index % width; + std::vector neighbors{}; + neighbors.reserve(8U); + + for (std::ptrdiff_t row_offset = -1; row_offset <= 1; ++row_offset) { + for (std::ptrdiff_t col_offset = -1; col_offset <= 1; ++col_offset) { + if (row_offset == 0 && col_offset == 0) { + continue; + } + const auto next_row = static_cast(row) + row_offset; + const auto next_col = static_cast(col) + col_offset; + if (next_row < 0 || next_col < 0) { + continue; + } + if (next_row >= static_cast(height) || next_col >= static_cast(width)) { + continue; + } + neighbors.push_back((static_cast(next_row) * width) + static_cast(next_col)); + } + } + + return neighbors; +} + +[[nodiscard]] auto component_containing_peak( + const std::vector& image, + std::size_t width, + std::size_t height, + std::size_t peak_index, + double threshold, + const std::vector* window_mask = nullptr +) -> std::vector { + std::vector result(image.size(), 0U); + if (image.empty() || peak_index >= image.size()) { + return result; + } + if (image[peak_index] < threshold || (window_mask != nullptr && (*window_mask)[peak_index] == 0U)) { + result[peak_index] = 1U; + return result; + } + + std::queue queue{}; + result[peak_index] = 1U; + queue.push(peak_index); + + while (!queue.empty()) { + const auto current = queue.front(); + queue.pop(); + + for (const auto neighbor : neighbor_indices(current, width, height)) { + if (result[neighbor] != 0U) { + continue; + } + if (window_mask != nullptr && (*window_mask)[neighbor] == 0U) { + continue; + } + if (image[neighbor] < threshold) { + continue; + } + result[neighbor] = 1U; + queue.push(neighbor); + } + } + + return result; +} + +[[nodiscard]] auto mask_count(const std::vector& mask) -> std::size_t { + return static_cast(std::count(mask.begin(), mask.end(), static_cast(1U))); +} + +[[nodiscard]] auto weighted_centroid( + const std::vector& image, + const std::vector& mask, + const GridDefinition& grid, + double threshold +) -> std::pair { + double weight_sum = 0.0; + double x_weighted_sum = 0.0; + double z_weighted_sum = 0.0; + + for (std::size_t index = 0U; index < image.size(); ++index) { + if (mask[index] == 0U) { + continue; + } + double weight = std::max(image[index] - threshold, 0.0); + if (!(weight > 0.0)) { + weight = image[index]; + } + if (!(weight > 0.0)) { + continue; + } + + const auto row = index / grid.x_grid.size(); + const auto col = index % grid.x_grid.size(); + weight_sum += weight; + x_weighted_sum += grid.x_grid[col] * weight; + z_weighted_sum += grid.z_grid[row] * weight; + } + + if (!(weight_sum > 0.0)) { + const auto found = std::find(mask.begin(), mask.end(), static_cast(1U)); + if (found == mask.end()) { + return {0.0, 0.0}; + } + const auto index = static_cast(std::distance(mask.begin(), found)); + return {grid.x_grid[index % grid.x_grid.size()], grid.z_grid[index / grid.x_grid.size()]}; + } + + return {x_weighted_sum / weight_sum, z_weighted_sum / weight_sum}; +} + +[[nodiscard]] auto compact_peak_centroid( + const std::vector& image, + const GridDefinition& grid, + std::size_t peak_index, + double peak +) -> std::tuple> { + const std::size_t width = grid.x_grid.size(); + const auto peak_row = peak_index / width; + const auto peak_col = peak_index % width; + const double x_peak = grid.x_grid[peak_col]; + const double z_peak = grid.z_grid[peak_row]; + const double threshold = kCenterThresholdFrac * peak; + + std::vector center_mask(image.size(), 0U); + for (std::size_t row = 0U; row < grid.z_grid.size(); ++row) { + const double z_m = grid.z_grid[row]; + if (std::abs(z_m - z_peak) > kCenterRadiusZM) { + continue; + } + for (std::size_t col = 0U; col < grid.x_grid.size(); ++col) { + const double x_m = grid.x_grid[col]; + const auto index = (row * width) + col; + if (std::abs(x_m - x_peak) <= kCenterRadiusXM && image[index] >= threshold) { + center_mask[index] = 1U; + } + } + } + + if (mask_count(center_mask) == 0U) { + center_mask[peak_index] = 1U; + } + + double weight_sum = 0.0; + double x_weighted_sum = 0.0; + double z_weighted_sum = 0.0; + for (std::size_t index = 0U; index < image.size(); ++index) { + if (center_mask[index] == 0U) { + continue; + } + double weight = std::pow(std::max(image[index] - threshold, 0.0), kCenterWeightPower); + if (!(weight > 0.0)) { + weight = image[index]; + } + if (!(weight > 0.0)) { + continue; + } + + const auto row = index / width; + const auto col = index % width; + weight_sum += weight; + x_weighted_sum += grid.x_grid[col] * weight; + z_weighted_sum += grid.z_grid[row] * weight; + } + + if (!(weight_sum > 0.0)) { + return {x_peak, z_peak, std::move(center_mask)}; + } + return {x_weighted_sum / weight_sum, z_weighted_sum / weight_sum, std::move(center_mask)}; +} + +[[nodiscard]] auto build_window_mask( + const GridDefinition& grid, + double x_center_m, + double z_center_m, + double radius_x_m, + double radius_z_m +) -> std::vector { + const std::size_t width = grid.x_grid.size(); + std::vector mask(width * grid.z_grid.size(), 0U); + for (std::size_t row = 0U; row < grid.z_grid.size(); ++row) { + if (std::abs(grid.z_grid[row] - z_center_m) > radius_z_m) { + continue; + } + for (std::size_t col = 0U; col < grid.x_grid.size(); ++col) { + if (std::abs(grid.x_grid[col] - x_center_m) <= radius_x_m) { + mask[(row * width) + col] = 1U; + } + } + } + return mask; +} + +[[nodiscard]] auto find_bp_objects( + const std::vector& bp_image, + const GridDefinition& grid +) -> std::vector { + std::vector objects{}; + if (bp_image.empty() || grid.x_grid.size() < 2U || grid.z_grid.size() < 2U) { + return objects; + } + + std::vector work = bp_image; + const double global_peak = max_value(work); + const double stop_level = kObjectMinFrac * global_peak; + if (!(global_peak > 0.0)) { + return objects; + } + + const std::size_t width = grid.x_grid.size(); + const std::size_t height = grid.z_grid.size(); + const double dx_cm = std::abs(grid.x_grid[1] - grid.x_grid[0]) * 100.0; + const double dz_cm = std::abs(grid.z_grid[1] - grid.z_grid[0]) * 100.0; + const double pixel_area_cm2 = dx_cm * dz_cm; + + for (std::size_t step = 0U; step < kMaxObjects; ++step) { + const auto peak_it = std::max_element(work.begin(), work.end()); + const double peak = *peak_it; + if (peak <= stop_level || !(peak > 0.0)) { + break; + } + + const auto peak_index = static_cast(std::distance(work.begin(), peak_it)); + const auto peak_row = peak_index / width; + const auto peak_col = peak_index % width; + const double x_peak = grid.x_grid[peak_col]; + const double z_peak = grid.z_grid[peak_row]; + + const double region_threshold = kRegionThresholdFrac * peak; + auto region_mask = component_containing_peak(work, width, height, peak_index, region_threshold); + const double region_area_cm2 = static_cast(mask_count(region_mask)) * pixel_area_cm2; + if (region_area_cm2 < kMinRegionAreaCm2) { + work[peak_index] = 0.0; + continue; + } + + const auto [x_region, z_region] = weighted_centroid(work, region_mask, grid, region_threshold); + auto [x_center, z_center, center_mask] = compact_peak_centroid(work, grid, peak_index, peak); + const double center_area_cm2 = static_cast(mask_count(center_mask)) * pixel_area_cm2; + + double sum_value = 0.0; + std::size_t value_count = 0U; + for (std::size_t index = 0U; index < work.size(); ++index) { + if (region_mask[index] == 0U) { + continue; + } + sum_value += work[index]; + value_count += 1U; + } + + ObjectRecord object{}; + object.index = objects.size() + 1U; + object.x_peak_m = x_peak; + object.z_peak_m = z_peak; + object.x_m = x_center; + object.z_m = z_center; + object.x_region_m = x_region; + object.z_region_m = z_region; + object.peak = peak; + object.area_cm2 = region_area_cm2; + object.center_area_cm2 = center_area_cm2; + object.mean_value = value_count > 0U ? sum_value / static_cast(value_count) : 0.0; + object.sum_value = sum_value; + object.region_mask = std::move(region_mask); + object.center_mask = std::move(center_mask); + objects.push_back(std::move(object)); + + const auto suppress_window = build_window_mask(grid, x_peak, z_peak, kSuppressRadiusXM, kSuppressRadiusZM); + auto suppress_mask = component_containing_peak( + work, + width, + height, + peak_index, + kSuppressThresholdFrac * peak, + &suppress_window + ); + if (mask_count(suppress_mask) < mask_count(objects.back().region_mask)) { + suppress_mask = objects.back().region_mask; + } + + for (std::size_t index = 0U; index < work.size(); ++index) { + if (suppress_mask[index] != 0U) { + work[index] = 0.0; + } + } + } + + return objects; +} + +[[nodiscard]] auto bistatic_depth_signature( + const ObjectRecord& object, + const std::vector& selected_traces, + const std::vector& x_tx, + const std::vector& x_rx +) -> std::vector { + std::vector signature{}; + signature.reserve(selected_traces.size()); + for (const auto& trace : selected_traces) { + const double r_tx = std::hypot(object.x_m - x_tx[trace.tx_local_index], object.z_m); + const double r_rx = std::hypot(object.x_m - x_rx[trace.rx_local_index], object.z_m); + signature.push_back(0.5 * (r_tx + r_rx)); + } + return signature; +} + +void mark_sidelobe_candidates( + std::vector& objects, + const std::vector& selected_traces, + const std::vector& x_tx, + const std::vector& x_rx +) { + std::vector> signatures{}; + signatures.reserve(objects.size()); + for (const auto& object : objects) { + signatures.push_back(bistatic_depth_signature(object, selected_traces, x_tx, x_rx)); + } + + for (std::size_t object_index = 0U; object_index < objects.size(); ++object_index) { + auto& object = objects[object_index]; + std::size_t best_parent = 0U; + double best_rms = std::numeric_limits::infinity(); + + for (std::size_t parent_index = 0U; parent_index < object_index; ++parent_index) { + const auto& parent = objects[parent_index]; + const double relative_peak = object.peak / (parent.peak + 1e-12); + const double dx_m = std::abs(object.x_m - parent.x_m); + const double dz_m = std::abs(object.z_m - parent.z_m); + + double squared_sum = 0.0; + const auto& object_signature = signatures[object_index]; + const auto& parent_signature = signatures[parent_index]; + for (std::size_t index = 0U; index < object_signature.size(); ++index) { + const double delta = object_signature[index] - parent_signature[index]; + squared_sum += delta * delta; + } + const double rms = object_signature.empty() + ? std::numeric_limits::infinity() + : std::sqrt(squared_sum / static_cast(object_signature.size())); + + const bool candidate = + relative_peak <= kSidelobeMaxRelativePeak && + dx_m >= kSidelobeMinDxM && + dz_m <= kSidelobeMaxDzM && + rms <= kSidelobeRangeRmsToleranceM; + + if (candidate && rms < best_rms) { + best_parent = parent.index; + best_rms = rms; + } + } + + if (best_parent != 0U) { + object.sidelobe_candidate = true; + object.sidelobe_parent = best_parent; + object.sidelobe_range_rms_m = best_rms; + } + } +} + +[[nodiscard]] auto flatten_table( + const std::vector>& rows, + std::uint32_t column_count +) -> std::vector { + std::vector values{}; + values.reserve(rows.size() * column_count); + for (const auto& row : rows) { + values.insert(values.end(), row.begin(), row.end()); + } + return values; +} + +[[nodiscard]] auto build_table_payload( + const std::string& processing_name, + const std::vector>& rows, + std::uint32_t column_count +) -> ipc::ResultPayload { + ipc::ResultPayload payload{}; + payload.processing_name = processing_name; + payload.kind = ipc::ResultKind::TableF32; + payload.table_columns = column_count; + payload.table_values = flatten_table(rows, column_count); + return payload; +} + +[[nodiscard]] auto build_image_payload( + const std::string& processing_name, + const std::vector& x_axis, + const std::vector& y_axis, + const std::vector& values +) -> ipc::ResultPayload { + ipc::ResultPayload payload{}; + payload.processing_name = processing_name; + payload.kind = ipc::ResultKind::ImageF32; + payload.image_x_axis.reserve(x_axis.size()); + for (const auto value : x_axis) { + payload.image_x_axis.push_back(static_cast(value)); + } + payload.image_y_axis.reserve(y_axis.size()); + for (const auto value : y_axis) { + payload.image_y_axis.push_back(static_cast(value)); + } + payload.image_values.reserve(values.size()); + for (const auto value : values) { + payload.image_values.push_back(static_cast(value)); + } + return payload; +} + +[[nodiscard]] auto process_backprojection_gpr( + const config::RunConfig& run_config, + const ipc::PreprocessedCollection& collection, + std::span previous_collections, + const ProcessingLiveConfig& live_config +) -> ipc::ResultCollection { + ipc::ResultCollection results{}; + results.collection_id = collection.collection_id; + results.monotonic_ns = collection.monotonic_ns; + + const auto selection = build_geometry_selection(run_config, live_config); + if (selection.input_positions.empty() || selection.output_positions.empty()) { + return results; + } + + validate_collection_trace_order(run_config, collection); + const auto background_mean = build_background_mean(previous_collections, selection, live_config); + const auto selected_traces = collect_selected_traces(collection, selection, background_mean); + if (selected_traces.empty()) { + return results; + } + + const double velocity_mps = + kSpeedOfLightMetersPerSec / std::sqrt(std::max(1e-6, static_cast(run_config.gpr.relative_permittivity))); + 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); + const double max_depth_m = static_cast(live_config.gpr_max_depth_m); + if (!(max_depth_m > min_depth_m) || !(max_depth_m > kGridZMinM)) { + return results; + } + + std::unordered_map ascans_by_pair{}; + for (const auto& trace : selected_traces) { + auto ascan = compute_ascan(trace, start_hz, stop_hz); + if (ascan.samples.empty() || !(ascan.bandwidth_hz > 0.0) || !(ascan.dt_s > 0.0)) { + continue; + } + ascans_by_pair.emplace( + make_pair_key(trace.tx_local_index, trace.rx_local_index), + std::move(ascan) + ); + } + if (ascans_by_pair.empty()) { + return results; + } + + const auto grid = build_grid(selection.x_tx, selection.x_rx, max_depth_m, kGridZMinM); + if (grid.x_grid.empty() || grid.z_grid.empty()) { + return results; + } + + auto bp = backproject_coherent( + selected_traces, + ascans_by_pair, + grid, + selection.x_tx, + selection.x_rx, + velocity_mps, + 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)) + ); + if (bp.image.empty()) { + return results; + } + + normalize_in_place(bp.image); + auto display_map = gaussian_filter_2d(bp.image, grid.x_grid.size(), grid.z_grid.size(), kSmoothSigma); + apply_depth_gate(display_map, grid.z_grid, grid.x_grid.size(), min_depth_m, max_depth_m); + normalize_in_place(display_map); + + auto objects = find_bp_objects(display_map, grid); + mark_sidelobe_candidates(objects, selected_traces, selection.x_tx, selection.x_rx); + + if (live_config.gpr_remove_sidelobe_objects_enabled) { + for (const auto& object : objects) { + if (!object.sidelobe_candidate) { + continue; + } + for (std::size_t index = 0U; index < display_map.size(); ++index) { + if (object.region_mask[index] != 0U) { + display_map[index] = 0.0; + } + } + } + } + + results.collection_payloads.push_back(build_image_payload("gpr_accumulator", grid.x_grid, grid.z_grid, display_map)); + + std::vector> point_rows{}; + point_rows.reserve(objects.size()); + for (const auto& object : objects) { + if (live_config.gpr_remove_sidelobe_objects_enabled && object.sidelobe_candidate) { + continue; + } + point_rows.push_back( + { + static_cast(object.x_m), + static_cast(object.z_m), + static_cast(object.peak), + } + ); + } + results.collection_payloads.push_back(build_table_payload("gpr_points", point_rows, 3U)); + + return results; +} diff --git a/data_acq_and_processing/processing/processors/src/gpr_legacy_processor.ipp b/data_acq_and_processing/processing/processors/src/gpr_legacy_processor.ipp new file mode 100644 index 0000000..57833b0 --- /dev/null +++ b/data_acq_and_processing/processing/processors/src/gpr_legacy_processor.ipp @@ -0,0 +1,827 @@ +constexpr double kLegacyGridZMinM = 0.20; +constexpr double kLegacySmoothSigma = 3.0; +constexpr double kLegacyCleanSuppressRadiusM = 0.07; +constexpr double kLegacyCleanThresholdFrac = 0.05; +constexpr std::size_t kLegacyMaxObjects = 15U; +constexpr double kLegacyExtendedThresholdFrac = 0.75; +constexpr double kLegacyExtendedMinAreaCm2 = 2.0; + +struct LegacyAscanResult { + std::vector time_s{}; + std::vector depth_m{}; + std::vector amplitude{}; + double bandwidth_hz = 0.0; +}; + +struct LegacyPeakRecord { + double z_app = 0.0; + double tau = 0.0; + double tau_corr = 0.0; + double z_corr = 0.0; + double snr_raw = 0.0; + double snr_comp = 0.0; +}; + +struct LegacyPointRecord { + double x_m = 0.0; + double z_m = 0.0; + double score = 0.0; +}; + +struct LegacyRegionRecord { + double x_m = 0.0; + double z_m = 0.0; + double score = 0.0; + double pixel_count = 0.0; + std::vector mask{}; +}; + +struct LegacyPairTiming { + double dtau_motion_s = 0.0; +}; + +enum class LegacyPeakDomain { + Apparent, + Corrected, +}; + +[[nodiscard]] auto find_legacy_peak_indices( + const std::vector& values, + std::size_t start_index, + std::size_t stop_index, + double threshold, + std::size_t min_distance +) -> std::vector { + if (stop_index <= start_index + 2U) { + return {}; + } + + std::vector candidates{}; + for (std::size_t index = start_index + 1U; index + 1U < stop_index; ++index) { + const bool below_threshold = values[index] < threshold; + const bool not_rising = values[index] <= values[index - 1U]; + const bool still_rising = values[index] < values[index + 1U]; + if (below_threshold || not_rising || still_rising) { + continue; + } + candidates.push_back(index); + } + + std::sort(candidates.begin(), candidates.end(), [&](std::size_t left, std::size_t right) { + return values[left] > values[right]; + }); + + std::vector selected{}; + for (const auto index : candidates) { + bool keep = true; + for (const auto accepted : selected) { + const auto distance = accepted > index ? accepted - index : index - accepted; + if (distance < min_distance) { + keep = false; + break; + } + } + if (keep) { + selected.push_back(index); + } + } + + std::sort(selected.begin(), selected.end()); + return selected; +} + +[[nodiscard]] auto legacy_attenuation_at_depth( + std::size_t tx_index, + std::size_t rx_index, + double z_app, + const std::vector& x_tx, + const std::vector& x_rx +) -> double { + const double x_center = 0.5 * (x_tx[tx_index] + x_rx[rx_index]); + const double r_tx = std::hypot(x_center - x_tx[tx_index], z_app); + const double r_rx = std::hypot(x_center - x_rx[rx_index], z_app); + const double geo = 1.0 / ((r_tx * r_rx) + 1e-12); + const double pattern = + std::pow(z_app / (r_tx + 1e-12), 2.0) * + std::pow(z_app / (r_rx + 1e-12), 2.0); + return (geo * pattern) + 1e-30; +} + +[[nodiscard]] auto lower_bound_index(const std::vector& axis, double value) -> std::size_t { + const auto found = std::lower_bound(axis.begin(), axis.end(), value); + return static_cast(std::distance(axis.begin(), found)); +} + +[[nodiscard]] auto compute_legacy_ascan( + const SelectedTrace& trace, + double start_hz, + double stop_hz, + double velocity_mps +) -> LegacyAscanResult { + LegacyAscanResult result{}; + if (trace.frequency_hz.size() != trace.s21.size()) { + return result; + } + + const double low_hz = std::min(start_hz, stop_hz); + const double high_hz = std::max(start_hz, stop_hz); + std::vector frequency_hz{}; + std::vector> s21{}; + frequency_hz.reserve(trace.frequency_hz.size()); + s21.reserve(trace.s21.size()); + for (std::size_t index = 0U; index < trace.frequency_hz.size(); ++index) { + const double frequency_value = trace.frequency_hz[index]; + if (frequency_value < low_hz || frequency_value > high_hz) { + continue; + } + frequency_hz.push_back(frequency_value); + s21.push_back(trace.s21[index]); + } + + if (frequency_hz.size() < 2U) { + return result; + } + + const std::size_t point_count = frequency_hz.size(); + const double df_hz = (frequency_hz.back() - frequency_hz.front()) / static_cast(point_count - 1U); + if (!(df_hz > 0.0)) { + return result; + } + + const auto start_bin = static_cast(std::llround(frequency_hz.front() / df_hz)); + if (start_bin < 0) { + return result; + } + + const auto start_index = static_cast(start_bin); + const std::size_t min_fft_len = 2U * (start_index + point_count - 1U); + const std::size_t fft_len = next_power_of_two(min_fft_len); + if (fft_len < min_fft_len || start_index > fft_len || point_count > (fft_len - start_index)) { + return result; + } + + std::vector> spectrum(fft_len, std::complex(0.0, 0.0)); + for (std::size_t index = 0U; index < point_count; ++index) { + const double window = point_count > 1U + ? 0.5 - (0.5 * std::cos((2.0 * kPi * static_cast(index)) / static_cast(point_count - 1U))) + : 1.0; + spectrum[start_index + index] = s21[index] * window; + } + + fft_inplace(spectrum, true); + + result.bandwidth_hz = frequency_hz.back() - frequency_hz.front(); + const double dt_s = 1.0 / (static_cast(fft_len) * df_hz); + result.time_s.resize(fft_len, 0.0); + result.depth_m.resize(fft_len, 0.0); + result.amplitude.resize(fft_len, 0.0); + for (std::size_t index = 0U; index < fft_len; ++index) { + result.time_s[index] = static_cast(index) * dt_s; + result.depth_m[index] = result.time_s[index] * velocity_mps * 0.5; + result.amplitude[index] = std::abs(spectrum[index]); + } + return result; +} + +[[nodiscard]] auto legacy_peak_depth_for_domain(const LegacyPeakRecord& peak, LegacyPeakDomain domain) -> double { + return domain == LegacyPeakDomain::Corrected ? peak.z_corr : peak.z_app; +} + +[[nodiscard]] auto legacy_peak_tau_for_domain(const LegacyPeakRecord& peak, LegacyPeakDomain domain) -> double { + return domain == LegacyPeakDomain::Corrected ? peak.tau_corr : peak.tau; +} + +[[nodiscard]] auto is_legacy_depth_excluded( + double z_value, + const std::vector>& ranges +) -> bool { + for (const auto& [low, high] : ranges) { + if (z_value >= low && z_value <= high) { + return true; + } + } + return false; +} + +[[nodiscard]] auto build_legacy_motion_timing_by_pair( + const std::vector& traces, + std::size_t total_combo_count, + std::uint64_t capture_start_ns, + std::uint64_t capture_end_ns, + const ProcessingLiveConfig& live_config, + double velocity_mps +) -> std::unordered_map { + std::unordered_map timing_by_pair{}; + timing_by_pair.reserve(traces.size()); + + const double speed_mps = static_cast(live_config.gpr_speed_m_s); + if (!(std::abs(speed_mps) > 1e-12)) { + for (const auto& trace : traces) { + timing_by_pair.emplace(make_pair_key(trace.tx_local_index, trace.rx_local_index), LegacyPairTiming{}); + } + return timing_by_pair; + } + + if (total_combo_count == 0U) { + throw std::runtime_error("Legacy GPR requires at least one run combo"); + } + if (capture_end_ns <= capture_start_ns) { + throw std::runtime_error( + "Legacy GPR requires valid capture_start_ns/capture_end_ns metadata when speed is non-zero" + ); + } + + const double capture_span_s = static_cast(capture_end_ns - capture_start_ns) * 1e-9; + const double slot_duration_s = capture_span_s / static_cast(total_combo_count); + if (!(slot_duration_s > 0.0)) { + throw std::runtime_error("Legacy GPR requires positive collection capture span when speed is non-zero"); + } + + const double t_ref_s = 0.5 * capture_span_s; + const double cos_theta = std::cos((static_cast(live_config.gpr_look_angle_deg) * kPi) / 180.0); + for (const auto& trace : traces) { + const double t_center_s = (static_cast(trace.run_order) + 0.5) * slot_duration_s; + const double dz_motion_m = speed_mps * (t_center_s - t_ref_s) * cos_theta; + timing_by_pair.emplace( + make_pair_key(trace.tx_local_index, trace.rx_local_index), + LegacyPairTiming{.dtau_motion_s = (2.0 * dz_motion_m) / velocity_mps} + ); + } + + return timing_by_pair; +} + +void apply_legacy_motion_correction( + std::unordered_map>& peaks_by_pair, + const std::unordered_map& timing_by_pair, + double velocity_mps +) { + for (auto& [key, peaks] : peaks_by_pair) { + const auto timing_it = timing_by_pair.find(key); + if (timing_it == timing_by_pair.end()) { + throw std::runtime_error("Missing motion timing for selected legacy GPR combo"); + } + + for (auto& peak : peaks) { + peak.tau_corr = peak.tau + timing_it->second.dtau_motion_s; + peak.z_corr = 0.5 * velocity_mps * peak.tau_corr; + } + } +} + +[[nodiscard]] auto build_legacy_accumulator( + const GridDefinition& grid, + const std::unordered_map>& peaks_by_pair, + const std::vector>& exclude_ranges, + double velocity_mps, + double shell_sigma_m, + const std::vector& x_tx, + const std::vector& x_rx, + LegacyPeakDomain domain +) -> std::vector { + const std::size_t width = grid.x_grid.size(); + const std::size_t height = grid.z_grid.size(); + std::vector accumulator(width * height, 0.0); + if (!(shell_sigma_m > 0.0)) { + return accumulator; + } + + for (std::size_t tx_index = 0U; tx_index < x_tx.size(); ++tx_index) { + for (std::size_t rx_index = 0U; rx_index < x_rx.size(); ++rx_index) { + const auto peak_it = peaks_by_pair.find( + make_pair_key(static_cast(tx_index), static_cast(rx_index)) + ); + if (peak_it == peaks_by_pair.end()) { + continue; + } + + const auto& tx_grid = grid.tx_distance_grids[tx_index]; + const auto& rx_grid = grid.rx_distance_grids[rx_index]; + for (const auto& peak : peak_it->second) { + if (is_legacy_depth_excluded(legacy_peak_depth_for_domain(peak, domain), exclude_ranges)) { + continue; + } + + const double range_total = velocity_mps * legacy_peak_tau_for_domain(peak, domain); + for (std::size_t cell_index = 0U; cell_index < accumulator.size(); ++cell_index) { + const double residual = tx_grid[cell_index] + rx_grid[cell_index] - range_total; + const double shell = std::exp(-0.5 * std::pow(residual / shell_sigma_m, 2.0)); + accumulator[cell_index] += shell * peak.snr_comp; + } + } + } + } + + return accumulator; +} + +[[nodiscard]] auto count_legacy_agreeing_ellipses( + double x_est, + double z_est, + const std::unordered_map>& peaks_by_pair, + const std::vector>& exclude_ranges, + const std::vector& x_tx, + const std::vector& x_rx, + double velocity_mps, + double shell_sigma_m, + LegacyPeakDomain domain +) -> double { + std::size_t count = 0U; + for (std::size_t tx_index = 0U; tx_index < x_tx.size(); ++tx_index) { + for (std::size_t rx_index = 0U; rx_index < x_rx.size(); ++rx_index) { + const auto peak_it = peaks_by_pair.find( + make_pair_key(static_cast(tx_index), static_cast(rx_index)) + ); + if (peak_it == peaks_by_pair.end()) { + continue; + } + + for (const auto& peak : peak_it->second) { + if (is_legacy_depth_excluded(legacy_peak_depth_for_domain(peak, domain), exclude_ranges)) { + continue; + } + + const double r_tx = std::hypot(x_est - x_tx[tx_index], z_est); + const double r_rx = std::hypot(x_est - x_rx[rx_index], z_est); + if (std::abs((r_tx + r_rx) - (velocity_mps * legacy_peak_tau_for_domain(peak, domain))) < + shell_sigma_m * 6.0) { + count += 1U; + break; + } + } + } + } + + return static_cast(count); +} + +[[nodiscard]] auto find_legacy_centroid( + const std::vector& values, + std::size_t width, + std::size_t height, + std::size_t row, + std::size_t col, + std::size_t radius_z, + std::size_t radius_x, + const std::vector& x_grid, + const std::vector& z_grid +) -> std::pair { + if (values.empty()) { + return {x_grid[col], z_grid[row]}; + } + + const auto row_start = row > radius_z ? row - radius_z : 0U; + const auto row_stop = std::min(height, row + radius_z + 1U); + const auto col_start = col > radius_x ? col - radius_x : 0U; + const auto col_stop = std::min(width, col + radius_x + 1U); + + double weight_sum = 0.0; + double row_weighted_sum = 0.0; + double col_weighted_sum = 0.0; + for (std::size_t sample_row = row_start; sample_row < row_stop; ++sample_row) { + for (std::size_t sample_col = col_start; sample_col < col_stop; ++sample_col) { + const double weight = values[(sample_row * width) + sample_col]; + weight_sum += weight; + row_weighted_sum += static_cast(sample_row) * weight; + col_weighted_sum += static_cast(sample_col) * weight; + } + } + + if (!(weight_sum > 0.0)) { + return {x_grid[col], z_grid[row]}; + } + + const auto centroid_row = + clamp_index(static_cast(std::llround(row_weighted_sum / weight_sum)), height); + const auto centroid_col = + clamp_index(static_cast(std::llround(col_weighted_sum / weight_sum)), width); + return {x_grid[centroid_col], z_grid[centroid_row]}; +} + +[[nodiscard]] auto clean_legacy_find_points( + const GridDefinition& grid, + const std::unordered_map>& peaks_by_pair, + const std::vector& x_tx, + const std::vector& x_rx, + double velocity_mps, + double shell_sigma_m, + LegacyPeakDomain domain +) -> std::pair, std::vector> { + std::vector found{}; + const auto accumulator = + build_legacy_accumulator(grid, peaks_by_pair, {}, velocity_mps, shell_sigma_m, x_tx, x_rx, domain); + const double initial_max = max_value(accumulator); + if (!(initial_max > 0.0) || grid.x_grid.size() < 2U || grid.z_grid.size() < 2U) { + return {found, gaussian_filter_2d(accumulator, grid.x_grid.size(), grid.z_grid.size(), kLegacySmoothSigma)}; + } + + const double dx = grid.x_grid[1] - grid.x_grid[0]; + const double dz = grid.z_grid[1] - grid.z_grid[0]; + const auto radius_x = + static_cast(std::max(1.0, std::round(kLegacyCleanSuppressRadiusM / std::max(dx, 1e-6)))); + const auto radius_z = + static_cast(std::max(1.0, std::round(kLegacyCleanSuppressRadiusM / std::max(dz, 1e-6)))); + + std::vector> excluded_ranges{}; + for (std::size_t step = 0U; step < kLegacyMaxObjects; ++step) { + const auto current = build_legacy_accumulator( + grid, + peaks_by_pair, + excluded_ranges, + velocity_mps, + shell_sigma_m, + x_tx, + x_rx, + domain + ); + const auto smoothed = gaussian_filter_2d(current, grid.x_grid.size(), grid.z_grid.size(), kLegacySmoothSigma); + const double smoothed_max = max_value(smoothed); + if (!(smoothed_max > (kLegacyCleanThresholdFrac * initial_max))) { + break; + } + + const auto max_it = std::max_element(smoothed.begin(), smoothed.end()); + const auto max_index = static_cast(std::distance(smoothed.begin(), max_it)); + const auto peak_row = max_index / grid.x_grid.size(); + const auto peak_col = max_index % grid.x_grid.size(); + const auto [x_est, z_est] = find_legacy_centroid( + smoothed, + grid.x_grid.size(), + grid.z_grid.size(), + peak_row, + peak_col, + radius_z, + radius_x, + grid.x_grid, + grid.z_grid + ); + + found.push_back( + LegacyPointRecord{ + .x_m = x_est, + .z_m = z_est, + .score = count_legacy_agreeing_ellipses( + x_est, + z_est, + peaks_by_pair, + excluded_ranges, + x_tx, + x_rx, + velocity_mps, + shell_sigma_m, + domain + ), + } + ); + + std::vector matched_depths{}; + for (std::size_t tx_index = 0U; tx_index < x_tx.size(); ++tx_index) { + for (std::size_t rx_index = 0U; rx_index < x_rx.size(); ++rx_index) { + const auto peak_it = peaks_by_pair.find( + make_pair_key(static_cast(tx_index), static_cast(rx_index)) + ); + if (peak_it == peaks_by_pair.end()) { + continue; + } + for (const auto& peak : peak_it->second) { + if (is_legacy_depth_excluded(legacy_peak_depth_for_domain(peak, domain), excluded_ranges)) { + continue; + } + const double r_tx = std::hypot(x_est - x_tx[tx_index], z_est); + const double r_rx = std::hypot(x_est - x_rx[rx_index], z_est); + if (std::abs((r_tx + r_rx) - (velocity_mps * legacy_peak_tau_for_domain(peak, domain))) < + shell_sigma_m * 3.0) { + matched_depths.push_back(legacy_peak_depth_for_domain(peak, domain)); + } + } + } + } + + if (!matched_depths.empty()) { + const auto [min_it, max_it_depth] = std::minmax_element(matched_depths.begin(), matched_depths.end()); + excluded_ranges.emplace_back(*min_it - shell_sigma_m, *max_it_depth + shell_sigma_m); + } + } + + return {found, gaussian_filter_2d(accumulator, grid.x_grid.size(), grid.z_grid.size(), kLegacySmoothSigma)}; +} + +[[nodiscard]] auto extended_legacy_find_regions( + const GridDefinition& grid, + const std::unordered_map>& peaks_by_pair, + const std::vector& x_tx, + const std::vector& x_rx, + double velocity_mps, + double shell_sigma_m +) -> std::pair, std::vector> { + std::vector regions{}; + const auto accumulator = build_legacy_accumulator( + grid, + peaks_by_pair, + {}, + velocity_mps, + shell_sigma_m, + x_tx, + x_rx, + LegacyPeakDomain::Apparent + ); + const auto smoothed = gaussian_filter_2d(accumulator, grid.x_grid.size(), grid.z_grid.size(), kLegacySmoothSigma); + const double smoothed_max = max_value(smoothed); + if (!(smoothed_max > 0.0) || grid.x_grid.size() < 2U || grid.z_grid.size() < 2U) { + return {regions, smoothed}; + } + + const double dx_cm = std::abs(grid.x_grid[1] - grid.x_grid[0]) * 100.0; + const double dz_cm = std::abs(grid.z_grid[1] - grid.z_grid[0]) * 100.0; + const double pixel_area_cm2 = std::max(dx_cm * dz_cm, 1e-6); + const auto min_pixels = + static_cast(std::max(1.0, std::floor(kLegacyExtendedMinAreaCm2 / pixel_area_cm2))); + + const std::size_t width = grid.x_grid.size(); + const std::size_t height = grid.z_grid.size(); + const double threshold = kLegacyExtendedThresholdFrac * smoothed_max; + std::vector visited(width * height, 0U); + + for (std::size_t row = 0U; row < height; ++row) { + for (std::size_t col = 0U; col < width; ++col) { + const auto start_index = (row * width) + col; + if (visited[start_index] != 0U || smoothed[start_index] <= threshold) { + continue; + } + + std::vector stack{start_index}; + std::vector component{}; + visited[start_index] = 1U; + + while (!stack.empty()) { + const auto cell_index = stack.back(); + stack.pop_back(); + component.push_back(cell_index); + + const auto cell_row = cell_index / width; + const auto cell_col = cell_index % width; + const std::pair offsets[] = { + {-1, 0}, + {1, 0}, + {0, -1}, + {0, 1}, + }; + + for (const auto& [row_offset, col_offset] : offsets) { + const auto next_row = static_cast(cell_row) + row_offset; + const auto next_col = static_cast(cell_col) + col_offset; + if (next_row < 0 || next_col < 0) { + continue; + } + const bool row_out_of_bounds = next_row >= static_cast(height); + const bool col_out_of_bounds = next_col >= static_cast(width); + if (row_out_of_bounds || col_out_of_bounds) { + continue; + } + const auto next_index = + (static_cast(next_row) * width) + static_cast(next_col); + if (visited[next_index] != 0U || smoothed[next_index] <= threshold) { + continue; + } + visited[next_index] = 1U; + stack.push_back(next_index); + } + } + + if (component.size() < min_pixels) { + continue; + } + + LegacyRegionRecord region{}; + region.mask.assign(width * height, 0.0F); + double weight_sum = 0.0; + double x_weight_sum = 0.0; + double z_weight_sum = 0.0; + for (const auto cell_index : component) { + const auto cell_row = cell_index / width; + const auto cell_col = cell_index % width; + const double weight = smoothed[cell_index]; + weight_sum += weight; + x_weight_sum += grid.x_grid[cell_col] * weight; + z_weight_sum += grid.z_grid[cell_row] * weight; + region.mask[cell_index] = 1.0F; + } + + if (!(weight_sum > 0.0)) { + continue; + } + + region.x_m = x_weight_sum / weight_sum; + region.z_m = z_weight_sum / weight_sum; + region.score = count_legacy_agreeing_ellipses( + region.x_m, + region.z_m, + peaks_by_pair, + {}, + x_tx, + x_rx, + velocity_mps, + shell_sigma_m, + LegacyPeakDomain::Apparent + ); + region.pixel_count = static_cast(component.size()); + regions.push_back(std::move(region)); + } + } + + return {regions, smoothed}; +} + +[[nodiscard]] auto process_legacy_gpr( + const config::RunConfig& run_config, + const ipc::PreprocessedCollection& collection, + std::span previous_collections, + const ProcessingLiveConfig& live_config +) -> ipc::ResultCollection { + ipc::ResultCollection results{}; + results.collection_id = collection.collection_id; + results.monotonic_ns = collection.monotonic_ns; + + const auto selection = build_geometry_selection(run_config, live_config); + if (selection.input_positions.empty() || selection.output_positions.empty()) { + return results; + } + + validate_collection_trace_order(run_config, collection); + const auto background_mean = build_background_mean(previous_collections, selection, live_config); + const auto selected_traces = collect_selected_traces(collection, selection, background_mean); + if (selected_traces.empty()) { + return results; + } + + const double velocity_mps = + kSpeedOfLightMetersPerSec / std::sqrt(std::max(1e-6, static_cast(run_config.gpr.relative_permittivity))); + 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); + const double max_depth_m = static_cast(live_config.gpr_max_depth_m); + if (!(max_depth_m > min_depth_m)) { + return results; + } + + std::unordered_map ascans_by_pair{}; + double bandwidth_hz = 0.0; + for (const auto& trace : selected_traces) { + auto ascan = compute_legacy_ascan(trace, start_hz, stop_hz, velocity_mps); + if (ascan.amplitude.empty() || !(ascan.bandwidth_hz > 0.0)) { + continue; + } + bandwidth_hz = std::max(bandwidth_hz, ascan.bandwidth_hz); + ascans_by_pair.emplace(make_pair_key(trace.tx_local_index, trace.rx_local_index), std::move(ascan)); + } + if (ascans_by_pair.empty() || !(bandwidth_hz > 0.0)) { + return results; + } + + const auto grid = build_grid(selection.x_tx, selection.x_rx, max_depth_m, kLegacyGridZMinM); + if (grid.x_grid.empty() || grid.z_grid.empty()) { + return results; + } + + const double shell_sigma_m = velocity_mps / bandwidth_hz * 0.5; + const double snr_thresh = std::max(0.0, static_cast(live_config.gpr_snr_thresh)); + const double snr_comp_max = std::max(0.0, static_cast(live_config.gpr_snr_comp_max)); + const double comp_power = std::max(0.0, static_cast(live_config.gpr_comp_power)); + + std::unordered_map> peaks_by_pair{}; + for (std::size_t tx_index = 0U; tx_index < selection.x_tx.size(); ++tx_index) { + for (std::size_t rx_index = 0U; rx_index < selection.x_rx.size(); ++rx_index) { + const auto key = make_pair_key(static_cast(tx_index), static_cast(rx_index)); + const auto ascan_it = ascans_by_pair.find(key); + if (ascan_it == ascans_by_pair.end()) { + continue; + } + + const auto& ascan = ascan_it->second; + if (ascan.depth_m.size() < 3U || ascan.amplitude.size() < 3U) { + continue; + } + + const auto min_index = lower_bound_index(ascan.depth_m, min_depth_m); + const auto max_index = lower_bound_index(ascan.depth_m, max_depth_m); + if (max_index <= min_index + 2U || max_index > ascan.amplitude.size()) { + continue; + } + + const auto noise_begin = ascan.amplitude.begin() + static_cast(min_index); + const auto noise_end = ascan.amplitude.begin() + static_cast(max_index); + const double noise = median_copy(std::vector(noise_begin, noise_end)); + const double z_step = std::max(ascan.depth_m[1] - ascan.depth_m[0], 1e-6); + const std::size_t min_distance = static_cast(std::max( + 4.0, + std::floor(((velocity_mps / (2.0 * bandwidth_hz)) / z_step) * 0.7) + )); + const auto peak_indices = + find_legacy_peak_indices(ascan.amplitude, min_index, max_index, noise * snr_thresh, min_distance); + + auto& peaks = peaks_by_pair[key]; + peaks.reserve(peak_indices.size()); + for (const auto peak_index : peak_indices) { + const double z_app = ascan.depth_m[peak_index]; + const double snr_raw = ascan.amplitude[peak_index] / std::max(noise, 1e-12); + const double attenuation = + legacy_attenuation_at_depth(tx_index, rx_index, z_app, selection.x_tx, selection.x_rx); + const double attenuation_ref = + legacy_attenuation_at_depth(tx_index, rx_index, 3.0, selection.x_tx, selection.x_rx); + const double snr_comp = std::min( + snr_raw / (std::pow(attenuation / attenuation_ref, comp_power) + 1e-12), + snr_comp_max + ); + peaks.push_back( + LegacyPeakRecord{ + .z_app = z_app, + .tau = ascan.time_s[peak_index], + .tau_corr = ascan.time_s[peak_index], + .z_corr = z_app, + .snr_raw = snr_raw, + .snr_comp = snr_comp, + } + ); + } + } + } + + if (peaks_by_pair.empty()) { + return results; + } + + if (live_config.gpr_algorithm == GprAlgorithm::LegacyExtended) { + const auto [regions, smoothed_accumulator] = extended_legacy_find_regions( + grid, + peaks_by_pair, + selection.x_tx, + selection.x_rx, + velocity_mps, + shell_sigma_m + ); + results.collection_payloads.push_back( + build_image_payload("gpr_accumulator", grid.x_grid, grid.z_grid, smoothed_accumulator) + ); + + std::vector> region_rows{}; + region_rows.reserve(regions.size()); + for (std::size_t index = 0U; index < regions.size(); ++index) { + const auto& region = regions[index]; + region_rows.push_back( + { + static_cast(region.x_m), + static_cast(region.z_m), + static_cast(region.score), + static_cast(region.pixel_count), + } + ); + results.collection_payloads.push_back( + build_image_payload( + "gpr_region_mask_" + std::to_string(index), + grid.x_grid, + grid.z_grid, + std::vector(region.mask.begin(), region.mask.end()) + ) + ); + } + results.collection_payloads.push_back(build_table_payload("gpr_region_centers", region_rows, 4U)); + return results; + } + + const auto motion_timing_by_pair = build_legacy_motion_timing_by_pair( + selected_traces, + run_config.run_combos.size(), + collection.capture_start_ns, + collection.capture_end_ns, + live_config, + velocity_mps + ); + apply_legacy_motion_correction(peaks_by_pair, motion_timing_by_pair, velocity_mps); + + const auto [points, smoothed_accumulator] = clean_legacy_find_points( + grid, + peaks_by_pair, + selection.x_tx, + selection.x_rx, + velocity_mps, + shell_sigma_m, + LegacyPeakDomain::Corrected + ); + results.collection_payloads.push_back( + build_image_payload("gpr_accumulator", grid.x_grid, grid.z_grid, smoothed_accumulator) + ); + + std::vector> point_rows{}; + point_rows.reserve(points.size()); + for (const auto& point : points) { + point_rows.push_back( + { + static_cast(point.x_m), + static_cast(point.z_m), + static_cast(point.score), + } + ); + } + results.collection_payloads.push_back(build_table_payload("gpr_points", point_rows, 3U)); + return results; +} 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 7017fd6..c8a87cb 100644 --- a/data_acq_and_processing/processing/processors/src/gpr_processor.cpp +++ b/data_acq_and_processing/processing/processors/src/gpr_processor.cpp @@ -19,1208 +19,9 @@ namespace radar::processing { namespace { -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; -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 = 3.0; - -constexpr double kSmoothSigma = 1.5; -constexpr std::size_t kMaxObjects = 10U; -constexpr double kObjectMinFrac = 0.35; -constexpr double kRegionThresholdFrac = 0.75; -constexpr double kSuppressThresholdFrac = 0.20; -constexpr double kSuppressRadiusXM = 0.80; -constexpr double kSuppressRadiusZM = 0.40; -constexpr double kMinRegionAreaCm2 = 10.0; - -constexpr double kCenterRadiusXM = 0.60; -constexpr double kCenterRadiusZM = 0.25; -constexpr double kCenterThresholdFrac = 0.88; -constexpr double kCenterWeightPower = 2.0; - -constexpr double kSidelobeRangeRmsToleranceM = 0.20; -constexpr double kSidelobeMinDxM = 0.35; -constexpr double kSidelobeMaxDzM = 0.70; -constexpr double kSidelobeMaxRelativePeak = 0.85; - -using PairKey = std::uint64_t; - -struct GeometrySelection { - std::vector input_positions{}; - std::vector output_positions{}; - std::vector x_tx{}; - std::vector x_rx{}; - std::unordered_map input_local_by_pos{}; - std::unordered_map output_local_by_pos{}; -}; - -struct BackgroundAccumulator { - std::vector> sum{}; - std::size_t count = 0U; -}; - -struct SelectedTrace { - ipc::ComboKey combo{}; - std::uint32_t tx_local_index = 0U; - std::uint32_t rx_local_index = 0U; - std::size_t run_order = 0U; - std::vector frequency_hz{}; - std::vector> s21{}; -}; - -struct AscanResult { - std::vector time_s{}; - std::vector> samples{}; - double dt_s = 0.0; - double bandwidth_hz = 0.0; -}; - -struct GridDefinition { - std::vector x_grid{}; - std::vector z_grid{}; - std::vector> tx_distance_grids{}; - std::vector> rx_distance_grids{}; -}; - -struct BpMap { - std::vector image{}; - std::vector> coherent{}; -}; - -struct ObjectRecord { - std::size_t index = 0U; - double x_peak_m = 0.0; - double z_peak_m = 0.0; - double x_m = 0.0; - double z_m = 0.0; - double x_region_m = 0.0; - double z_region_m = 0.0; - double peak = 0.0; - double area_cm2 = 0.0; - double center_area_cm2 = 0.0; - double mean_value = 0.0; - double sum_value = 0.0; - std::vector region_mask{}; - std::vector center_mask{}; - bool sidelobe_candidate = false; - std::size_t sidelobe_parent = 0U; - double sidelobe_range_rms_m = std::numeric_limits::quiet_NaN(); -}; - -[[nodiscard]] auto make_pair_key(std::uint32_t tx_local_index, std::uint32_t rx_local_index) -> PairKey { - return (static_cast(tx_local_index) << 32U) | static_cast(rx_local_index); -} - -[[nodiscard]] auto combo_to_string(const ipc::ComboKey& combo) -> std::string { - return "input=" + std::to_string(combo.input_pos) + " output=" + std::to_string(combo.output_pos); -} - -[[nodiscard]] auto next_power_of_two(std::size_t value) -> std::size_t { - if (value <= 1U) { - return 1U; - } - - std::size_t power = 1U; - while (power < value) { - if (power > (std::numeric_limits::max() >> 1U)) { - return value; - } - power <<= 1U; - } - return power; -} - -void fft_inplace(std::vector>& values, bool inverse) { - const std::size_t size = values.size(); - if (size <= 1U) { - return; - } - - for (std::size_t index = 1U, bit_reversed = 0U; index < size; ++index) { - std::size_t bit = size >> 1U; - while (bit_reversed & bit) { - bit_reversed ^= bit; - bit >>= 1U; - } - bit_reversed ^= bit; - if (index < bit_reversed) { - std::swap(values[index], values[bit_reversed]); - } - } - - for (std::size_t len = 2U; len <= size; len <<= 1U) { - const double angle = 2.0 * kPi * (inverse ? 1.0 : -1.0) / static_cast(len); - const std::complex twiddle_step(std::cos(angle), std::sin(angle)); - const std::size_t half_len = len >> 1U; - - for (std::size_t offset = 0U; offset < size; offset += len) { - std::complex twiddle(1.0, 0.0); - for (std::size_t inner = 0U; inner < half_len; ++inner) { - const auto even = values[offset + inner]; - const auto odd = values[offset + inner + half_len] * twiddle; - values[offset + inner] = even + odd; - values[offset + inner + half_len] = even - odd; - twiddle *= twiddle_step; - } - } - } - - if (!inverse) { - return; - } - - const double scale = 1.0 / static_cast(size); - for (auto& value : values) { - value *= scale; - } -} - -[[nodiscard]] auto median_copy(std::vector values) -> double { - if (values.empty()) { - return 0.0; - } - - const auto middle = values.begin() + static_cast(values.size() / 2U); - std::nth_element(values.begin(), middle, values.end()); - double median = *middle; - if ((values.size() % 2U) == 0U) { - const auto lower_middle = values.begin() + static_cast((values.size() / 2U) - 1U); - std::nth_element(values.begin(), lower_middle, values.end()); - median = 0.5 * (median + *lower_middle); - } - return median; -} - -[[nodiscard]] auto build_axis(double min_value, double max_value, std::size_t count) -> std::vector { - std::vector axis{}; - if (count == 0U) { - return axis; - } - - axis.resize(count, min_value); - if (count == 1U) { - return axis; - } - - const double step = (max_value - min_value) / static_cast(count - 1U); - for (std::size_t index = 0U; index < count; ++index) { - axis[index] = min_value + (step * static_cast(index)); - } - 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(limit - 1U); - if (value > max_index) { - return limit - 1U; - } - return static_cast(value); -} - -[[nodiscard]] auto max_value(const std::vector& values) -> double { - if (values.empty()) { - return 0.0; - } - return *std::max_element(values.begin(), values.end()); -} - -void normalize_in_place(std::vector& values) { - const double maximum = max_value(values); - if (!(maximum > 0.0)) { - return; - } - for (auto& value : values) { - value /= maximum; - } -} - -[[nodiscard]] auto build_gaussian_kernel(double sigma) -> std::vector { - if (!(sigma > 0.0)) { - return {1.0}; - } - - const auto radius = static_cast(std::ceil(sigma * 3.0)); - std::vector kernel(static_cast((radius * 2) + 1), 0.0); - double sum = 0.0; - for (std::ptrdiff_t offset = -radius; offset <= radius; ++offset) { - const double value = std::exp(-0.5 * std::pow(static_cast(offset) / sigma, 2.0)); - kernel[static_cast(offset + radius)] = value; - sum += value; - } - if (sum > 0.0) { - for (auto& value : kernel) { - value /= sum; - } - } - return kernel; -} - -[[nodiscard]] auto gaussian_filter_2d( - const std::vector& values, - std::size_t width, - std::size_t height, - double sigma -) -> std::vector { - if (values.empty() || width == 0U || height == 0U) { - return {}; - } - - const auto kernel = build_gaussian_kernel(sigma); - const auto radius = static_cast((kernel.size() - 1U) / 2U); - std::vector temp(values.size(), 0.0); - std::vector output(values.size(), 0.0); - - for (std::size_t row = 0U; row < height; ++row) { - for (std::size_t col = 0U; col < width; ++col) { - double sum = 0.0; - for (std::ptrdiff_t offset = -radius; offset <= radius; ++offset) { - const auto sample_col = clamp_index(static_cast(col) + offset, width); - sum += values[(row * width) + sample_col] * kernel[static_cast(offset + radius)]; - } - temp[(row * width) + col] = sum; - } - } - - for (std::size_t row = 0U; row < height; ++row) { - for (std::size_t col = 0U; col < width; ++col) { - double sum = 0.0; - for (std::ptrdiff_t offset = -radius; offset <= radius; ++offset) { - const auto sample_row = clamp_index(static_cast(row) + offset, height); - sum += temp[(sample_row * width) + col] * kernel[static_cast(offset + radius)]; - } - output[(row * width) + col] = sum; - } - } - - return output; -} - -[[nodiscard]] auto contains_u32(const std::vector& values, std::uint32_t value) -> bool { - return std::find(values.begin(), values.end(), value) != values.end(); -} - -[[nodiscard]] auto selected_positions( - const std::vector& requested, - std::vector available -) -> std::vector { - std::sort(available.begin(), available.end()); - available.erase(std::unique(available.begin(), available.end()), available.end()); - if (requested.empty()) { - return available; - } - - std::vector result{}; - for (const auto value : requested) { - if (contains_u32(available, value) && !contains_u32(result, value)) { - result.push_back(value); - } - } - std::sort(result.begin(), result.end()); - return result; -} - -[[nodiscard]] auto build_geometry_selection( - const config::RunConfig& run_config, - const ProcessingLiveConfig& live_config -) -> GeometrySelection { - std::unordered_map tx_x_by_pos{}; - for (const auto& entry : run_config.gpr.tx_geometry) { - tx_x_by_pos[entry.output_pos] = static_cast(entry.x_m); - } - - std::unordered_map rx_x_by_pos{}; - for (const auto& entry : run_config.gpr.rx_geometry) { - rx_x_by_pos[entry.input_pos] = static_cast(entry.x_m); - } - - std::vector available_outputs{}; - available_outputs.reserve(tx_x_by_pos.size()); - for (const auto& [position, _] : tx_x_by_pos) { - available_outputs.push_back(position); - } - - std::vector available_inputs{}; - available_inputs.reserve(rx_x_by_pos.size()); - for (const auto& [position, _] : rx_x_by_pos) { - available_inputs.push_back(position); - } - - GeometrySelection selection{}; - selection.output_positions = selected_positions(live_config.gpr_output_positions, std::move(available_outputs)); - selection.input_positions = selected_positions(live_config.gpr_input_positions, std::move(available_inputs)); - - selection.x_tx.reserve(selection.output_positions.size()); - for (std::size_t index = 0U; index < selection.output_positions.size(); ++index) { - const auto position = selection.output_positions[index]; - selection.output_local_by_pos[position] = static_cast(index); - selection.x_tx.push_back(tx_x_by_pos[position]); - } - - selection.x_rx.reserve(selection.input_positions.size()); - for (std::size_t index = 0U; index < selection.input_positions.size(); ++index) { - const auto position = selection.input_positions[index]; - selection.input_local_by_pos[position] = static_cast(index); - selection.x_rx.push_back(rx_x_by_pos[position]); - } - - return selection; -} - -void validate_collection_trace_order( - const config::RunConfig& run_config, - const ipc::PreprocessedCollection& collection -) { - if (collection.traces.size() != run_config.run_combos.size()) { - throw std::runtime_error( - "GPR requires collection trace order to match run.combos exactly: trace_count=" + - std::to_string(collection.traces.size()) + - ", run_combo_count=" + std::to_string(run_config.run_combos.size()) - ); - } - - for (std::size_t index = 0U; index < collection.traces.size(); ++index) { - const auto& actual = collection.traces[index].combo; - const auto& expected = run_config.run_combos[index]; - if (actual.input_pos == expected.input_pos && actual.output_pos == expected.output_pos) { - continue; - } - - throw std::runtime_error( - "GPR requires preprocessed trace order to match run.combos: index=" + std::to_string(index) + - ", expected=(" + combo_to_string(expected) + "), actual=(" + combo_to_string(actual) + ")" - ); - } -} - -[[nodiscard]] auto build_background_mean( - std::span previous_collections, - const GeometrySelection& selection, - const ProcessingLiveConfig& live_config -) -> std::unordered_map>> { - std::unordered_map>> 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(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 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(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(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(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>>& background_mean -) -> std::vector { - std::vector traces{}; - traces.reserve(collection.traces.size()); - std::unordered_set seen_keys{}; - - for (std::size_t trace_index = 0U; trace_index < collection.traces.size(); ++trace_index) { - const auto& trace = collection.traces[trace_index]; - 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; - } - if (trace.frequency_hz.size() != trace.s21.size()) { - continue; - } - - SelectedTrace selected{}; - selected.combo = trace.combo; - selected.tx_local_index = output_it->second; - selected.rx_local_index = input_it->second; - selected.run_order = trace_index; - selected.frequency_hz.reserve(trace.frequency_hz.size()); - for (const auto value : trace.frequency_hz) { - selected.frequency_hz.push_back(static_cast(value)); - } - - const auto key = make_pair_key(selected.tx_local_index, selected.rx_local_index); - if (!seen_keys.insert(key).second) { - 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 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); - } - - traces.push_back(std::move(selected)); - } - - return traces; -} - -[[nodiscard]] auto compute_ascan( - const SelectedTrace& trace, - double start_hz, - double stop_hz -) -> AscanResult { - AscanResult result{}; - if (trace.frequency_hz.size() != trace.s21.size()) { - return result; - } - - const double low_hz = std::min(start_hz, stop_hz); - const double high_hz = std::max(start_hz, stop_hz); - std::vector frequency_hz{}; - std::vector> s21{}; - frequency_hz.reserve(trace.frequency_hz.size()); - s21.reserve(trace.s21.size()); - - for (std::size_t index = 0U; index < trace.frequency_hz.size(); ++index) { - const double frequency_value = trace.frequency_hz[index]; - if (frequency_value < low_hz || frequency_value > high_hz) { - continue; - } - frequency_hz.push_back(frequency_value); - s21.push_back(trace.s21[index]); - } - - if (frequency_hz.size() < 2U) { - return result; - } - - std::vector df_values{}; - df_values.reserve(frequency_hz.size() - 1U); - for (std::size_t index = 1U; index < frequency_hz.size(); ++index) { - const double df = frequency_hz[index] - frequency_hz[index - 1U]; - if (!(df > 0.0)) { - return result; - } - df_values.push_back(df); - } - - const double df_hz = median_copy(std::move(df_values)); - if (!(df_hz > 0.0)) { - return result; - } - - const auto start_bin = static_cast(std::llround(frequency_hz.front() / df_hz)); - if (start_bin < 0) { - return result; - } - - const std::size_t point_count = frequency_hz.size(); - const auto start_index = static_cast(start_bin); - const std::size_t min_fft_len = 2U * (start_index + point_count - 1U); - const std::size_t base_fft_len = next_power_of_two(min_fft_len); - if (base_fft_len < min_fft_len || base_fft_len > (std::numeric_limits::max() / kAscanOversample)) { - return result; - } - - const std::size_t fft_len = base_fft_len * kAscanOversample; - if (start_index > fft_len || point_count > (fft_len - start_index)) { - return result; - } - - std::vector> spectrum(fft_len, std::complex(0.0, 0.0)); - for (std::size_t index = 0U; index < point_count; ++index) { - const double window = point_count > 1U - ? 0.5 - (0.5 * std::cos((2.0 * kPi * static_cast(index)) / static_cast(point_count - 1U))) - : 1.0; - spectrum[start_index + index] = s21[index] * window; - } - - fft_inplace(spectrum, true); - - result.bandwidth_hz = frequency_hz.back() - frequency_hz.front(); - result.dt_s = 1.0 / (static_cast(fft_len) * df_hz); - result.samples = std::move(spectrum); - result.time_s.resize(fft_len, 0.0); - for (std::size_t index = 0U; index < fft_len; ++index) { - result.time_s[index] = static_cast(index) * result.dt_s; - } - return result; -} - -[[nodiscard]] auto build_grid( - const std::vector& x_tx, - const std::vector& x_rx, - double max_depth_m -) -> GridDefinition { - GridDefinition grid{}; - if (x_tx.empty() || x_rx.empty() || !(max_depth_m > kGridZMinM)) { - return grid; - } - - const auto [tx_min_it, tx_max_it] = std::minmax_element(x_tx.begin(), x_tx.end()); - const auto [rx_min_it, rx_max_it] = std::minmax_element(x_rx.begin(), x_rx.end()); - const double x_min = std::min(*tx_min_it, *rx_min_it) - kXMarginM; - const double x_max = std::max(*tx_max_it, *rx_max_it) + kXMarginM; - - grid.x_grid = build_axis(x_min, x_max, kGridWidth); - grid.z_grid = build_axis(kGridZMinM, max_depth_m, kGridHeight); - - const std::size_t cell_count = grid.x_grid.size() * grid.z_grid.size(); - grid.tx_distance_grids.assign(x_tx.size(), std::vector(cell_count, 0.0)); - grid.rx_distance_grids.assign(x_rx.size(), std::vector(cell_count, 0.0)); - - for (std::size_t row = 0U; row < grid.z_grid.size(); ++row) { - const double z_value = grid.z_grid[row]; - for (std::size_t col = 0U; col < grid.x_grid.size(); ++col) { - const double x_value = grid.x_grid[col]; - const auto cell_index = (row * grid.x_grid.size()) + col; - - for (std::size_t tx_index = 0U; tx_index < x_tx.size(); ++tx_index) { - grid.tx_distance_grids[tx_index][cell_index] = - std::hypot(x_value - x_tx[tx_index], z_value); - } - for (std::size_t rx_index = 0U; rx_index < x_rx.size(); ++rx_index) { - grid.rx_distance_grids[rx_index][cell_index] = - std::hypot(x_value - x_rx[rx_index], z_value); - } - } - } - - return grid; -} - -[[nodiscard]] auto interpolate_complex(const AscanResult& ascan, double tau_s) -> std::complex { - if (ascan.samples.empty() || !(ascan.dt_s > 0.0) || tau_s < 0.0) { - return {0.0, 0.0}; - } - - const double position = tau_s / ascan.dt_s; - const auto index = static_cast(std::floor(position)); - if (index >= ascan.samples.size()) { - return {0.0, 0.0}; - } - if (index + 1U >= ascan.samples.size()) { - return ascan.samples[index]; - } - - const double frac = position - static_cast(index); - return (ascan.samples[index] * (1.0 - frac)) + (ascan.samples[index + 1U] * frac); -} - -[[nodiscard]] auto attenuation_components( - double r_tx, - double r_rx, - double z_m -) -> std::pair { - const double geo = 1.0 / ((r_tx * r_rx) + 1e-12); - const double angle = - std::pow(z_m / (r_tx + 1e-12), 2.0) * - std::pow(z_m / (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 std::vector& x_tx, - const std::vector& x_rx -) -> std::pair { - const double x_center = 0.5 * (x_tx[tx_index] + x_rx[rx_index]); - const double r_tx = std::hypot(x_center - x_tx[tx_index], kCompensationReferenceDepthM); - const double r_rx = std::hypot(x_center - x_rx[rx_index], kCompensationReferenceDepthM); - return attenuation_components(r_tx, r_rx, kCompensationReferenceDepthM); -} - -[[nodiscard]] auto compensation_weight( - double r_tx, - double r_rx, - double z_m, - double geo_ref, - double angle_ref, - double range_power, - double angle_power -) -> double { - const auto [geo, angle] = attenuation_components(r_tx, r_rx, z_m); - 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); -} - -[[nodiscard]] auto backproject_coherent( - const std::vector& selected_traces, - const std::unordered_map& ascans_by_pair, - const GridDefinition& grid, - const std::vector& x_tx, - const std::vector& x_rx, - double velocity_mps, - double min_depth_m, - double max_depth_m, - double range_power, - double angle_power -) -> BpMap { - const std::size_t width = grid.x_grid.size(); - const std::size_t height = grid.z_grid.size(); - const std::size_t cell_count = width * height; - - BpMap result{}; - result.image.assign(cell_count, 0.0); - result.coherent.assign(cell_count, std::complex(0.0, 0.0)); - std::vector contribution_count(cell_count, 0.0); - - for (const auto& trace : selected_traces) { - const auto key = make_pair_key(trace.tx_local_index, trace.rx_local_index); - const auto ascan_it = ascans_by_pair.find(key); - if (ascan_it == ascans_by_pair.end()) { - continue; - } - - const auto [geo_ref, angle_ref] = attenuation_components_at_ref_depth( - trace.tx_local_index, - trace.rx_local_index, - x_tx, - x_rx - ); - const auto& tx_distances = grid.tx_distance_grids[trace.tx_local_index]; - const auto& rx_distances = grid.rx_distance_grids[trace.rx_local_index]; - - for (std::size_t row = 0U; row < height; ++row) { - const double z_m = grid.z_grid[row]; - const bool in_depth_gate = z_m >= min_depth_m && z_m <= max_depth_m; - if (!in_depth_gate) { - continue; - } - - for (std::size_t col = 0U; col < width; ++col) { - const auto cell_index = (row * width) + col; - const double r_tx = tx_distances[cell_index]; - const double r_rx = rx_distances[cell_index]; - const double tau_s = (r_tx + r_rx) / velocity_mps; - const auto sample = interpolate_complex(ascan_it->second, tau_s); - if (sample == std::complex(0.0, 0.0)) { - continue; - } - - const double weight = compensation_weight( - r_tx, - r_rx, - z_m, - geo_ref, - angle_ref, - range_power, - angle_power - ); - result.coherent[cell_index] += sample * weight; - contribution_count[cell_index] += 1.0; - } - } - } - - for (std::size_t index = 0U; index < cell_count; ++index) { - if (!(contribution_count[index] > 0.0)) { - continue; - } - result.coherent[index] /= contribution_count[index]; - result.image[index] = std::abs(result.coherent[index]); - } - - return result; -} - -void apply_depth_gate( - std::vector& image, - const std::vector& z_grid, - std::size_t width, - double min_depth_m, - double max_depth_m -) { - for (std::size_t row = 0U; row < z_grid.size(); ++row) { - const double z_m = z_grid[row]; - if (z_m >= min_depth_m && z_m <= max_depth_m) { - continue; - } - const auto row_offset = row * width; - std::fill(image.begin() + static_cast(row_offset), - image.begin() + static_cast(row_offset + width), - 0.0); - } -} - -[[nodiscard]] auto neighbor_indices( - std::size_t index, - std::size_t width, - std::size_t height -) -> std::vector { - const auto row = index / width; - const auto col = index % width; - std::vector neighbors{}; - neighbors.reserve(8U); - - for (std::ptrdiff_t row_offset = -1; row_offset <= 1; ++row_offset) { - for (std::ptrdiff_t col_offset = -1; col_offset <= 1; ++col_offset) { - if (row_offset == 0 && col_offset == 0) { - continue; - } - const auto next_row = static_cast(row) + row_offset; - const auto next_col = static_cast(col) + col_offset; - if (next_row < 0 || next_col < 0) { - continue; - } - if (next_row >= static_cast(height) || next_col >= static_cast(width)) { - continue; - } - neighbors.push_back((static_cast(next_row) * width) + static_cast(next_col)); - } - } - - return neighbors; -} - -[[nodiscard]] auto component_containing_peak( - const std::vector& image, - std::size_t width, - std::size_t height, - std::size_t peak_index, - double threshold, - const std::vector* window_mask = nullptr -) -> std::vector { - std::vector result(image.size(), 0U); - if (image.empty() || peak_index >= image.size()) { - return result; - } - if (image[peak_index] < threshold || (window_mask != nullptr && (*window_mask)[peak_index] == 0U)) { - result[peak_index] = 1U; - return result; - } - - std::queue queue{}; - result[peak_index] = 1U; - queue.push(peak_index); - - while (!queue.empty()) { - const auto current = queue.front(); - queue.pop(); - - for (const auto neighbor : neighbor_indices(current, width, height)) { - if (result[neighbor] != 0U) { - continue; - } - if (window_mask != nullptr && (*window_mask)[neighbor] == 0U) { - continue; - } - if (image[neighbor] < threshold) { - continue; - } - result[neighbor] = 1U; - queue.push(neighbor); - } - } - - return result; -} - -[[nodiscard]] auto mask_count(const std::vector& mask) -> std::size_t { - return static_cast(std::count(mask.begin(), mask.end(), static_cast(1U))); -} - -[[nodiscard]] auto weighted_centroid( - const std::vector& image, - const std::vector& mask, - const GridDefinition& grid, - double threshold -) -> std::pair { - double weight_sum = 0.0; - double x_weighted_sum = 0.0; - double z_weighted_sum = 0.0; - - for (std::size_t index = 0U; index < image.size(); ++index) { - if (mask[index] == 0U) { - continue; - } - double weight = std::max(image[index] - threshold, 0.0); - if (!(weight > 0.0)) { - weight = image[index]; - } - if (!(weight > 0.0)) { - continue; - } - - const auto row = index / grid.x_grid.size(); - const auto col = index % grid.x_grid.size(); - weight_sum += weight; - x_weighted_sum += grid.x_grid[col] * weight; - z_weighted_sum += grid.z_grid[row] * weight; - } - - if (!(weight_sum > 0.0)) { - const auto found = std::find(mask.begin(), mask.end(), static_cast(1U)); - if (found == mask.end()) { - return {0.0, 0.0}; - } - const auto index = static_cast(std::distance(mask.begin(), found)); - return {grid.x_grid[index % grid.x_grid.size()], grid.z_grid[index / grid.x_grid.size()]}; - } - - return {x_weighted_sum / weight_sum, z_weighted_sum / weight_sum}; -} - -[[nodiscard]] auto compact_peak_centroid( - const std::vector& image, - const GridDefinition& grid, - std::size_t peak_index, - double peak -) -> std::tuple> { - const std::size_t width = grid.x_grid.size(); - const auto peak_row = peak_index / width; - const auto peak_col = peak_index % width; - const double x_peak = grid.x_grid[peak_col]; - const double z_peak = grid.z_grid[peak_row]; - const double threshold = kCenterThresholdFrac * peak; - - std::vector center_mask(image.size(), 0U); - for (std::size_t row = 0U; row < grid.z_grid.size(); ++row) { - const double z_m = grid.z_grid[row]; - if (std::abs(z_m - z_peak) > kCenterRadiusZM) { - continue; - } - for (std::size_t col = 0U; col < grid.x_grid.size(); ++col) { - const double x_m = grid.x_grid[col]; - const auto index = (row * width) + col; - if (std::abs(x_m - x_peak) <= kCenterRadiusXM && image[index] >= threshold) { - center_mask[index] = 1U; - } - } - } - - if (mask_count(center_mask) == 0U) { - center_mask[peak_index] = 1U; - } - - double weight_sum = 0.0; - double x_weighted_sum = 0.0; - double z_weighted_sum = 0.0; - for (std::size_t index = 0U; index < image.size(); ++index) { - if (center_mask[index] == 0U) { - continue; - } - double weight = std::pow(std::max(image[index] - threshold, 0.0), kCenterWeightPower); - if (!(weight > 0.0)) { - weight = image[index]; - } - if (!(weight > 0.0)) { - continue; - } - - const auto row = index / width; - const auto col = index % width; - weight_sum += weight; - x_weighted_sum += grid.x_grid[col] * weight; - z_weighted_sum += grid.z_grid[row] * weight; - } - - if (!(weight_sum > 0.0)) { - return {x_peak, z_peak, std::move(center_mask)}; - } - return {x_weighted_sum / weight_sum, z_weighted_sum / weight_sum, std::move(center_mask)}; -} - -[[nodiscard]] auto build_window_mask( - const GridDefinition& grid, - double x_center_m, - double z_center_m, - double radius_x_m, - double radius_z_m -) -> std::vector { - const std::size_t width = grid.x_grid.size(); - std::vector mask(width * grid.z_grid.size(), 0U); - for (std::size_t row = 0U; row < grid.z_grid.size(); ++row) { - if (std::abs(grid.z_grid[row] - z_center_m) > radius_z_m) { - continue; - } - for (std::size_t col = 0U; col < grid.x_grid.size(); ++col) { - if (std::abs(grid.x_grid[col] - x_center_m) <= radius_x_m) { - mask[(row * width) + col] = 1U; - } - } - } - return mask; -} - -[[nodiscard]] auto find_bp_objects( - const std::vector& bp_image, - const GridDefinition& grid -) -> std::vector { - std::vector objects{}; - if (bp_image.empty() || grid.x_grid.size() < 2U || grid.z_grid.size() < 2U) { - return objects; - } - - std::vector work = bp_image; - const double global_peak = max_value(work); - const double stop_level = kObjectMinFrac * global_peak; - if (!(global_peak > 0.0)) { - return objects; - } - - const std::size_t width = grid.x_grid.size(); - const std::size_t height = grid.z_grid.size(); - const double dx_cm = std::abs(grid.x_grid[1] - grid.x_grid[0]) * 100.0; - const double dz_cm = std::abs(grid.z_grid[1] - grid.z_grid[0]) * 100.0; - const double pixel_area_cm2 = dx_cm * dz_cm; - - for (std::size_t step = 0U; step < kMaxObjects; ++step) { - const auto peak_it = std::max_element(work.begin(), work.end()); - const double peak = *peak_it; - if (peak <= stop_level || !(peak > 0.0)) { - break; - } - - const auto peak_index = static_cast(std::distance(work.begin(), peak_it)); - const auto peak_row = peak_index / width; - const auto peak_col = peak_index % width; - const double x_peak = grid.x_grid[peak_col]; - const double z_peak = grid.z_grid[peak_row]; - - const double region_threshold = kRegionThresholdFrac * peak; - auto region_mask = component_containing_peak(work, width, height, peak_index, region_threshold); - const double region_area_cm2 = static_cast(mask_count(region_mask)) * pixel_area_cm2; - if (region_area_cm2 < kMinRegionAreaCm2) { - work[peak_index] = 0.0; - continue; - } - - const auto [x_region, z_region] = weighted_centroid(work, region_mask, grid, region_threshold); - auto [x_center, z_center, center_mask] = compact_peak_centroid(work, grid, peak_index, peak); - const double center_area_cm2 = static_cast(mask_count(center_mask)) * pixel_area_cm2; - - double sum_value = 0.0; - std::size_t value_count = 0U; - for (std::size_t index = 0U; index < work.size(); ++index) { - if (region_mask[index] == 0U) { - continue; - } - sum_value += work[index]; - value_count += 1U; - } - - ObjectRecord object{}; - object.index = objects.size() + 1U; - object.x_peak_m = x_peak; - object.z_peak_m = z_peak; - object.x_m = x_center; - object.z_m = z_center; - object.x_region_m = x_region; - object.z_region_m = z_region; - object.peak = peak; - object.area_cm2 = region_area_cm2; - object.center_area_cm2 = center_area_cm2; - object.mean_value = value_count > 0U ? sum_value / static_cast(value_count) : 0.0; - object.sum_value = sum_value; - object.region_mask = std::move(region_mask); - object.center_mask = std::move(center_mask); - objects.push_back(std::move(object)); - - const auto suppress_window = build_window_mask(grid, x_peak, z_peak, kSuppressRadiusXM, kSuppressRadiusZM); - auto suppress_mask = component_containing_peak( - work, - width, - height, - peak_index, - kSuppressThresholdFrac * peak, - &suppress_window - ); - if (mask_count(suppress_mask) < mask_count(objects.back().region_mask)) { - suppress_mask = objects.back().region_mask; - } - - for (std::size_t index = 0U; index < work.size(); ++index) { - if (suppress_mask[index] != 0U) { - work[index] = 0.0; - } - } - } - - return objects; -} - -[[nodiscard]] auto bistatic_depth_signature( - const ObjectRecord& object, - const std::vector& selected_traces, - const std::vector& x_tx, - const std::vector& x_rx -) -> std::vector { - std::vector signature{}; - signature.reserve(selected_traces.size()); - for (const auto& trace : selected_traces) { - const double r_tx = std::hypot(object.x_m - x_tx[trace.tx_local_index], object.z_m); - const double r_rx = std::hypot(object.x_m - x_rx[trace.rx_local_index], object.z_m); - signature.push_back(0.5 * (r_tx + r_rx)); - } - return signature; -} - -void mark_sidelobe_candidates( - std::vector& objects, - const std::vector& selected_traces, - const std::vector& x_tx, - const std::vector& x_rx -) { - std::vector> signatures{}; - signatures.reserve(objects.size()); - for (const auto& object : objects) { - signatures.push_back(bistatic_depth_signature(object, selected_traces, x_tx, x_rx)); - } - - for (std::size_t object_index = 0U; object_index < objects.size(); ++object_index) { - auto& object = objects[object_index]; - std::size_t best_parent = 0U; - double best_rms = std::numeric_limits::infinity(); - - for (std::size_t parent_index = 0U; parent_index < object_index; ++parent_index) { - const auto& parent = objects[parent_index]; - const double relative_peak = object.peak / (parent.peak + 1e-12); - const double dx_m = std::abs(object.x_m - parent.x_m); - const double dz_m = std::abs(object.z_m - parent.z_m); - - double squared_sum = 0.0; - const auto& object_signature = signatures[object_index]; - const auto& parent_signature = signatures[parent_index]; - for (std::size_t index = 0U; index < object_signature.size(); ++index) { - const double delta = object_signature[index] - parent_signature[index]; - squared_sum += delta * delta; - } - const double rms = object_signature.empty() - ? std::numeric_limits::infinity() - : std::sqrt(squared_sum / static_cast(object_signature.size())); - - const bool candidate = - relative_peak <= kSidelobeMaxRelativePeak && - dx_m >= kSidelobeMinDxM && - dz_m <= kSidelobeMaxDzM && - rms <= kSidelobeRangeRmsToleranceM; - - if (candidate && rms < best_rms) { - best_parent = parent.index; - best_rms = rms; - } - } - - if (best_parent != 0U) { - object.sidelobe_candidate = true; - object.sidelobe_parent = best_parent; - object.sidelobe_range_rms_m = best_rms; - } - } -} - -[[nodiscard]] auto flatten_table( - const std::vector>& rows, - std::uint32_t column_count -) -> std::vector { - std::vector values{}; - values.reserve(rows.size() * column_count); - for (const auto& row : rows) { - values.insert(values.end(), row.begin(), row.end()); - } - return values; -} - -[[nodiscard]] auto build_table_payload( - const std::string& processing_name, - const std::vector>& rows, - std::uint32_t column_count -) -> ipc::ResultPayload { - ipc::ResultPayload payload{}; - payload.processing_name = processing_name; - payload.kind = ipc::ResultKind::TableF32; - payload.table_columns = column_count; - payload.table_values = flatten_table(rows, column_count); - return payload; -} - -[[nodiscard]] auto build_image_payload( - const std::string& processing_name, - const std::vector& x_axis, - const std::vector& y_axis, - const std::vector& values -) -> ipc::ResultPayload { - ipc::ResultPayload payload{}; - payload.processing_name = processing_name; - payload.kind = ipc::ResultKind::ImageF32; - payload.image_x_axis.reserve(x_axis.size()); - for (const auto value : x_axis) { - payload.image_x_axis.push_back(static_cast(value)); - } - payload.image_y_axis.reserve(y_axis.size()); - for (const auto value : y_axis) { - payload.image_y_axis.push_back(static_cast(value)); - } - payload.image_values.reserve(values.size()); - for (const auto value : values) { - payload.image_values.push_back(static_cast(value)); - } - return payload; -} +// 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 @@ -1234,108 +35,10 @@ auto GprProcessor::process_collection( std::span previous_collections, const ProcessingLiveConfig& live_config ) -> ipc::ResultCollection { - ipc::ResultCollection results{}; - results.collection_id = collection.collection_id; - results.monotonic_ns = collection.monotonic_ns; - - const auto selection = build_geometry_selection(run_config, live_config); - if (selection.input_positions.empty() || selection.output_positions.empty()) { - return results; + if (live_config.gpr_algorithm != GprAlgorithm::Backprojection) { + return process_legacy_gpr(run_config, collection, previous_collections, live_config); } - - validate_collection_trace_order(run_config, collection); - const auto background_mean = build_background_mean(previous_collections, selection, live_config); - const auto selected_traces = collect_selected_traces(collection, selection, background_mean); - if (selected_traces.empty()) { - return results; - } - - const double velocity_mps = - kSpeedOfLightMetersPerSec / std::sqrt(std::max(1e-6, static_cast(run_config.gpr.relative_permittivity))); - 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); - const double max_depth_m = static_cast(live_config.gpr_max_depth_m); - if (!(max_depth_m > min_depth_m) || !(max_depth_m > kGridZMinM)) { - return results; - } - - std::unordered_map ascans_by_pair{}; - for (const auto& trace : selected_traces) { - auto ascan = compute_ascan(trace, start_hz, stop_hz); - if (ascan.samples.empty() || !(ascan.bandwidth_hz > 0.0) || !(ascan.dt_s > 0.0)) { - continue; - } - ascans_by_pair.emplace( - make_pair_key(trace.tx_local_index, trace.rx_local_index), - std::move(ascan) - ); - } - if (ascans_by_pair.empty()) { - return results; - } - - const auto grid = build_grid(selection.x_tx, selection.x_rx, max_depth_m); - if (grid.x_grid.empty() || grid.z_grid.empty()) { - return results; - } - - auto bp = backproject_coherent( - selected_traces, - ascans_by_pair, - grid, - selection.x_tx, - selection.x_rx, - velocity_mps, - 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)) - ); - if (bp.image.empty()) { - return results; - } - - normalize_in_place(bp.image); - auto display_map = gaussian_filter_2d(bp.image, grid.x_grid.size(), grid.z_grid.size(), kSmoothSigma); - apply_depth_gate(display_map, grid.z_grid, grid.x_grid.size(), min_depth_m, max_depth_m); - normalize_in_place(display_map); - - auto objects = find_bp_objects(display_map, grid); - mark_sidelobe_candidates(objects, selected_traces, selection.x_tx, selection.x_rx); - - if (live_config.gpr_remove_sidelobe_objects_enabled) { - for (const auto& object : objects) { - if (!object.sidelobe_candidate) { - continue; - } - for (std::size_t index = 0U; index < display_map.size(); ++index) { - if (object.region_mask[index] != 0U) { - display_map[index] = 0.0; - } - } - } - } - - results.collection_payloads.push_back(build_image_payload("gpr_accumulator", grid.x_grid, grid.z_grid, display_map)); - - std::vector> point_rows{}; - point_rows.reserve(objects.size()); - for (const auto& object : objects) { - if (live_config.gpr_remove_sidelobe_objects_enabled && object.sidelobe_candidate) { - continue; - } - point_rows.push_back( - { - static_cast(object.x_m), - static_cast(object.z_m), - static_cast(object.peak), - } - ); - } - results.collection_payloads.push_back(build_table_payload("gpr_points", point_rows, 3U)); - - return results; + return process_backprojection_gpr(run_config, collection, previous_collections, live_config); } } // namespace radar::processing diff --git a/docs/operation_modes.md b/docs/operation_modes.md index b622269..4215173 100644 --- a/docs/operation_modes.md +++ b/docs/operation_modes.md @@ -18,6 +18,7 @@ run_config_librevna.example.json run_config_librevna_multi.example.json run_config_compact_m_k209.example.json run_config_compact_m_k209_local_mock_switches.example.json +run_config_simulator.example.json ``` ## Common Commands @@ -47,6 +48,23 @@ The GUI process supervisor starts the correct producer automatically: - `compact_m_k209` -> `build/bin/sweep_orchestrator` - `librevna_multi` -> `python_app.scripts.multi_device_raw_producer` +## Pure Simulator + +Use `run_config_simulator.example.json` to run the full GUI pipeline without +radar hardware or GPIO. It uses the single-LibreVNA mock producer, mock +switches, and the synthetic `smoke_cal` / `smoke_ref` preprocessing sets stored +under `python_app/data`. + +Typical local check: + +```bash +cd /path/to/radar_system +make +.venv/bin/python -m python_app.gui.main +``` + +Then load `run_config_simulator.example.json` in the GUI and press Start. + ## Single LibreVNA Use this mode when one LibreVNA is connected directly over USB to the machine @@ -210,4 +228,3 @@ sends one command byte and receives only binary `S11` and `S21` `float32` arrays. Use wired Ethernet. Wi-Fi works for tests but adds jitter. - 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 e3a0316..9788a1e 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 @@ -38,12 +38,18 @@ class AppWindowLiveProcessingMixin: bscan_gain=float(self._bscan_gain.value()), bscan_start_freq_mhz=float(self._bscan_start_freq_mhz.value()), bscan_stop_freq_mhz=float(self._bscan_stop_freq_mhz.value()), + gpr_algorithm=self._gpr_algorithm.currentText(), gpr_input_positions=self._parse_csv_int_list(self._gpr_input_positions_input.text()), gpr_output_positions=self._parse_csv_int_list(self._gpr_output_positions_input.text()), gpr_min_depth_m=float(self._gpr_min_depth_m.value()), gpr_max_depth_m=float(self._gpr_max_depth_m.value()), gpr_range_comp_power=float(self._gpr_range_comp_power.value()), gpr_angle_comp_power=float(self._gpr_angle_comp_power.value()), + gpr_comp_power=float(self._gpr_comp_power.value()), + gpr_speed_m_s=float(self._gpr_speed_m_s.value()), + gpr_look_angle_deg=float(self._gpr_look_angle_deg.value()), + gpr_snr_thresh=float(self._gpr_snr_thresh.value()), + gpr_snr_comp_max=float(self._gpr_snr_comp_max.value()), gpr_start_freq_mhz=float(self._gpr_start_freq_mhz.value()), gpr_stop_freq_mhz=float(self._gpr_stop_freq_mhz.value()), gpr_background_subtract_enabled=bool(self._gpr_background_subtract_enabled.isChecked()), @@ -176,12 +182,15 @@ class AppWindowLiveProcessingMixin: elif mode == "gpr": self._log( "Processing mode selected: gpr " - f"(inputs={self._gpr_input_positions_input.text().strip() or ''}, " + f"(algorithm={self._gpr_algorithm.currentText()}, " + f"inputs={self._gpr_input_positions_input.text().strip() or ''}, " f"outputs={self._gpr_output_positions_input.text().strip() or ''}, " 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"comp={self._gpr_comp_power.value():g}, " + f"snr={self._gpr_snr_thresh.value():g}, " 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()}, " 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 8dd5e61..3964101 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 @@ -70,6 +70,25 @@ class AppWindowConfigProfileIOMixin: self._pass_through_y_min_db.setEnabled(enabled) self._pass_through_y_max_db.setEnabled(enabled) + def _sync_gpr_algorithm_controls(self) -> None: + """Enable only controls that affect the selected GPR algorithm.""" + backprojection_enabled = self._gpr_algorithm.currentText() == "backprojection" + for widget in ( + self._gpr_range_comp_power, + self._gpr_angle_comp_power, + self._gpr_remove_sidelobe_objects_enabled, + ): + widget.setEnabled(backprojection_enabled) + + for widget in ( + self._gpr_comp_power, + self._gpr_speed_m_s, + self._gpr_look_angle_deg, + self._gpr_snr_thresh, + self._gpr_snr_comp_max, + ): + widget.setEnabled(not backprojection_enabled) + def _apply_history_limit_from_config(self, config) -> None: """Resize in-memory history buffers to match the loaded config.""" history_limit = self._history_limit_for_config(config) @@ -194,6 +213,7 @@ class AppWindowConfigProfileIOMixin: self._bscan_start_freq_mhz, self._bscan_stop_freq_mhz, self._bscan_subtract_mean_ascan, + self._gpr_algorithm, self._gpr_relative_permittivity, self._gpr_tx_geometry_input, self._gpr_rx_geometry_input, @@ -203,6 +223,11 @@ class AppWindowConfigProfileIOMixin: self._gpr_max_depth_m, self._gpr_range_comp_power, self._gpr_angle_comp_power, + self._gpr_comp_power, + self._gpr_speed_m_s, + self._gpr_look_angle_deg, + self._gpr_snr_thresh, + self._gpr_snr_comp_max, self._gpr_start_freq_mhz, self._gpr_stop_freq_mhz, self._gpr_background_subtract_enabled, @@ -265,10 +290,16 @@ class AppWindowConfigProfileIOMixin: ) self._gpr_input_positions_input.setText(str(gui_state.processing.gpr.input_positions)) self._gpr_output_positions_input.setText(str(gui_state.processing.gpr.output_positions)) + self._set_combo_current_text(self._gpr_algorithm, gui_state.processing.gpr.algorithm) 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_comp_power.setValue(float(gui_state.processing.gpr.comp_power)) + self._gpr_speed_m_s.setValue(float(gui_state.processing.gpr.speed_m_s)) + self._gpr_look_angle_deg.setValue(float(gui_state.processing.gpr.look_angle_deg)) + self._gpr_snr_thresh.setValue(float(gui_state.processing.gpr.snr_thresh)) + self._gpr_snr_comp_max.setValue(float(gui_state.processing.gpr.snr_comp_max)) 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( @@ -299,6 +330,7 @@ class AppWindowConfigProfileIOMixin: self._gpr_geometry_signature = None self._gpr_selected_geometry = None self._sync_pass_through_y_controls() + self._sync_gpr_algorithm_controls() self._refresh_preprocess_summary_labels() if self._preprocess_dialog is not None: 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 027cce4..aaf49c8 100644 --- a/python_app/gui/controllers/app_window_config/state_builders.py +++ b/python_app/gui/controllers/app_window_config/state_builders.py @@ -252,12 +252,18 @@ class AppWindowConfigStateBuildersMixin: subtract_mean_ascan=bool(self._bscan_subtract_mean_ascan.isChecked()), ), gpr=GuiGprStateModel( + algorithm=self._gpr_algorithm.currentText(), input_positions=self._gpr_input_positions_input.text().strip(), output_positions=self._gpr_output_positions_input.text().strip(), 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()), + comp_power=float(self._gpr_comp_power.value()), + speed_m_s=float(self._gpr_speed_m_s.value()), + look_angle_deg=float(self._gpr_look_angle_deg.value()), + snr_thresh=float(self._gpr_snr_thresh.value()), + snr_comp_max=float(self._gpr_snr_comp_max.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()), diff --git a/python_app/gui/controllers/app_window_pipeline_mixin.py b/python_app/gui/controllers/app_window_pipeline_mixin.py index 3c12db9..f094fc4 100644 --- a/python_app/gui/controllers/app_window_pipeline_mixin.py +++ b/python_app/gui/controllers/app_window_pipeline_mixin.py @@ -4,6 +4,8 @@ from __future__ import annotations import time +from PyQt6.QtCore import QSignalBlocker + from python_app.gui.runtime.constraints import validate_processing_mode_constraints from python_app.gui.runtime.history import build_run_history_signature, record_result_history from python_app.hardware_full.single_radar_service import create_single_radar_service @@ -470,10 +472,21 @@ class AppWindowPipelineMixin: ) def _drain_locator_speed_updates(self) -> None: - """Drain queued locator speed packets; coherent BP does not use motion speed.""" + """Apply the newest queued locator speed packet to live processing settings.""" if self._locator_service is None: return - self._locator_service.drain_speed_updates() + speed_m_s = self._locator_service.drain_speed_updates() + if speed_m_s is None: + return + + previous_speed_m_s = float(self._gpr_speed_m_s.value()) + with QSignalBlocker(self._gpr_speed_m_s): + self._gpr_speed_m_s.setValue(float(speed_m_s)) + current_speed_m_s = float(self._gpr_speed_m_s.value()) + if current_speed_m_s == previous_speed_m_s: + return + + self._write_live_processing_config() def _publish_locator_snapshot_from_collection(self, collection: ResultCollection) -> None: """Publish one locator snapshot from a GPR result collection.""" diff --git a/python_app/gui/controllers/sections/processing_section.py b/python_app/gui/controllers/sections/processing_section.py index 5f5c3fd..86f41d9 100644 --- a/python_app/gui/controllers/sections/processing_section.py +++ b/python_app/gui/controllers/sections/processing_section.py @@ -154,6 +154,10 @@ def build_processing_group(owner) -> QGroupBox: gpr_defaults = owner._defaults_config.gpr + owner._gpr_algorithm = QComboBox() + owner._gpr_algorithm.addItems(["backprojection", "legacy_point", "legacy_extended"]) + owner._set_combo_current_text(owner._gpr_algorithm, gpr_live_defaults.algorithm) + owner._gpr_relative_permittivity = QDoubleSpinBox() owner._gpr_relative_permittivity.setDecimals(4) owner._gpr_relative_permittivity.setRange(0.0001, 1000.0) @@ -198,6 +202,36 @@ def build_processing_group(owner) -> QGroupBox: owner._gpr_angle_comp_power.setSingleStep(0.01) owner._gpr_angle_comp_power.setValue(float(gpr_live_defaults.angle_comp_power)) + owner._gpr_comp_power = QDoubleSpinBox() + owner._gpr_comp_power.setDecimals(3) + owner._gpr_comp_power.setRange(0.0, 5.0) + owner._gpr_comp_power.setSingleStep(0.05) + owner._gpr_comp_power.setValue(float(gpr_live_defaults.comp_power)) + + owner._gpr_speed_m_s = QDoubleSpinBox() + owner._gpr_speed_m_s.setDecimals(3) + owner._gpr_speed_m_s.setRange(-100.0, 100.0) + owner._gpr_speed_m_s.setSingleStep(0.01) + owner._gpr_speed_m_s.setValue(float(gpr_live_defaults.speed_m_s)) + + owner._gpr_look_angle_deg = QDoubleSpinBox() + owner._gpr_look_angle_deg.setDecimals(2) + owner._gpr_look_angle_deg.setRange(-90.0, 90.0) + owner._gpr_look_angle_deg.setSingleStep(0.1) + owner._gpr_look_angle_deg.setValue(float(gpr_live_defaults.look_angle_deg)) + + owner._gpr_snr_thresh = QDoubleSpinBox() + owner._gpr_snr_thresh.setDecimals(2) + owner._gpr_snr_thresh.setRange(0.0, 1_000.0) + owner._gpr_snr_thresh.setSingleStep(0.1) + owner._gpr_snr_thresh.setValue(float(gpr_live_defaults.snr_thresh)) + + owner._gpr_snr_comp_max = QDoubleSpinBox() + owner._gpr_snr_comp_max.setDecimals(2) + owner._gpr_snr_comp_max.setRange(0.0, 1_000.0) + owner._gpr_snr_comp_max.setSingleStep(0.5) + owner._gpr_snr_comp_max.setValue(float(gpr_live_defaults.snr_comp_max)) + owner._gpr_start_freq_mhz = QDoubleSpinBox() owner._gpr_start_freq_mhz.setDecimals(1) owner._gpr_start_freq_mhz.setRange(100.0, 8800.0) @@ -220,6 +254,8 @@ def build_processing_group(owner) -> QGroupBox: owner._gpr_remove_sidelobe_objects_enabled = QCheckBox("Remove sidelobe objects") owner._gpr_remove_sidelobe_objects_enabled.setChecked(bool(gpr_live_defaults.remove_sidelobe_objects_enabled)) + owner._sync_gpr_algorithm_controls() + owner._gpr_render_mode = QComboBox() owner._gpr_render_mode.addItems(["heatmap", "objects_only"]) owner._set_combo_current_text(owner._gpr_render_mode, gpr_live_defaults.render_mode) @@ -257,6 +293,7 @@ def build_processing_group(owner) -> QGroupBox: gpr_page = _build_processing_mode_page( owner._processing_mode_pages, [ + ("Algorithm", owner._gpr_algorithm), ("Relative permittivity", owner._gpr_relative_permittivity), ("Input positions", owner._gpr_input_positions_input), ("Output positions", owner._gpr_output_positions_input), @@ -264,12 +301,17 @@ def build_processing_group(owner) -> QGroupBox: ("Max depth m", owner._gpr_max_depth_m), ("Range comp power", owner._gpr_range_comp_power), ("Angle comp power", owner._gpr_angle_comp_power), + ("Comp power", owner._gpr_comp_power), + ("SNR thresh", owner._gpr_snr_thresh), + ("SNR comp max", owner._gpr_snr_comp_max), ("Render mode", owner._gpr_render_mode), ("Min visible score", owner._gpr_min_visible_score), ("Tx geometry", owner._gpr_tx_geometry_input), ("Rx geometry", owner._gpr_rx_geometry_input), ("Start MHz", owner._gpr_start_freq_mhz), ("Stop MHz", owner._gpr_stop_freq_mhz), + ("Speed m/s", owner._gpr_speed_m_s), + ("Look angle deg", owner._gpr_look_angle_deg), ("Visible X min m", owner._gpr_visible_x_min_m), ("Visible X max m", owner._gpr_visible_x_max_m), ("Visible Z min m", owner._gpr_visible_z_min_m), @@ -278,7 +320,7 @@ def build_processing_group(owner) -> QGroupBox: ("Mean count", owner._gpr_background_mean_count), owner._gpr_remove_sidelobe_objects_enabled, ], - split_index=10, + split_index=12, ) owner._processing_mode_pages.addWidget(gpr_page) @@ -299,12 +341,19 @@ def build_processing_group(owner) -> QGroupBox: owner._bscan_start_freq_mhz.valueChanged.connect(owner._on_processing_live_settings_changed) owner._bscan_stop_freq_mhz.valueChanged.connect(owner._on_processing_live_settings_changed) owner._bscan_subtract_mean_ascan.toggled.connect(owner._on_processing_live_settings_changed) + owner._gpr_algorithm.currentTextChanged.connect(owner._sync_gpr_algorithm_controls) + owner._gpr_algorithm.currentTextChanged.connect(owner._on_processing_live_settings_changed) owner._gpr_input_positions_input.editingFinished.connect(owner._on_processing_live_settings_changed) owner._gpr_output_positions_input.editingFinished.connect(owner._on_processing_live_settings_changed) 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_comp_power.valueChanged.connect(owner._on_processing_live_settings_changed) + owner._gpr_speed_m_s.valueChanged.connect(owner._on_processing_live_settings_changed) + owner._gpr_look_angle_deg.valueChanged.connect(owner._on_processing_live_settings_changed) + owner._gpr_snr_thresh.valueChanged.connect(owner._on_processing_live_settings_changed) + owner._gpr_snr_comp_max.valueChanged.connect(owner._on_processing_live_settings_changed) owner._gpr_start_freq_mhz.valueChanged.connect(owner._on_processing_live_settings_changed) owner._gpr_stop_freq_mhz.valueChanged.connect(owner._on_processing_live_settings_changed) owner._gpr_background_subtract_enabled.toggled.connect(owner._on_processing_live_settings_changed) diff --git a/python_app/models/gui_profile_codec.py b/python_app/models/gui_profile_codec.py index 9551f69..ce24f5f 100644 --- a/python_app/models/gui_profile_codec.py +++ b/python_app/models/gui_profile_codec.py @@ -91,6 +91,13 @@ def gui_profile_from_dict(payload: dict[str, Any]) -> GuiProfileModel: pass_through_object = _as_dict(processing_object.get("pass_through"), "gui.processing.pass_through") bscan_object = _as_dict(processing_object.get("bscan"), "gui.processing.bscan") gpr_object = _as_dict(processing_object.get("gpr"), "gui.processing.gpr") + root_gpr_object = payload.get("gpr") + gpr_algorithm_default = gui.processing.gpr.algorithm + if isinstance(root_gpr_object, dict): + if root_gpr_object.get("mode") == "point": + gpr_algorithm_default = "legacy_point" + elif root_gpr_object.get("mode") == "extended": + gpr_algorithm_default = "legacy_extended" gui.processing = GuiProcessingStateModel( selected_mode=_optional_string( processing_object, @@ -160,6 +167,12 @@ def gui_profile_from_dict(payload: dict[str, Any]) -> GuiProfileModel: ), ), gpr=GuiGprStateModel( + algorithm=_optional_string( + gpr_object, + "algorithm", + gpr_algorithm_default, + "gui.processing.gpr", + ), input_positions=_optional_string( gpr_object, "input_positions", @@ -196,6 +209,36 @@ def gui_profile_from_dict(payload: dict[str, Any]) -> GuiProfileModel: gui.processing.gpr.angle_comp_power, "gui.processing.gpr", ), + comp_power=_optional_float( + gpr_object, + "comp_power", + gui.processing.gpr.comp_power, + "gui.processing.gpr", + ), + speed_m_s=_optional_float( + gpr_object, + "speed_m_s", + gui.processing.gpr.speed_m_s, + "gui.processing.gpr", + ), + look_angle_deg=_optional_float( + gpr_object, + "look_angle_deg", + gui.processing.gpr.look_angle_deg, + "gui.processing.gpr", + ), + snr_thresh=_optional_float( + gpr_object, + "snr_thresh", + gui.processing.gpr.snr_thresh, + "gui.processing.gpr", + ), + snr_comp_max=_optional_float( + gpr_object, + "snr_comp_max", + gui.processing.gpr.snr_comp_max, + "gui.processing.gpr", + ), start_freq_mhz=_optional_float( gpr_object, "start_freq_mhz", @@ -268,12 +311,20 @@ def gui_profile_from_dict(payload: dict[str, Any]) -> GuiProfileModel: raise ValueError("gui.processing.selected_mode must be one of: pass_through, bscan, gpr") if gui.processing.bscan.axis not in {"abs", "real", "phase"}: raise ValueError("gui.processing.bscan.axis must be one of: abs, real, phase") + if gui.processing.gpr.algorithm not in {"backprojection", "legacy_point", "legacy_extended"}: + raise ValueError("gui.processing.gpr.algorithm must be one of: backprojection, legacy_point, legacy_extended") if gui.processing.gpr.render_mode not in {"heatmap", "objects_only"}: raise ValueError("gui.processing.gpr.render_mode must be one of: heatmap, objects_only") 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.comp_power < 0.0: + raise ValueError("gui.processing.gpr.comp_power must be >= 0") + if gui.processing.gpr.snr_thresh < 0.0: + raise ValueError("gui.processing.gpr.snr_thresh must be >= 0") + if gui.processing.gpr.snr_comp_max < 0.0: + raise ValueError("gui.processing.gpr.snr_comp_max must be >= 0") if gui.processing.gpr.min_visible_score < 0.0: raise ValueError("gui.processing.gpr.min_visible_score must be >= 0") @@ -356,12 +407,18 @@ def gui_profile_to_dict(model: GuiProfileModel) -> dict[str, Any]: "subtract_mean_ascan": gui.processing.bscan.subtract_mean_ascan, }, "gpr": { + "algorithm": gui.processing.gpr.algorithm, "input_positions": gui.processing.gpr.input_positions, "output_positions": gui.processing.gpr.output_positions, "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, + "comp_power": gui.processing.gpr.comp_power, + "speed_m_s": gui.processing.gpr.speed_m_s, + "look_angle_deg": gui.processing.gpr.look_angle_deg, + "snr_thresh": gui.processing.gpr.snr_thresh, + "snr_comp_max": gui.processing.gpr.snr_comp_max, "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, diff --git a/python_app/models/gui_profile_schema.py b/python_app/models/gui_profile_schema.py index e0fd079..c9e4db7 100644 --- a/python_app/models/gui_profile_schema.py +++ b/python_app/models/gui_profile_schema.py @@ -49,12 +49,18 @@ class GuiBscanStateModel: class GuiGprStateModel: """UI-only defaults for GPR live settings.""" + algorithm: str = "backprojection" input_positions: str = "" output_positions: str = "" min_depth_m: float = 2.0 max_depth_m: float = 14.0 range_comp_power: float = 0.28 angle_comp_power: float = 0.10 + comp_power: float = 0.2 + speed_m_s: float = 0.0 + look_angle_deg: float = 0.0 + snr_thresh: float = 4.5 + snr_comp_max: float = 25.0 start_freq_mhz: float = 3000.0 stop_freq_mhz: float = 6000.0 background_subtract_enabled: bool = True diff --git a/python_app/orchestration/live_processing_config.py b/python_app/orchestration/live_processing_config.py index 3dd8e79..18212f2 100644 --- a/python_app/orchestration/live_processing_config.py +++ b/python_app/orchestration/live_processing_config.py @@ -23,12 +23,18 @@ class ProcessingLiveConfig: bscan_gain: float = 1.0 bscan_start_freq_mhz: float = 100.0 bscan_stop_freq_mhz: float = 8800.0 + gpr_algorithm: str = "backprojection" gpr_input_positions: list[int] | None = None gpr_output_positions: list[int] | None = None gpr_min_depth_m: float = 2.0 gpr_max_depth_m: float = 14.0 gpr_range_comp_power: float = 0.28 gpr_angle_comp_power: float = 0.10 + gpr_comp_power: float = 0.2 + gpr_speed_m_s: float = 0.0 + gpr_look_angle_deg: float = 0.0 + 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 @@ -63,12 +69,18 @@ class ProcessingLiveConfig: "bscan_gain": float(self.bscan_gain), "bscan_start_freq_mhz": float(self.bscan_start_freq_mhz), "bscan_stop_freq_mhz": float(self.bscan_stop_freq_mhz), + "gpr_algorithm": str(self.gpr_algorithm), "gpr_input_positions": [int(value) for value in self.gpr_input_positions], "gpr_output_positions": [int(value) for value in self.gpr_output_positions], "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_speed_m_s": float(self.gpr_speed_m_s), + "gpr_look_angle_deg": float(self.gpr_look_angle_deg), + "gpr_snr_thresh": float(self.gpr_snr_thresh), + "gpr_snr_comp_max": float(self.gpr_snr_comp_max), "gpr_start_freq_mhz": float(self.gpr_start_freq_mhz), "gpr_stop_freq_mhz": float(self.gpr_stop_freq_mhz), "gpr_background_subtract_enabled": bool(self.gpr_background_subtract_enabled), diff --git a/run_config.json b/run_config.json index 096fb14..50afb47 100644 --- a/run_config.json +++ b/run_config.json @@ -1,23 +1,18 @@ { "radar": { - "model": "librevna_multi", - "serial": "207730885532", - "remote_host": "127.0.0.1", - "remote_port": 50209, - "driver_mode": "native", + "model": "librevna", + "serial": "", + "driver_mode": "mock", "mock_signal_hz": 5000000.0, "multi_device": { - "slave_serials": [ - "20A1307D5532", - "2072306C5532" - ], - "force_external_reference": true, + "slave_serials": [], + "force_external_reference": false, "recovery_attempts": 3 }, "sweep": { "start_hz": 1000000.0, "stop_hz": 6000000000.0, - "points": 4501, + "points": 201, "if_bandwidth_hz": 50000.0, "stimulus_power_dbm": -10.0 } @@ -52,7 +47,7 @@ "settling_ms": 0, "idle_sleep_ms": 2, "continuous": true, - "processing_live_config_path": "/home/europa/Documents/radar_system/python_app/runtime/processing_live.json", + "processing_live_config_path": "python_app/runtime/processing_live.json", "locator_server": { "device_id": 3, "protocol_version": 1, @@ -100,11 +95,11 @@ "preprocess": { "s21": { "calibration": { - "set_name": "set_001", + "set_name": "smoke_cal", "bundle_path": "" }, "reference": { - "set_name": "set_001", + "set_name": "smoke_ref", "bundle_path": "" } }, @@ -168,27 +163,27 @@ }, "rings": { "raw": { - "name": "/radar_raw", + "name": "/radar_raw_simulator", "capacity": 50, "slot_size_bytes": 2097152 }, "raw_tap": { - "name": "/radar_raw_tap", + "name": "/radar_raw_tap_simulator", "capacity": 50, "slot_size_bytes": 2097152 }, "preprocessed": { - "name": "/radar_preprocessed", + "name": "/radar_preprocessed_simulator", "capacity": 50, "slot_size_bytes": 2097152 }, "preprocessed_tap": { - "name": "/radar_preprocessed_tap", + "name": "/radar_preprocessed_tap_simulator", "capacity": 50, "slot_size_bytes": 2097152 }, "results": { - "name": "/radar_results", + "name": "/radar_results_simulator", "capacity": 50, "slot_size_bytes": 2097152 } @@ -220,34 +215,40 @@ "subtract_mean_ascan": false }, "gpr": { + "algorithm": "backprojection", "input_positions": "0,1,2,3", "output_positions": "0,1", "min_depth_m": 2.0, "max_depth_m": 14.0, "range_comp_power": 0.28, "angle_comp_power": 0.1, + "comp_power": 0.2, + "speed_m_s": 0.0, + "look_angle_deg": 0.0, + "snr_thresh": 4.5, + "snr_comp_max": 25.0, "start_freq_mhz": 3000.0, "stop_freq_mhz": 6000.0, "background_subtract_enabled": true, "background_mean_count": 10, "remove_sidelobe_objects_enabled": false, "render_mode": "heatmap", - "min_visible_score": 0.049999999999999684, - "visible_x_min_m": -1.609999999999999, - "visible_x_max_m": 1.1099999999999985, - "visible_z_min_m": 0.30000000000000004, + "min_visible_score": 0.0, + "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": { "save_count": 10, - "save_path": "/home/europa/Documents/radar_system/python_app/data/snapshots", - "save_name": "snapshot_manual" + "save_path": "python_app/data/snapshots", + "save_name": "snapshot_simulator" }, "preprocess_dialog": { - "set_name": "set_001", + "set_name": "smoke_cal", "radar_config_dir": "", "use_all_radar_configs": false } } -} \ No newline at end of file +} diff --git a/run_config_simulator.example.json b/run_config_simulator.example.json new file mode 100644 index 0000000..50afb47 --- /dev/null +++ b/run_config_simulator.example.json @@ -0,0 +1,254 @@ +{ + "radar": { + "model": "librevna", + "serial": "", + "driver_mode": "mock", + "mock_signal_hz": 5000000.0, + "multi_device": { + "slave_serials": [], + "force_external_reference": false, + "recovery_attempts": 3 + }, + "sweep": { + "start_hz": 1000000.0, + "stop_hz": 6000000000.0, + "points": 201, + "if_bandwidth_hz": 50000.0, + "stimulus_power_dbm": -10.0 + } + }, + "switches": { + "port1": { + "name": "port1", + "driver_mode": "mock", + "driver": "h7992", + "radar_port": 1, + "positions": 2, + "default_position": 0, + "gpio_chip": "/dev/gpiochip0", + "pin_a": 17, + "pin_b": 27, + "invert_logic": false + }, + "port2": { + "name": "port2", + "driver_mode": "mock", + "driver": "h7992", + "radar_port": 2, + "positions": 4, + "default_position": 0, + "gpio_chip": "/dev/gpiochip0", + "pin_a": 22, + "pin_b": 23, + "invert_logic": false + } + }, + "run": { + "settling_ms": 0, + "idle_sleep_ms": 2, + "continuous": true, + "processing_live_config_path": "python_app/runtime/processing_live.json", + "locator_server": { + "device_id": 3, + "protocol_version": 1, + "host": "0.0.0.0", + "port": 8888, + "max_payload_bytes": 65536, + "client_queue_size": 32, + "logger_name": "locator_runtime" + }, + "combos": [ + { + "input": 0, + "output": 0 + }, + { + "input": 1, + "output": 0 + }, + { + "input": 2, + "output": 0 + }, + { + "input": 3, + "output": 0 + }, + { + "input": 0, + "output": 1 + }, + { + "input": 1, + "output": 1 + }, + { + "input": 2, + "output": 1 + }, + { + "input": 3, + "output": 1 + } + ] + }, + "preprocess": { + "s21": { + "calibration": { + "set_name": "smoke_cal", + "bundle_path": "" + }, + "reference": { + "set_name": "smoke_ref", + "bundle_path": "" + } + }, + "s11": { + "calibration": { + "open": { + "set_name": "", + "bundle_path": "" + }, + "short": { + "set_name": "", + "bundle_path": "" + }, + "load": { + "set_name": "", + "bundle_path": "" + } + }, + "reference": { + "set_name": "", + "bundle_path": "" + } + }, + "notch": { + "enabled": true, + "bands_hz": [], + "taper_width_hz": 40000000.0, + "taper_type": "cosine" + } + }, + "gpr": { + "relative_permittivity": 1.0, + "tx_geometry": [ + { + "output_pos": 0, + "x_m": 0.905 + }, + { + "output_pos": 1, + "x_m": -0.905 + } + ], + "rx_geometry": [ + { + "input_pos": 0, + "x_m": -0.18 + }, + { + "input_pos": 1, + "x_m": 0.485 + }, + { + "input_pos": 2, + "x_m": -0.49 + }, + { + "input_pos": 3, + "x_m": 0.185 + } + ] + }, + "rings": { + "raw": { + "name": "/radar_raw_simulator", + "capacity": 50, + "slot_size_bytes": 2097152 + }, + "raw_tap": { + "name": "/radar_raw_tap_simulator", + "capacity": 50, + "slot_size_bytes": 2097152 + }, + "preprocessed": { + "name": "/radar_preprocessed_simulator", + "capacity": 50, + "slot_size_bytes": 2097152 + }, + "preprocessed_tap": { + "name": "/radar_preprocessed_tap_simulator", + "capacity": 50, + "slot_size_bytes": 2097152 + }, + "results": { + "name": "/radar_results_simulator", + "capacity": 50, + "slot_size_bytes": 2097152 + } + }, + "gui": { + "version": 1, + "switches": { + "combo_mode": "text", + "combos_text": "0:0,1:0,2:0,3:0,0:1,1:1,2:1,3:1", + "single_input": "0", + "single_output": "0" + }, + "processing": { + "selected_mode": "gpr", + "pass_through": { + "show_magnitude": true, + "show_phase": false, + "fixed_y_enabled": false, + "y_min_db": -100.0, + "y_max_db": 0.0 + }, + "bscan": { + "axis": "abs", + "cut_m": 0.0, + "max_depth_m": 3.0, + "gain": 1.0, + "start_freq_mhz": 100.0, + "stop_freq_mhz": 6000.0, + "subtract_mean_ascan": false + }, + "gpr": { + "algorithm": "backprojection", + "input_positions": "0,1,2,3", + "output_positions": "0,1", + "min_depth_m": 2.0, + "max_depth_m": 14.0, + "range_comp_power": 0.28, + "angle_comp_power": 0.1, + "comp_power": 0.2, + "speed_m_s": 0.0, + "look_angle_deg": 0.0, + "snr_thresh": 4.5, + "snr_comp_max": 25.0, + "start_freq_mhz": 3000.0, + "stop_freq_mhz": 6000.0, + "background_subtract_enabled": true, + "background_mean_count": 10, + "remove_sidelobe_objects_enabled": false, + "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, + "visible_z_max_m": 14.0 + } + }, + "data_actions": { + "save_count": 10, + "save_path": "python_app/data/snapshots", + "save_name": "snapshot_simulator" + }, + "preprocess_dialog": { + "set_name": "smoke_cal", + "radar_config_dir": "", + "use_all_radar_configs": false + } + } +}