Rework radar system

This commit is contained in:
Ayzen
2026-10-06 12:32:26 +03:00
parent 552cdc688a
commit 5bb86b7165
299 changed files with 13537 additions and 27763 deletions
@@ -10,12 +10,6 @@
namespace radar::processing {
enum class HistoryCommand {
None,
RemoveLast,
ClearAll,
};
enum class LegacyGprMode {
Point,
Extended,
@@ -29,16 +23,16 @@ enum class LegacyGprReferenceMode {
FirstTxEvent,
};
struct HistoryTarget {
std::uint64_t collection_id = 0;
std::uint64_t capture_start_ns = 0;
};
struct ProcessingLiveConfig {
std::string processor_mode = "pass_through";
std::string pass_through_channel = "s21";
bool pass_through_fixed_y_enabled = false;
float pass_through_y_min_db = -100.0F;
float pass_through_y_max_db = 0.0F;
std::string bscan_axis = "abs";
std::string bscan_channel = "s21";
float bscan_cut_m = 0.0F;
float bscan_max_depth_m = 8.0F;
float bscan_cut_m = 0.824F;
float bscan_max_depth_m = 1.0F;
float bscan_gain = 1.0F;
float bscan_start_freq_mhz = 100.0F;
float bscan_stop_freq_mhz = 8800.0F;
@@ -51,13 +45,12 @@ struct ProcessingLiveConfig {
float gpr_max_depth_m = 14.0F;
float gpr_range_comp_power = 0.1F;
float gpr_comp_power = 0.2F;
// BP object-detection stop level, as a fraction of the global peak (Python
// Horns_motion_3libre.py BP_OBJECT_MIN_FRAC); peaks below it are not objects.
// BP object-detection stop level, as a fraction of the global peak; peaks
// below it are not objects.
float gpr_object_min_frac = 0.7F;
std::string gpr_score_mode = "combined";
// Backprojection intra-sweep speed-correction mode: "int_minus" (full
// correction) or "int_focus" (focusing residual only). Mirrors the Python
// Horns_motion_3libre.py MOTION_CORRECTION_MODE selector.
// correction) or "int_focus" (focusing residual only).
std::string gpr_motion_mode = "int_minus";
float gpr_speed_m_s = 0.0F;
float gpr_look_angle_deg = 0.0F;
@@ -81,8 +74,8 @@ struct ProcessingLiveConfig {
// Default 0 keeps legacy 1D antenna layouts imaging in the antenna plane.
float gpr_imaging_plane_y_m = 0.0F;
// Coherent BP object visibility (window + the draw limits below) is applied in
// the processor itself, matching Horns_motion_3libre.py — there is NO score
// threshold for it. Only legacy GPR still thresholds, on a pair count.
// the processor itself, with no score threshold. Only legacy GPR thresholds,
// on a pair count.
float legacy_gpr_min_visible_pair_count = 0.0F;
std::uint32_t gpr_max_detected_objects_to_draw = 0;
std::uint32_t gpr_draw_top_m_objects = 0;
@@ -111,8 +104,9 @@ struct ProcessingLiveConfig {
// "ignore socket speed" checkbox.
bool ignore_socket_speed = false;
bool reprocess_current_result = true;
std::uint64_t history_command_seq = 0;
HistoryCommand history_command = HistoryCommand::None;
// Persistent desired history: normal settings writes must preserve deletions.
std::uint64_t history_clear_before_ns = 0;
std::vector<HistoryTarget> history_removed{};
std::uint64_t settings_revision = 0;
};
@@ -1,5 +1,6 @@
#include "data_processor.hpp"
#include "runtime_wait.hpp"
#include "pipeline_runtime.hpp"
#include <algorithm>
#include <chrono>
@@ -69,9 +70,7 @@ void DataProcessor::run(const std::atomic<bool>& stop_requested) {
std::vector<ipc::PreprocessedCollection> preprocessed_history{};
const std::size_t history_limit = replay_history_limit(config_);
std::uint64_t last_replayed_revision = live_config_loader_.revision();
std::uint64_t last_applied_history_command_seq = 0;
std::uint64_t error_count = 0;
std::uint64_t consecutive_errors = 0;
runtime::CollectionErrors errors;
std::uint64_t oversized_results = 0;
// track the socket-fed speed used for the last reprocess so a change
// arriving without a live-config revision bump still triggers a reprocess of
@@ -95,17 +94,19 @@ void DataProcessor::run(const std::atomic<bool>& stop_requested) {
auto& processor = resolve_processor(live_config);
const bool socket_speed_dirty = current_socket_speed != last_reprocessed_socket_speed;
if (live_revision != last_replayed_revision || socket_speed_dirty) {
if (live_config.history_command_seq > last_applied_history_command_seq) {
if (live_config.history_command == HistoryCommand::RemoveLast) {
if (!preprocessed_history.empty()) {
preprocessed_history.pop_back();
}
} else if (live_config.history_command == HistoryCommand::ClearAll) {
preprocessed_history.clear();
}
last_applied_history_command_seq = live_config.history_command_seq;
const auto was_removed = [&](const ipc::PreprocessedCollection& collection) {
if (live_config.history_clear_before_ns &&
collection.capture_start_ns <= live_config.history_clear_before_ns) {
return true;
}
return std::any_of(live_config.history_removed.begin(), live_config.history_removed.end(),
[&](const HistoryTarget& target) {
return collection.collection_id == target.collection_id &&
collection.capture_start_ns == target.capture_start_ns;
});
};
if (live_revision != last_replayed_revision || socket_speed_dirty) {
std::erase_if(preprocessed_history, was_removed);
if (!live_config.reprocess_current_result) {
// Socket-fed speed updates should affect only future preprocessed collections,
@@ -139,6 +140,12 @@ void DataProcessor::run(const std::atomic<bool>& stop_requested) {
if (preprocessed_ring_.pop(bytes)) {
auto preprocessed = ipc::deserialize_preprocessed_collection(bytes);
if (!preprocessed.capture_start_ns || preprocessed.capture_end_ns < preprocessed.capture_start_ns) {
throw std::runtime_error("Preprocessed collection requires a valid acquisition window");
}
if (was_removed(preprocessed)) {
continue; // A deletion also applies to queued input and later GUI replays.
}
// Sweeps the GUI feeds back to recompute its B-scan keep their original
// timestamps, so they are no newer than the newest retained sweep. They
// are processed like any other but stay out of the history, which must
@@ -146,7 +153,7 @@ void DataProcessor::run(const std::atomic<bool>& stop_requested) {
// background from it), and away from the locator, which reports the
// current scene.
const bool fed_back = !preprocessed_history.empty() &&
preprocessed.monotonic_ns <= preprocessed_history.back().monotonic_ns;
preprocessed.capture_start_ns <= preprocessed_history.back().capture_start_ns;
if (!fed_back) {
preprocessed_history.push_back(std::move(preprocessed));
while (preprocessed_history.size() > history_limit) {
@@ -157,7 +164,7 @@ void DataProcessor::run(const std::atomic<bool>& stop_requested) {
const auto earlier = std::partition_point(
preprocessed_history.begin(),
preprocessed_history.end(),
[&](const ipc::PreprocessedCollection& retained) { return retained.monotonic_ns < current.monotonic_ns; }
[&](const ipc::PreprocessedCollection& retained) { return retained.capture_start_ns < current.capture_start_ns; }
);
const auto result_collection = process_collection(
@@ -170,29 +177,13 @@ void DataProcessor::run(const std::atomic<bool>& stop_requested) {
if (!fed_back) {
publish_locator(result_collection, live_config);
}
consecutive_errors = 0; // made progress; reset the escalation counter
errors.progress();
continue;
}
(void)config::wait_or_stop(config_.runtime.idle_sleep_ms, stop_requested);
} catch (const std::exception& exc) {
// A single bad collection (torn ring slot, decode/processing error) must
// not kill the long-running processor: drop it and keep going.
++error_count;
++consecutive_errors;
// Escalate a PERSISTENT failure (a deterministic config error drops every
// collection forever): log it loudly and distinctly — not buried in the
// every-100 throttle — so liveness-only supervision still surfaces it.
if (consecutive_errors % 500 == 0) {
std::cerr << "data_processor: " << consecutive_errors
<< " consecutive failures — likely a permanent configuration error: "
<< exc.what() << '\n';
} else if (error_count % 100 == 1) {
std::cerr << "data_processor: dropped collection after error (count="
<< error_count << "): " << exc.what() << '\n';
}
// Back-off (floored at 1ms even if idle_sleep_ms==0) so a persistent error
// cannot busy-spin a core or flood the log.
errors.report("data_processor", exc);
(void)config::wait_or_stop(std::max(1U, config_.runtime.idle_sleep_ms), stop_requested);
}
}
@@ -210,15 +201,13 @@ auto DataProcessor::process_collection(
result.processing_duration_ns = static_cast<std::uint64_t>(
std::chrono::duration_cast<std::chrono::nanoseconds>(finished_at - started_at).count()
);
// Four 16-bit words retain the uint64 exactly in the existing float32 table format.
ipc::ResultPayload revision{};
revision.processing_name = "processing_revision";
revision.kind = ipc::ResultKind::TableF32;
revision.table_columns = 4U;
for (unsigned shift = 0; shift < 64; shift += 16) {
revision.table_values.push_back(static_cast<float>((live_config.settings_revision >> shift) & 0xffffU));
}
result.collection_payloads.push_back(std::move(revision));
result.collection_id = preprocessed.collection_id;
result.monotonic_ns = ipc::current_monotonic_ns();
result.settings_revision = live_config.settings_revision;
result.capture_start_ns = preprocessed.capture_start_ns;
result.capture_end_ns = preprocessed.capture_end_ns;
// Socket speed can change without a GUI revision; retain the actual value per result.
result.speed_m_s = live_config.gpr_speed_m_s;
return result;
}
@@ -268,7 +257,7 @@ auto DataProcessor::build_locator_filter(const ProcessingLiveConfig& live_config
filter.draw_limits.reset();
}
// Coherent BP already emits the FINAL visible object set from the processor
// (window + N/M, no score threshold — matching Horns_motion_3libre.py), so the
// (window + N/M, no score threshold), so the
// socket forwards it verbatim. The default FilterParams passes everything through
// (it only drops non-finite rows), keeping the filtering logic in one place.
return filter;
@@ -1,43 +1,14 @@
#include <atomic>
#include <csignal>
#include <exception>
#include <iostream>
#include <memory>
#include <string>
#include "data_processor.hpp"
#include "locator/tcp_server.hpp"
#include "run_config.hpp"
#include "pipeline_runtime.hpp"
#include "shm_ring.hpp"
namespace {
constexpr const char* kDefaultConfigPath = "run_config.json";
std::atomic<bool> g_stop_requested{false};
void signal_handler(int /*signal*/) {
g_stop_requested.store(true, std::memory_order_relaxed);
}
void install_signal_handlers() {
std::signal(SIGINT, signal_handler);
std::signal(SIGTERM, signal_handler);
// Writing to a peer-closed socket would otherwise terminate the process.
std::signal(SIGPIPE, SIG_IGN);
}
[[nodiscard]] auto read_config_path(int argc, char** argv) -> std::string {
std::string config_path = kDefaultConfigPath;
for (int index = 1; index < argc; ++index) {
const std::string arg = argv[index];
if (arg == "--config" && (index + 1) < argc) {
config_path = argv[++index];
}
}
return config_path;
}
[[nodiscard]] auto start_locator_server(const radar::config::RunConfig& config)
-> std::unique_ptr<radar::locator::TcpServer> {
if (!config.locator_server.enabled) {
@@ -56,11 +27,8 @@ void install_signal_handlers() {
} // namespace
int main(int argc, char** argv) {
install_signal_handlers();
try {
const auto config_path = read_config_path(argc, argv);
const auto config = radar::config::load_run_config(config_path);
const auto config = radar::runtime::initialize(argc, argv);
auto preprocessed_ring = radar::ipc::ShmRing::open_or_create(
config.rings.preprocessed.name,
@@ -82,7 +50,7 @@ int main(int argc, char** argv) {
radar::processing::create_default_processors(),
locator_server.get()
);
processor.run(g_stop_requested);
processor.run(radar::runtime::stop_requested);
return 0;
} catch (const std::exception& exception) {
std::cerr << "data_processor error: " << exception.what() << '\n';
@@ -19,19 +19,6 @@ namespace {
using Json = nlohmann::json;
[[nodiscard]] auto parse_history_command(const std::string& value) -> HistoryCommand {
if (value == "none") {
return HistoryCommand::None;
}
if (value == "remove_last") {
return HistoryCommand::RemoveLast;
}
if (value == "clear_all") {
return HistoryCommand::ClearAll;
}
throw std::runtime_error("processing.history_command must be one of: none, remove_last, clear_all");
}
[[nodiscard]] auto parse_legacy_gpr_mode(const std::string& value, const std::string& field_name) -> LegacyGprMode {
if (value == "point") {
return LegacyGprMode::Point;
@@ -86,13 +73,6 @@ void apply_legacy_gpr_algorithm_alias(ProcessingLiveConfig& config, const std::s
}
}
[[nodiscard]] auto parse_s_parameter_channel(const std::string& value, const std::string& field_name) -> std::string {
if (value == "s21" || value == "s11") {
return value;
}
throw std::runtime_error(field_name + " must be one of: s21, s11");
}
[[nodiscard]] auto parse_f32(const Json& value, const std::string& field_name) -> float {
if (!value.is_number()) {
throw std::runtime_error(field_name + " must be number");
@@ -226,23 +206,7 @@ auto parse_geometry(const Json& value, const char* position_field, std::uint32_t
config::validate_gpr_config(*config.gpr, run_config);
}
assign_optional(root, "processor_mode", config.processor_mode);
if (const auto found = root.find("pass_through_channel"); found != root.end()) {
if (!found->is_string()) {
throw std::runtime_error("processing.pass_through_channel must be string");
}
config.pass_through_channel =
parse_s_parameter_channel(found->get<std::string>(), "processing.pass_through_channel");
}
assign_optional(root, "pass_through_fixed_y_enabled", config.pass_through_fixed_y_enabled);
assign_optional(root, "pass_through_y_min_db", config.pass_through_y_min_db);
assign_optional(root, "pass_through_y_max_db", config.pass_through_y_max_db);
assign_optional(root, "bscan_axis", config.bscan_axis);
if (const auto found = root.find("bscan_channel"); found != root.end()) {
if (!found->is_string()) {
throw std::runtime_error("processing.bscan_channel must be string");
}
config.bscan_channel = parse_s_parameter_channel(found->get<std::string>(), "processing.bscan_channel");
}
assign_optional(root, "bscan_cut_m", config.bscan_cut_m);
assign_optional(root, "bscan_max_depth_m", config.bscan_max_depth_m);
assign_optional(root, "bscan_gain", config.bscan_gain);
@@ -344,14 +308,23 @@ auto parse_geometry(const Json& value, const char* position_field, std::uint32_t
}
assign_optional(root, "ignore_socket_speed", config.ignore_socket_speed);
assign_optional(root, "reprocess_current_result", config.reprocess_current_result);
if (const auto found = root.find("history_command_seq"); found != root.end()) {
config.history_command_seq = parse_u64_number(*found, "processing.history_command_seq");
if (const auto found = root.find("history_clear_before_ns"); found != root.end()) {
config.history_clear_before_ns = parse_u64_number(*found, "processing.history_clear_before_ns");
}
if (const auto found = root.find("history_command"); found != root.end()) {
if (!found->is_string()) {
throw std::runtime_error("processing.history_command must be string");
if (const auto found = root.find("history_removed"); found != root.end()) {
if (!found->is_array()) {
throw std::runtime_error("processing.history_removed must be an array");
}
for (const auto& value : *found) {
HistoryTarget target{
parse_u64_number(value.at("collection_id"), "history_removed.collection_id"),
parse_u64_number(value.at("capture_start_ns"), "history_removed.capture_start_ns"),
};
if (!target.capture_start_ns) {
throw std::runtime_error("history_removed.capture_start_ns must be positive");
}
config.history_removed.push_back(target);
}
config.history_command = parse_history_command(found->get<std::string>());
}
return config;
}
@@ -393,9 +366,10 @@ auto ProcessingLiveConfigLoader::refresh_if_needed() -> ProcessingLiveConfig {
return current_;
}
current_ = parse_live_config(json_text, path_, run_config_);
// Remember rejected content too; retry only when the file actually changes.
last_json_text_ = json_text;
has_last_json_text_ = true;
current_ = parse_live_config(json_text, path_, run_config_);
++revision_;
} catch (const std::exception& error) {
std::cerr << "data_processor warning: failed to refresh live processing config: " << error.what() << '\n';
@@ -36,7 +36,6 @@ class ClientQueue {
// Wake any waiter and reject further pushes. Idempotent.
void close();
[[nodiscard]] auto is_closed() const -> bool;
private:
mutable std::mutex mutex_{};
@@ -197,11 +197,6 @@ void ClientQueue::close() {
not_empty_.notify_all();
}
auto ClientQueue::is_closed() const -> bool {
std::lock_guard<std::mutex> guard(mutex_);
return closed_;
}
// ----- ClientSession --------------------------------------------------------
ClientSession::ClientSession(
@@ -10,8 +10,7 @@
namespace radar::processing {
// Temporal "approach" filter for coherent-BP detections — a port of the objects-only
// filter in Horns_motion_3libre's demonstrate notebook.
// Temporal "approach" filter for coherent-BP detections.
//
// It keeps only objects that persist as a *motion-consistent track* across at least
// `min_frames` consecutive frames: as the radar moves, a real target reappears at a
@@ -30,12 +29,8 @@ namespace radar::processing {
// inter-frame gap (`kMaxFrameGapSeconds`), so a stale history from a previous run cannot
// spuriously confirm objects.
//
// IDEMPOTENT under reprocessing: the data_processor re-runs the last collection (or
// replays the whole window) whenever live settings or the socket speed change, with no
// new sweep. The history is therefore keyed by `frame_id` (the strictly increasing
// collection id): the same id replaces its entry (reprocess of the current frame), a
// smaller id rebuilds from scratch (a replay restart), so repeated reprocessing never
// duplicates frames or falsely confirms a track.
// Reprocessing replaces the same (collection_id, capture_start_ns) entry. Earlier
// capture times rebuild the history when the processor replays its retained window.
class ObjectApproachFilter {
public:
struct Point {
@@ -43,20 +38,16 @@ class ObjectApproachFilter {
double z_m;
};
// Record `objects` as frame `frame_id` and return, per object, whether it is
// confirmed (ends a >= `min_frames` motion-consistent track). `frame_id` is the
// collection id (identity/order, survives reprocessing); `frame_time_seconds` is the
// frame's wall-clock timestamp (drives the inter-frame interval); `speed_m_s`/
// `look_angle_deg` are the live motion estimate. `min_frames <= 1` disables filtering.
// Acquisition time drives both ordering and motion intervals. min_frames <= 1 disables filtering.
[[nodiscard]] auto confirm(
const std::vector<Point>& objects,
std::uint64_t frame_id,
double frame_time_seconds,
std::uint64_t capture_start_ns,
double speed_m_s,
double look_angle_deg,
std::size_t min_frames
) -> std::vector<bool> {
record_frame(Frame{frame_id, frame_time_seconds, objects});
record_frame(Frame{frame_id, capture_start_ns, objects});
const std::size_t history_depth = std::max<std::size_t>(min_frames, 1U);
while (history_.size() > history_depth) {
history_.pop_front();
@@ -79,17 +70,14 @@ class ObjectApproachFilter {
private:
struct Frame {
std::uint64_t id;
double time_seconds;
std::uint64_t capture_start_ns;
std::vector<Point> objects;
};
// Append a genuinely new frame, replace the current one on reprocessing (same id),
// or rebuild from scratch when the id steps backward (a replay restart). This keeps
// the history one entry per distinct collection no matter how often settings change.
void record_frame(Frame frame) {
if (history_.empty() || frame.id > history_.back().id) {
if (history_.empty() || frame.capture_start_ns > history_.back().capture_start_ns) {
history_.push_back(std::move(frame));
} else if (frame.id == history_.back().id) {
} else if (frame.id == history_.back().id && frame.capture_start_ns == history_.back().capture_start_ns) {
history_.back() = std::move(frame);
} else {
history_.clear();
@@ -97,7 +85,7 @@ class ObjectApproachFilter {
}
}
// Fixed matching tolerances (Horns_motion notebook 0.2 block). Range decreases as the
// Fixed matching tolerances. Range decreases as the
// radar approaches the target, hence the negative Z sign.
static constexpr double kXToleranceM = 0.45;
static constexpr double kRangeToleranceFraction = 0.85;
@@ -122,8 +110,10 @@ class ObjectApproachFilter {
for (std::size_t step = 1U; step < min_frames; ++step) {
const std::size_t earlier_index = newest - step;
const Frame& earlier = history_[earlier_index];
const double dt = history_[earlier_index + 1U].time_seconds - earlier.time_seconds;
if (!(dt > 0.0) || dt > kMaxFrameGapSeconds) {
const auto later_ns = history_[earlier_index + 1U].capture_start_ns;
if (later_ns <= earlier.capture_start_ns) { return false; }
const double dt = static_cast<double>(later_ns - earlier.capture_start_ns) * 1e-9;
if (dt > kMaxFrameGapSeconds) {
return false; // non-monotonic time, or a gap that breaks the track
}
const double expected_range_shift = range_step_per_second * dt;
@@ -93,16 +93,6 @@ void fft_inplace(std::vector<std::complex<double>>& values, bool inverse) {
return std::abs(sample);
}
[[nodiscard]] auto selected_trace_samples(
const ipc::SweepTraceBlock& trace,
const ProcessingLiveConfig& live_config
) -> std::span<const ipc::Complex32> {
if (live_config.bscan_channel == "s11") {
return trace.s11;
}
return trace.s21;
}
[[nodiscard]] auto fallback_profile(
const ipc::SweepTraceBlock& trace,
std::span<const ipc::Complex32> selected_samples
@@ -125,7 +115,7 @@ void fft_inplace(std::vector<std::complex<double>>& values, bool inverse) {
const ipc::SweepTraceBlock& trace,
const ProcessingLiveConfig& live_config
) -> BScanProfile {
const auto selected_samples = selected_trace_samples(trace, live_config);
const auto selected_samples = std::span<const ipc::Complex32>(trace.s21);
const std::size_t point_count = std::min(trace.frequency_hz.size(), selected_samples.size());
if (point_count < 2U) {
return fallback_profile(trace, selected_samples);
@@ -237,8 +227,6 @@ auto BScanProcessor::process_collection(
const ProcessingLiveConfig& live_config
) -> ipc::ResultCollection {
ipc::ResultCollection results{};
results.collection_id = collection.collection_id;
results.monotonic_ns = collection.monotonic_ns;
results.blocks.reserve(collection.traces.size());
for (const auto& trace : collection.traces) {
@@ -51,7 +51,7 @@ constexpr double kScoreCfEps = 1e-12;
using PairKey = std::uint64_t;
// Frequency-domain speed correction mode (Horns_motion_3libre.py MOTION_CONFIG):
// Frequency-domain speed correction mode:
// IntMinus — full intra-frequency correction to the interleaved-sweep centre.
// IntFocus — remove the constant + linear-in-frequency part of phi(f), leaving
// the focusing residual without a net Z shift.
@@ -653,9 +653,7 @@ void validate_collection_trace_order(
return traces;
}
// Frequency-domain speed correction applied to S21 *before* the IFFT, ported
// from Horns_motion_3libre.py (apply_intra_sweep_phase_correction +
// compute_frequency_sample_times). The measurement is interleaved Tx-by-frequency
// Frequency-domain speed correction applied to S21 *before* the IFFT. The measurement is interleaved Tx-by-frequency
// — Tx0(f0), Tx1(f0), Tx0(f1), ... — so each Tx occupies one interleave slot and
// its frequency k is sampled at global step (slot_count*k + slot). Each in-band
// frequency's phase is rotated back to the centre of the (full) interleaved sweep.
@@ -1740,7 +1738,7 @@ void add_bp_score_metrics(
}
}
// Select the FINAL visible objects exactly as Horns_motion_3libre.py does, so the
// Select the FINAL visible objects here, so the
// processor is the single source of truth: the GUI plot and the locator socket both
// consume this set verbatim (no second, duplicated filter). Steps, in order:
// 1. drop sidelobe candidates (when enabled), then sort by score (peak tie-break);
@@ -1844,8 +1842,6 @@ void add_bp_score_metrics(
ObjectApproachFilter& approach_filter
) -> 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()) {
@@ -1859,9 +1855,8 @@ void add_bp_score_metrics(
return results;
}
// Coherent BP fixes the medium to vacuum/air (eps_r = 1), matching the Python
// reference Horns_motion_3libre.py (its 0.3 block hardcodes eps_r = 1.0). The
// configurable relative_permittivity stays a legacy-GPR-only knob.
// Coherent BP fixes the medium to vacuum/air (eps_r = 1); the configurable
// relative_permittivity is a legacy-GPR-only knob.
const double velocity_mps = kSpeedOfLightMetersPerSec;
const double start_hz = static_cast<double>(live_config.gpr_start_freq_mhz) * 1'000'000.0;
const double stop_hz = static_cast<double>(live_config.gpr_stop_freq_mhz) * 1'000'000.0;
@@ -1948,7 +1943,7 @@ void add_bp_score_metrics(
min_depth_m,
max_depth_m,
std::max(0.0, static_cast<double>(live_config.gpr_range_comp_power)),
0.0 // angle compensation fixed off (Python Horns_motion_3libre.py COMP_ANGLE_POWER = 0.0)
0.0 // angle compensation is fixed off for coherent BP
);
if (bp.image.empty()) {
return results;
@@ -1980,7 +1975,7 @@ void add_bp_score_metrics(
results.collection_payloads.push_back(build_image_payload("gpr_accumulator", grid.x_grid, grid.z_grid, display_map));
// Final visible set (window + N/M), then the cross-frame approach filter: keep only
// objects confirmed as a >= min_frames motion-consistent track (Horns_motion notebook).
// objects confirmed as a >= min_frames motion-consistent track.
const auto visible = select_visible_objects(objects, live_config);
std::vector<ObjectApproachFilter::Point> visible_points{};
visible_points.reserve(visible.size());
@@ -1990,7 +1985,7 @@ void add_bp_score_metrics(
const auto confirmed = approach_filter.confirm(
visible_points,
collection.collection_id,
static_cast<double>(collection.monotonic_ns) * 1e-9,
collection.capture_start_ns,
static_cast<double>(live_config.gpr_speed_m_s),
static_cast<double>(live_config.gpr_look_angle_deg),
static_cast<std::size_t>(live_config.gpr_object_approach_min_frames)
@@ -853,8 +853,6 @@ void apply_legacy_motion_correction(
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()) {
@@ -881,7 +879,7 @@ void apply_legacy_motion_correction(
// Motion timing is computed once per collection. Matrix radars get one
// event per Tx (parallel Rx); sequential radars get one event per pair.
const bool matrix_radar = is_matrix_radar_model(run_config.radar.model);
const bool matrix_radar = is_matrix_radar_model(run_config.radar_model);
const auto motion_timing = compute_legacy_motion_timing(
selected_traces,
matrix_radar,
@@ -10,11 +10,9 @@ auto PassThroughProcessor::process_collection(
const config::RunConfig& /*run_config*/,
const ipc::PreprocessedCollection& collection,
std::span<const ipc::PreprocessedCollection> /*previous_collections*/,
const ProcessingLiveConfig& live_config
const ProcessingLiveConfig& /*live_config*/
) -> ipc::ResultCollection {
ipc::ResultCollection results{};
results.collection_id = collection.collection_id;
results.monotonic_ns = collection.monotonic_ns;
results.blocks.reserve(collection.traces.size());
for (const auto& trace : collection.traces) {
@@ -22,11 +20,7 @@ auto PassThroughProcessor::process_collection(
payload.processing_name = name();
payload.kind = ipc::ResultKind::TraceComplex;
payload.frequency_hz = trace.frequency_hz;
if (live_config.pass_through_channel == "s11") {
payload.trace = trace.s11;
} else {
payload.trace = trace.s21;
}
payload.trace = trace.s21;
ipc::ResultBlock block{};
block.combo = trace.combo;
@@ -297,9 +297,8 @@ void check(nvrtcResult result, const char* what) {
}
}
if (target == 0) {
throw std::runtime_error(
"GPU sm_" + std::to_string(device_arch) + " is older than every architecture this NVRTC supports"
);
throw CudaUnavailable("CUDA GPU sm_" + std::to_string(device_arch) +
" is older than every architecture this NVRTC supports; rebuild with NVRTC 12");
}
const bool native = target == device_arch;
const std::string arch_option =
@@ -387,7 +386,7 @@ void launch(CUfunction kernel, int items, Args... args) {
} // namespace
struct CudaBackend {
struct Gpu::Impl {
// Declared first, destroyed last: buffers and the module need the context.
struct Context {
CUdevice device = 0;
@@ -395,10 +394,10 @@ struct CudaBackend {
Context() {
check(cuInit(0U), "init", true);
check(cuDeviceGet(&device, 0), "device", true);
check(cuDevicePrimaryCtxRetain(&handle, device), "context");
check(cuDevicePrimaryCtxRetain(&handle, device), "context", true);
if (const CUresult result = cuCtxSetCurrent(handle); result != CUDA_SUCCESS) {
cuDevicePrimaryCtxRelease(device);
check(result, "context");
check(result, "context", true);
}
}
~Context() { cuDevicePrimaryCtxRelease(device); }
@@ -430,8 +429,8 @@ struct CudaBackend {
DeviceBuffer images;
DeviceBuffer peak_stats;
~CudaBackend() { cuCtxSetCurrent(context.handle); }
explicit CudaBackend(std::span<const float> weights) {
~Impl() { cuCtxSetCurrent(context.handle); }
explicit Impl(std::span<const float> weights) {
smooth_radius = static_cast<int>(weights.size() / 2U);
taps.upload(weights);
}
@@ -439,13 +438,13 @@ struct CudaBackend {
auto project(const VolumeRequest&) -> Projections;
};
auto CudaBackend::inverse_dft(
auto Gpu::Impl::inverse_dft(
std::span<const std::complex<float>> bands,
std::size_t points,
std::size_t first_bin,
std::size_t size
) -> std::vector<std::complex<float>> {
CudaBackend& gpu = *this;
Impl& gpu = *this;
check(cuCtxSetCurrent(gpu.context.handle), "context");
const std::size_t samples = bands.size() / points * size;
const int items = checked_int(samples, "A-scan stack");
@@ -456,8 +455,8 @@ auto CudaBackend::inverse_dft(
return download<std::complex<float>>(output, samples);
}
auto CudaBackend::project(const VolumeRequest& request) -> Projections {
CudaBackend& gpu = *this;
auto Gpu::Impl::project(const VolumeRequest& request) -> Projections {
Impl& gpu = *this;
check(cuCtxSetCurrent(gpu.context.handle), "context");
const int nx = checked_int(request.x.size(), "grid");
@@ -524,17 +523,15 @@ auto CudaBackend::project(const VolumeRequest& request) -> Projections {
return result;
}
#else
struct Gpu::Impl {};
#endif
struct Engine::Impl {
Gpu::Gpu(double smooth_sigma_voxels) {
#ifdef SAR3D_WITH_CUDA
std::unique_ptr<CudaBackend> cuda;
#endif
};
Engine::Engine(double smooth_sigma_voxels) : impl_(std::make_unique<Impl>()) {
#ifdef SAR3D_WITH_CUDA
impl_->cuda = std::make_unique<CudaBackend>(gaussian_taps(smooth_sigma_voxels));
impl_ = std::make_unique<Impl>(gaussian_taps(smooth_sigma_voxels));
#else
(void)smooth_sigma_voxels;
throw CudaUnavailable("SAR3D requires an NVIDIA GPU with CUDA; this program was built without "
@@ -542,23 +539,24 @@ Engine::Engine(double smooth_sigma_voxels) : impl_(std::make_unique<Impl>()) {
#endif
}
Engine::~Engine() = default;
Gpu::~Gpu() = default;
auto Engine::inverse_dft(std::span<const std::complex<float>> bands, std::size_t points,
std::size_t first_bin, std::size_t size) -> std::vector<std::complex<float>> {
// Without CUDA the constructor throws, so the #else branches below only satisfy the compiler.
auto Gpu::inverse_dft(std::span<const std::complex<float>> bands, std::size_t points,
std::size_t first_bin, std::size_t size) -> std::vector<std::complex<float>> {
if (points == 0U || bands.empty() || bands.size() % points != 0U || !std::has_single_bit(size) ||
size > kMaxIndexedItems || first_bin >= size || points > size - first_bin) {
throw std::runtime_error("sar3d invalid inverse FFT dimensions");
}
(void)checked_product(bands.size() / points, size, kMaxIndexedItems, "A-scan stack");
#ifdef SAR3D_WITH_CUDA
return impl_->cuda->inverse_dft(bands, points, first_bin, size);
return impl_->inverse_dft(bands, points, first_bin, size);
#else
throw CudaUnavailable("SAR3D requires an NVIDIA GPU with CUDA");
#endif
}
auto Engine::project(const VolumeRequest& request) -> Projections {
auto Gpu::project(const VolumeRequest& request) -> Projections {
const auto nx = request.x.size(), ny = request.y.size(), nz = request.z.size();
const auto plane = checked_product(nx, nz, kMaxIndexedItems, "grid plane");
(void)checked_product(plane, ny, kMaxIndexedItems, "grid (32-bit indexing; increase SAR_VOXEL_M)");
@@ -581,11 +579,6 @@ auto Engine::project(const VolumeRequest& request) -> Projections {
}
}
}
for (const auto& sample : request.ascans) {
if (!std::isfinite(sample.real()) || !std::isfinite(sample.imag())) {
throw std::runtime_error("sar3d A-scan contains a non-finite sample");
}
}
for (const auto& term : request.contributions) {
if (!std::isfinite(term.weight) || term.weight < 0.0F ||
!std::isfinite(term.inv_reference_ranges) || term.inv_reference_ranges <= 0.0F) {
@@ -600,7 +593,7 @@ auto Engine::project(const VolumeRequest& request) -> Projections {
(void)checked_product(static_cast<std::size_t>(view.nu), static_cast<std::size_t>(view.nv),
kMaxIndexedItems, "oblique image");
#ifdef SAR3D_WITH_CUDA
return impl_->cuda->project(request);
return impl_->project(request);
#else
throw CudaUnavailable("SAR3D requires an NVIDIA GPU with CUDA");
#endif
@@ -8,7 +8,7 @@
#include <stdexcept>
#include <vector>
// Numerical backend of the sar3d processor: A-scans of the band spectra, and the
// CUDA backend of the sar3d processor: A-scans of the band spectra, and the
// backprojection of an aperture into the 3D volume, smoothed and projected.
// Only the projections and peak leave the backend.
namespace radar::processing::sar3d {
@@ -74,12 +74,12 @@ struct Projections {
// CUDA context and runtime-compiled kernels. A missing device raises
// CudaUnavailable; computation and compilation failures remain ordinary errors.
class Engine {
class Gpu {
public:
explicit Engine(double smooth_sigma_voxels);
~Engine();
Engine(const Engine&) = delete;
auto operator=(const Engine&) -> Engine& = delete;
explicit Gpu(double smooth_sigma_voxels);
~Gpu();
Gpu(const Gpu&) = delete;
auto operator=(const Gpu&) -> Gpu& = delete;
// numpy.fft.ifft of each band spectrum placed at bins [first_bin, first_bin +
// points) of a `size`-point spectrum; `bands` holds the bands back to back.
@@ -41,10 +41,10 @@ constexpr float kCompRangeWeightMax = 5.0F; // COMP_RANGE_WEIGHT_MAX
constexpr double kEdgeFrameWeight = 0.8; // SAR_EDGE_FRAME_WEIGHT
constexpr double kSmoothSigma = 1.5; // BP_SMOOTH_SIGMA, voxels
constexpr double kNormEdgeMargin = 0.3; // SAR_NORM_EDGE_MARGIN_M
// Scene window: X across, Y height above the ground (SAR_Y_RANGE_M). Z along the
// ground follows the depth gate.
constexpr double kSceneXMin = -2.0;
constexpr double kSceneXMax = 2.0;
// Scene window: X across spans the selected antennas plus a margin on each side
// (x_ant.min() - 2 ... x_ant.max() + 2), Y is the height above the ground
// (SAR_Y_RANGE_M), Z along the ground follows the depth gate.
constexpr double kSceneXMargin = 2.0;
constexpr double kSceneYMin = -0.5;
constexpr double kSceneYMax = 2.0;
static_assert(kSceneYMax - kSceneYMin > 2.5 * kNormEdgeMargin, "else sar_norm_reference_mask keeps every plane");
@@ -169,8 +169,7 @@ struct TraceClock {
};
struct CollectionTiming {
std::uint64_t center_ns = 0U; // integer centre avoids overflow and loss of relative timing
double center_fraction_ns = 0.0;
std::uint64_t center_ns = 0U; // centre of the collection's capture window
std::vector<TraceClock> clocks; // per collection trace
};
@@ -202,7 +201,6 @@ struct CollectionTiming {
CollectionTiming timing{};
const auto duration_ns = last - first;
timing.center_ns = first + duration_ns / 2U;
timing.center_fraction_ns = 0.5 * static_cast<double>(duration_ns % 2U);
for (const auto& trace : collection.traces) {
const auto& stages = sweep_outputs.at({trace.capture_start_ns, trace.capture_end_ns});
const auto stage = std::find(stages.begin(), stages.end(), trace.combo.output_pos) - stages.begin();
@@ -315,7 +313,6 @@ void normalize_ascan(std::span<std::complex<float>> ascan, double dt_s, const Se
// A-scans of every selected Tx/Rx pair of one collection, (Tx, Rx) order.
struct Frame {
std::uint64_t center_ns = 0U;
double center_fraction_ns = 0.0;
double dt_s = 0.0;
std::size_t samples = 0U; // per A-scan
std::vector<std::complex<float>> ascans{}; // [pair][sample]
@@ -324,7 +321,7 @@ struct Frame {
[[nodiscard]] auto compute_frame(
const ipc::PreprocessedCollection& collection,
const Settings& settings,
sar3d::Engine& backend
sar3d::Gpu& backend
) -> Frame {
const auto timing = collection_timing(collection);
std::vector<Band> bands{};
@@ -354,7 +351,6 @@ struct Frame {
}
Frame frame{};
frame.center_ns = timing.center_ns;
frame.center_fraction_ns = timing.center_fraction_ns;
frame.samples = first.size;
frame.dt_s = 1.0 / (static_cast<double>(first.size) * first.df_hz);
frame.ascans = backend.inverse_dft(spectra, first.spectrum.size(), first.first_bin, first.size);
@@ -399,6 +395,15 @@ struct Grid {
if (std::abs(cos_p) < 1e-6) {
throw std::runtime_error("SAR grid is undefined at a radar look angle of 90 degrees");
}
// The pitch turns the radar about X, so antenna X is already the scene X.
double x_min = std::numeric_limits<double>::infinity();
double x_max = -std::numeric_limits<double>::infinity();
for (const auto* antennas : {&settings.tx, &settings.rx}) {
for (const auto& antenna : *antennas) {
x_min = std::min(x_min, antenna[0] - kSceneXMargin);
x_max = std::max(x_max, antenna[0] + kSceneXMargin);
}
}
double z_min = std::numeric_limits<double>::infinity();
double z_max = -std::numeric_limits<double>::infinity();
for (const double height : {kSceneYMin, kSceneYMax}) {
@@ -414,13 +419,13 @@ struct Grid {
const auto axis_size = [&](double span) {
return checked_axis_size(std::max(std::nearbyint(span / settings.voxel_m) + 1.0, 2.0));
};
const auto nx = axis_size(kSceneXMax - kSceneXMin);
const auto nx = axis_size(x_max - x_min);
const auto ny = axis_size(kSceneYMax - kSceneYMin);
const auto nz = axis_size(z_max - z_min);
if (nx > static_cast<std::size_t>(std::numeric_limits<int>::max()) / ny / nz) {
throw std::runtime_error("SAR grid exceeds 32-bit kernel indexing; increase SAR_VOXEL_M");
}
return {linspace(kSceneXMin, kSceneXMax, nx), linspace(kSceneYMin, kSceneYMax, ny),
return {linspace(x_min, x_max, nx), linspace(kSceneYMin, kSceneYMax, ny),
linspace(z_min, z_max, nz)};
}
@@ -571,11 +576,11 @@ struct Oblique {
} // namespace
struct Sar3dProcessor::State {
// A sweep is its id with its timestamp: ids start again from 1 on every run,
// Acquisition identity is (id, capture start): ids restart on every run,
// while the processor and this cache outlive runs.
using SweepKey = std::pair<std::uint64_t, std::uint64_t>;
[[nodiscard]] static auto sweep_key(const ipc::PreprocessedCollection& collection) -> SweepKey {
return {collection.collection_id, collection.monotonic_ns};
return {collection.collection_id, collection.capture_start_ns};
}
struct CachedFrame {
@@ -584,12 +589,12 @@ struct Sar3dProcessor::State {
};
std::map<SweepKey, CachedFrame> frames{};
AscanKey frames_key{};
std::unique_ptr<sar3d::Engine> engine{};
std::exception_ptr engine_error{};
std::unique_ptr<sar3d::Gpu> device{};
std::exception_ptr device_error{}; // why the GPU could not be opened; not retried
std::string last_error{};
std::uint64_t repeated_errors = 0U;
auto frame(const ipc::PreprocessedCollection& collection, const Settings& settings, sar3d::Engine& backend)
auto frame(const ipc::PreprocessedCollection& collection, const Settings& settings, sar3d::Gpu& backend)
-> std::shared_ptr<const Frame> {
if (const auto found = frames.find(sweep_key(collection)); found != frames.end()) {
if (found->second.value == nullptr) { report_error(found->second.error); }
@@ -607,13 +612,13 @@ struct Sar3dProcessor::State {
return frame;
}
auto numerical_engine() -> sar3d::Engine& {
if (engine == nullptr && engine_error == nullptr) {
try { engine = std::make_unique<sar3d::Engine>(kSmoothSigma); }
catch (...) { engine_error = std::current_exception(); }
auto gpu() -> sar3d::Gpu& {
if (device == nullptr && device_error == nullptr) {
try { device = std::make_unique<sar3d::Gpu>(kSmoothSigma); }
catch (...) { device_error = std::current_exception(); }
}
if (engine == nullptr) { std::rethrow_exception(engine_error); }
return *engine;
if (device == nullptr) { std::rethrow_exception(device_error); }
return *device;
}
auto render(
@@ -621,14 +626,12 @@ struct Sar3dProcessor::State {
std::span<const ipc::PreprocessedCollection> previous,
const Settings& settings
) -> std::vector<ipc::ResultPayload> {
auto& backend = numerical_engine();
auto& backend = gpu();
if (settings.ascan_key() != frames_key) {
frames.clear();
frames_key = settings.ascan_key();
}
const auto history_count = settings.frame_radius >= previous.size() / 2U + previous.size() % 2U
? previous.size() : 2U * settings.frame_radius;
const auto recent = previous.last(history_count);
const auto recent = previous.last(std::min(previous.size(), 2U * settings.frame_radius));
std::set<SweepKey> retained{sweep_key(collection)};
for (const auto& member : recent) { retained.insert(sweep_key(member)); }
// Prune even when the newest frame is invalid, so a bad input stream
@@ -657,10 +660,9 @@ struct Sar3dProcessor::State {
std::vector<double> offsets_s{};
for (const auto& member : aperture) {
const double delta_ns = member->center_ns >= centre.center_ns
? static_cast<double>(member->center_ns - centre.center_ns)
: -static_cast<double>(centre.center_ns - member->center_ns);
offsets_s.push_back(1e-9 * (delta_ns + member->center_fraction_ns - centre.center_fraction_ns));
// Unsigned difference reinterpreted as signed: exact for any two timestamps.
const auto delta_ns = static_cast<std::int64_t>(member->center_ns - centre.center_ns);
offsets_s.push_back(1e-9 * static_cast<double>(delta_ns));
}
const auto weights = frame_weights(offsets_s);
const Grid grid = scene_grid(settings);
@@ -797,11 +799,18 @@ auto Sar3dProcessor::process_collection(
const ProcessingLiveConfig& live_config
) -> ipc::ResultCollection {
ipc::ResultCollection results{};
results.collection_id = collection.collection_id;
results.monotonic_ns = collection.monotonic_ns;
try {
results.collection_payloads =
state_->render(collection, previous_collections, read_settings(run_config, live_config));
// RSL3 includes the typed metadata in its fixed header.
const auto bytes = ipc::serialize_result_collection(results).size();
const std::size_t slot = run_config.rings.results.slot_size_bytes;
if (bytes > slot) {
throw std::runtime_error("the views need " + std::to_string(bytes / 1024U) +
" KiB but a results ring slot holds " + std::to_string(slot / 1024U) +
" KiB; increase SAR_VOXEL_M, narrow the depth range or raise "
"rings.results.slot_size_bytes");
}
} catch (const sar3d::CudaUnavailable& error) {
state_->report_error(error.what());
results.collection_payloads = {status_payload(3.0F, error.what())};