sar3d: GPU port of the notebook with four views
The sar3d processor is rewritten as a checked port of Multi_antenna_3D_clean_v1.ipynb: NVRTC kernels, timing from the trace stamps, notebook parameter names and one cubic SAR_VOXEL_M. It adds front, side, top and 3D views, drops gpr_cpp and the parity tools, and the GUI speed now applies only while the socket speed is ignored.
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
#include <atomic>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <span>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
@@ -40,11 +41,10 @@ class DataProcessor {
|
||||
[[nodiscard]] auto resolve_processor(const ProcessingLiveConfig& live_config) -> ProcessorInterface&;
|
||||
[[nodiscard]] auto should_replay_entire_history(const ProcessingLiveConfig& live_config) const -> bool;
|
||||
|
||||
// Merge live config with the latest socket-supplied speed (if any and if
|
||||
// not suppressed by `ignore_socket_speed`). The returned config is what
|
||||
// actually drives processing for this tick.
|
||||
[[nodiscard]] auto resolve_effective_live_config(const ProcessingLiveConfig& live_config) const
|
||||
-> ProcessingLiveConfig;
|
||||
// Speed streamed by a locator client (`vlc`), which then replaces
|
||||
// `gpr_speed_m_s`; nullopt when `ignore_socket_speed` is set or no fresh
|
||||
// value exists, so the speed from the live config stays in force.
|
||||
[[nodiscard]] auto socket_speed(const ProcessingLiveConfig& live_config) const -> std::optional<double>;
|
||||
|
||||
// Convert one processed collection into a locator filter spec, derived
|
||||
// from the live config and current processor mode.
|
||||
|
||||
+8
-42
@@ -90,51 +90,17 @@ struct ProcessingLiveConfig {
|
||||
float gpr_visible_x_max_m = 2.0F;
|
||||
float gpr_visible_z_min_m = 0.0F;
|
||||
float gpr_visible_z_max_m = 14.0F;
|
||||
// 3D SAR (sar3d processor) parameters; defaults mirror the reference
|
||||
// notebook Multi_antenna_3D_clean_v1.ipynb (cells 0/4). Frequency band,
|
||||
// depth gate, antenna selection and motion (speed, look angle, direction,
|
||||
// phase correction) are shared with the gpr_* fields.
|
||||
// 3D SAR (sar3d processor) parameters, named after the reference notebook
|
||||
// Multi_antenna_3D_clean_v1.ipynb (SAR_FRAME_RADIUS, BP_GROUND_DEPTH_M, ...).
|
||||
// The band, depth gate, antenna selection, speed and look angle are the
|
||||
// shared gpr_* fields above.
|
||||
std::uint32_t sar3d_frame_radius = 0U;
|
||||
float sar3d_edge_frame_weight = 0.8F;
|
||||
// Frame pitch for ground coordinates and inter-frame offsets. Override
|
||||
// disabled -> the pitch follows gpr_look_angle_deg (notebook
|
||||
// BP_GROUND_PITCH_DEG = None).
|
||||
bool sar3d_pitch_override_enabled = false;
|
||||
float sar3d_pitch_deg = 17.5F;
|
||||
// Height of the LOWER antenna row above the ground; scene Y=0 is the ground.
|
||||
float sar3d_ground_depth_m = 1.25F;
|
||||
// Scene grid bounds (metres): X across, Y height above the ground, Z along
|
||||
// the ground away from the radar. Voxels whose radar depth falls outside
|
||||
// [gpr_min_depth, gpr_max_depth] are zeroed by the mask regardless.
|
||||
// Doubles, not floats: the parity tool feeds the notebook-derived bounds
|
||||
// through here and a float roundtrip shifts the grid by ~1e-7 m, flipping
|
||||
// knife-edge voxels of the depth mask against the reference volumes.
|
||||
double sar3d_x_min_m = -2.0;
|
||||
double sar3d_x_max_m = 2.0;
|
||||
double sar3d_y_min_m = -0.5;
|
||||
double sar3d_y_max_m = 2.0;
|
||||
double sar3d_z_min_m = 0.0;
|
||||
double sar3d_z_max_m = 8.0;
|
||||
float sar3d_voxel_x_m = 0.025F;
|
||||
float sar3d_voxel_y_m = 0.02F;
|
||||
float sar3d_voxel_z_m = 0.02F;
|
||||
// Volume finalization: normalization ignores this much of the grid's Y
|
||||
// edges (constant-range ridge), then Gaussian smoothing over (Z, X).
|
||||
float sar3d_norm_edge_margin_m = 0.3F;
|
||||
float sar3d_smooth_sigma = 1.5F;
|
||||
// Geometric spreading compensation 1/(Rtx*Rrx)^power, capped.
|
||||
// Edge of the cubic voxel: the notebook's SAR_VOXEL_M with one value for X, Y, Z.
|
||||
float sar3d_voxel_m = 0.02F;
|
||||
float sar3d_bp_ground_depth_m = 1.25F;
|
||||
float sar3d_comp_range_power = 0.3F;
|
||||
// Fallback frame segmentation when per-trace capture stamps are absent.
|
||||
std::uint32_t sar3d_tx_per_segment = 2U;
|
||||
bool sar3d_use_compensation = true;
|
||||
bool sar3d_normalize_pairs = true;
|
||||
// Debug: when non-empty, the processor dumps every finished volume to this
|
||||
// directory (raw float32 + json header). Used by the parity tool only.
|
||||
std::string sar3d_volume_dump_dir{};
|
||||
// Bottom-view projection angle around the vertical Y axis, degrees:
|
||||
// 0 = front view (X horizontal), 90 = side view (Z horizontal),
|
||||
// intermediate values project along the rotated horizontal direction.
|
||||
float sar3d_bottom_view_deg = 0.0F;
|
||||
bool sar3d_normalize_each_frame = true;
|
||||
// When true, the data_processor ignores socket-supplied `vlc` updates and
|
||||
// keeps using `gpr_speed_m_s` from this file. Mirrored from the GUI's
|
||||
// "ignore socket speed" checkbox.
|
||||
|
||||
@@ -73,19 +73,16 @@ void DataProcessor::run(const std::atomic<bool>& stop_requested) {
|
||||
|
||||
while (!stop_requested.load(std::memory_order_relaxed)) {
|
||||
try {
|
||||
const auto live_config_raw = live_config_loader_.refresh_if_needed();
|
||||
const auto live_config = resolve_effective_live_config(live_config_raw);
|
||||
// The socket speed is read once per tick: it both drives this tick's
|
||||
// processing and, when it changed, triggers a reprocess on its own,
|
||||
// even when the live revision is unchanged.
|
||||
auto live_config = live_config_loader_.refresh_if_needed();
|
||||
const std::optional<double> current_socket_speed = socket_speed(live_config);
|
||||
if (current_socket_speed.has_value()) {
|
||||
live_config.gpr_speed_m_s = static_cast<float>(*current_socket_speed);
|
||||
}
|
||||
const auto live_revision = live_config_loader_.revision();
|
||||
auto& processor = resolve_processor(live_config);
|
||||
|
||||
// The effective config folds in latest_socket_speed(); a change there is
|
||||
// a reprocess trigger on its own, even when the live revision is unchanged.
|
||||
// Mirror the gating in resolve_effective_live_config(): only consider the
|
||||
// socket speed when it is actually applied to the effective config.
|
||||
const std::optional<double> current_socket_speed =
|
||||
(live_config_raw.ignore_socket_speed || locator_server_ == nullptr)
|
||||
? std::nullopt
|
||||
: locator_server_->latest_socket_speed();
|
||||
const bool socket_speed_dirty = current_socket_speed != last_reprocessed_socket_speed;
|
||||
|
||||
if (live_revision != last_replayed_revision || socket_speed_dirty) {
|
||||
@@ -213,18 +210,11 @@ auto DataProcessor::should_replay_entire_history(const ProcessingLiveConfig& liv
|
||||
return requested_mode == "bscan";
|
||||
}
|
||||
|
||||
auto DataProcessor::resolve_effective_live_config(const ProcessingLiveConfig& live_config) const
|
||||
-> ProcessingLiveConfig {
|
||||
auto DataProcessor::socket_speed(const ProcessingLiveConfig& live_config) const -> std::optional<double> {
|
||||
if (live_config.ignore_socket_speed || locator_server_ == nullptr) {
|
||||
return live_config;
|
||||
return std::nullopt;
|
||||
}
|
||||
const auto socket_speed = locator_server_->latest_socket_speed();
|
||||
if (!socket_speed.has_value()) {
|
||||
return live_config;
|
||||
}
|
||||
ProcessingLiveConfig effective = live_config;
|
||||
effective.gpr_speed_m_s = static_cast<float>(*socket_speed);
|
||||
return effective;
|
||||
return locator_server_->latest_socket_speed();
|
||||
}
|
||||
|
||||
auto DataProcessor::build_locator_filter(const ProcessingLiveConfig& live_config) const
|
||||
|
||||
@@ -390,16 +390,9 @@ void apply_legacy_gpr_algorithm_alias(ProcessingLiveConfig& config, const std::s
|
||||
config.sar3d_frame_radius = parse_u32_number(*found, "processing.sar3d_frame_radius");
|
||||
}
|
||||
for (const auto& [key, target] : {
|
||||
std::pair{"sar3d_edge_frame_weight", &config.sar3d_edge_frame_weight},
|
||||
std::pair{"sar3d_pitch_deg", &config.sar3d_pitch_deg},
|
||||
std::pair{"sar3d_ground_depth_m", &config.sar3d_ground_depth_m},
|
||||
std::pair{"sar3d_voxel_x_m", &config.sar3d_voxel_x_m},
|
||||
std::pair{"sar3d_voxel_y_m", &config.sar3d_voxel_y_m},
|
||||
std::pair{"sar3d_voxel_z_m", &config.sar3d_voxel_z_m},
|
||||
std::pair{"sar3d_norm_edge_margin_m", &config.sar3d_norm_edge_margin_m},
|
||||
std::pair{"sar3d_smooth_sigma", &config.sar3d_smooth_sigma},
|
||||
std::pair{"sar3d_voxel_m", &config.sar3d_voxel_m},
|
||||
std::pair{"sar3d_bp_ground_depth_m", &config.sar3d_bp_ground_depth_m},
|
||||
std::pair{"sar3d_comp_range_power", &config.sar3d_comp_range_power},
|
||||
std::pair{"sar3d_bottom_view_deg", &config.sar3d_bottom_view_deg},
|
||||
}) {
|
||||
if (const auto found = root.find(key); found != root.end()) {
|
||||
if (!found->is_number()) {
|
||||
@@ -409,30 +402,8 @@ void apply_legacy_gpr_algorithm_alias(ProcessingLiveConfig& config, const std::s
|
||||
}
|
||||
}
|
||||
for (const auto& [key, target] : {
|
||||
std::pair{"sar3d_x_min_m", &config.sar3d_x_min_m},
|
||||
std::pair{"sar3d_x_max_m", &config.sar3d_x_max_m},
|
||||
std::pair{"sar3d_y_min_m", &config.sar3d_y_min_m},
|
||||
std::pair{"sar3d_y_max_m", &config.sar3d_y_max_m},
|
||||
std::pair{"sar3d_z_min_m", &config.sar3d_z_min_m},
|
||||
std::pair{"sar3d_z_max_m", &config.sar3d_z_max_m},
|
||||
}) {
|
||||
if (const auto found = root.find(key); found != root.end()) {
|
||||
if (!found->is_number()) {
|
||||
throw std::runtime_error(std::string("processing.") + key + " must be number");
|
||||
}
|
||||
*target = found->get<double>();
|
||||
}
|
||||
}
|
||||
if (const auto found = root.find("sar3d_tx_per_segment"); found != root.end()) {
|
||||
config.sar3d_tx_per_segment = parse_u32_number(*found, "processing.sar3d_tx_per_segment");
|
||||
if (config.sar3d_tx_per_segment == 0U) {
|
||||
throw std::runtime_error("processing.sar3d_tx_per_segment must be >= 1");
|
||||
}
|
||||
}
|
||||
for (const auto& [key, target] : {
|
||||
std::pair{"sar3d_pitch_override_enabled", &config.sar3d_pitch_override_enabled},
|
||||
std::pair{"sar3d_use_compensation", &config.sar3d_use_compensation},
|
||||
std::pair{"sar3d_normalize_pairs", &config.sar3d_normalize_pairs},
|
||||
std::pair{"sar3d_normalize_each_frame", &config.sar3d_normalize_each_frame},
|
||||
}) {
|
||||
if (const auto found = root.find(key); found != root.end()) {
|
||||
if (!found->is_boolean()) {
|
||||
|
||||
@@ -8,9 +8,11 @@
|
||||
|
||||
namespace radar::processing {
|
||||
|
||||
// 3D SAR over a synthesized aperture, computed by the external gpr_cpp
|
||||
// CUDA/NVRTC library (VNA_AlGo). Emits 2D max-projections of the volume
|
||||
// (side XZ and top XY views) as ImageF32 collection payloads.
|
||||
// 3D SAR of the reference notebook Multi_antenna_3D_clean_v1.ipynb on an NVIDIA
|
||||
// GPU. Publishes max-projections of the normalized volume as ImageF32 payloads:
|
||||
// sar3d_front (X-Y), sar3d_side (Z-Y), sar3d_top (X-Z) and sar3d_oblique (the
|
||||
// notebook's 3D scene view, axes in the plane of sar3d_oblique_basis), plus
|
||||
// sar3d_peak = [level, X, Y, Z] of the volume maximum.
|
||||
class Sar3dProcessor final : public ProcessorInterface {
|
||||
public:
|
||||
Sar3dProcessor();
|
||||
@@ -25,8 +27,6 @@ class Sar3dProcessor final : public ProcessorInterface {
|
||||
) -> ipc::ResultCollection override;
|
||||
|
||||
private:
|
||||
// Pimpl keeps gpr/CUDA headers (cuda.h, nvrtc.h) out of the shared
|
||||
// processor include path; only sar3d_processor.cpp needs them.
|
||||
struct State;
|
||||
std::unique_ptr<State> state_;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,471 @@
|
||||
#include "sar3d_gpu.hpp"
|
||||
|
||||
#include <stdexcept>
|
||||
|
||||
#ifdef SAR3D_WITH_CUDA
|
||||
#include <cuda.h>
|
||||
#include <nvrtc.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <climits>
|
||||
#include <cmath>
|
||||
#include <string>
|
||||
#include <type_traits>
|
||||
#endif
|
||||
|
||||
namespace radar::processing::sar3d {
|
||||
|
||||
#ifdef SAR3D_WITH_CUDA
|
||||
namespace {
|
||||
|
||||
// Compiled at run time by NVRTC for the installed GPU, so the build needs no
|
||||
// nvcc. Arithmetic is float32 apart from a few per-voxel terms: FP64 runs at
|
||||
// 1/32-1/64 of the FP32 rate on consumer GPUs, and float32 already keeps the
|
||||
// A-scan sample position to ~1e-3 of a sample of the 8x oversampled A-scan.
|
||||
// Volumes are [y][z][x] with x fastest; their sizes stay below 2^31 (checked on
|
||||
// the host), so the hot index arithmetic is 32-bit.
|
||||
constexpr const char* kKernelSource = R"CUDA(
|
||||
struct Contribution {
|
||||
float tx[3];
|
||||
float rx[3];
|
||||
float weight;
|
||||
float inv_reference_ranges;
|
||||
};
|
||||
|
||||
// numpy.fft.ifft of a band spectrum placed at bins first_bin.. of a size-N
|
||||
// spectrum (N a power of two), summed directly: the band is a few hundred
|
||||
// bins of thousands. One thread per (pair, sample); the phase index is exact.
|
||||
extern "C" __global__ void inverse_dft(
|
||||
const float2* __restrict__ bands, const int points, const int first_bin,
|
||||
const int size, const int items, float2* __restrict__ ascans)
|
||||
{
|
||||
const int item = blockIdx.x * blockDim.x + threadIdx.x;
|
||||
if (item >= items) return;
|
||||
const unsigned k = (unsigned)(item % size);
|
||||
const unsigned mask = (unsigned)size - 1u;
|
||||
const float2* band = bands + (item / size) * points;
|
||||
unsigned phase = (unsigned)(((unsigned long long)first_bin * k) & mask);
|
||||
const float turns_per_index = 2.0f / (float)size;
|
||||
float re = 0.0f, im = 0.0f;
|
||||
for (int n = 0; n < points; ++n) {
|
||||
float s, c;
|
||||
sincospif((float)phase * turns_per_index, &s, &c);
|
||||
const float2 b = band[n];
|
||||
re += b.x * c - b.y * s;
|
||||
im += b.x * s + b.y * c;
|
||||
phase = (phase + k) & mask;
|
||||
}
|
||||
ascans[item] = make_float2(re / (float)size, im / (float)size);
|
||||
}
|
||||
|
||||
// Coherent backprojection, one thread per voxel: |sum w*comp*h| / sum w over the
|
||||
// valid (frame, pair) terms (notebook sar_frame_contribution and the first line
|
||||
// of sar_finish_volume).
|
||||
extern "C" __global__ void backproject(
|
||||
const float2* __restrict__ ascans, const int samples,
|
||||
const Contribution* __restrict__ contributions, const int count,
|
||||
const double* __restrict__ gx, const double* __restrict__ gy, const double* __restrict__ gz,
|
||||
const int nx, const int nz, const int voxels,
|
||||
const double cos_p, const double sin_p, const double ground_depth,
|
||||
const double min_depth, const double max_depth,
|
||||
const float samples_per_metre, const int use_compensation,
|
||||
const float comp_power, const float comp_max,
|
||||
float* __restrict__ volume)
|
||||
{
|
||||
const int voxel = blockIdx.x * blockDim.x + threadIdx.x;
|
||||
if (voxel >= voxels) return;
|
||||
const int row = voxel / nx;
|
||||
// Scene -> radar axes (sar_scene_to_radar_yz). The depth gate is decided in
|
||||
// double so voxels on its edge are classified like in the reference.
|
||||
const double y0 = gy[row / nz] - ground_depth;
|
||||
const double zs = gz[row % nz];
|
||||
const double depth = -y0 * sin_p + zs * cos_p;
|
||||
if (depth < min_depth || depth > max_depth) {
|
||||
volume[voxel] = 0.0f;
|
||||
return;
|
||||
}
|
||||
const float px = (float)gx[voxel - row * nx];
|
||||
const float py = (float)(y0 * cos_p + zs * sin_p);
|
||||
const float pz = (float)depth;
|
||||
const float last = (float)(samples - 1);
|
||||
float re = 0.0f, im = 0.0f, weights = 0.0f;
|
||||
for (int c = 0; c < count; ++c) {
|
||||
const Contribution k = contributions[c];
|
||||
const float dxt = px - k.tx[0], dyt = py - k.tx[1], dzt = pz - k.tx[2];
|
||||
const float dxr = px - k.rx[0], dyr = py - k.rx[1], dzr = pz - k.rx[2];
|
||||
const float rt = sqrtf(dxt * dxt + dyt * dyt + dzt * dzt);
|
||||
const float rr = sqrtf(dxr * dxr + dyr * dyr + dzr * dzr);
|
||||
const float position = (rt + rr) * samples_per_metre;
|
||||
if (position > last) continue;
|
||||
const int i = min((int)position, samples - 2);
|
||||
const float frac = position - (float)i;
|
||||
const float2* row_ascan = ascans + (long long)c * samples;
|
||||
const float2 a = row_ascan[i];
|
||||
const float2 b = row_ascan[i + 1];
|
||||
float w = k.weight;
|
||||
if (use_compensation) {
|
||||
// sar_compensation_weight: 1 / (geo / geo_ref)^power, clipped.
|
||||
w *= fminf(__powf(rt * rr * k.inv_reference_ranges, comp_power), comp_max);
|
||||
}
|
||||
re += w * (a.x + frac * (b.x - a.x));
|
||||
im += w * (a.y + frac * (b.y - a.y));
|
||||
weights += k.weight;
|
||||
}
|
||||
volume[voxel] = sqrtf(re * re + im * im) / (weights + 1e-12f);
|
||||
}
|
||||
|
||||
// One pass of scipy.ndimage.gaussian_filter1d (mode 'reflect') along Z or X.
|
||||
extern "C" __global__ void smooth(
|
||||
const float* __restrict__ src, float* __restrict__ dst,
|
||||
const float* __restrict__ taps, const int radius,
|
||||
const int nx, const int nz, const int voxels, const int along_z)
|
||||
{
|
||||
const int voxel = blockIdx.x * blockDim.x + threadIdx.x;
|
||||
if (voxel >= voxels) return;
|
||||
const int length = along_z ? nz : nx;
|
||||
const int index = along_z ? (voxel / nx) % nz : voxel % nx;
|
||||
const int stride = along_z ? nx : 1;
|
||||
float sum = 0.0f;
|
||||
for (int k = -radius; k <= radius; ++k) {
|
||||
int j = index + k;
|
||||
while (j < 0 || j >= length) j = j < 0 ? -j - 1 : 2 * length - j - 1; // d c b a | a b c d | d c b a
|
||||
sum += taps[k + radius] * src[voxel + (j - index) * stride];
|
||||
}
|
||||
dst[voxel] = sum;
|
||||
}
|
||||
|
||||
// dst[o] = max over r of src[(o / inner) * outer_stride + (o % inner) * inner_stride + r * reduce_stride].
|
||||
extern "C" __global__ void max_project(
|
||||
const float* __restrict__ src, float* __restrict__ dst, const int outputs, const int inner,
|
||||
const int outer_stride, const int inner_stride, const int reduce, const int reduce_stride)
|
||||
{
|
||||
const int o = blockIdx.x * blockDim.x + threadIdx.x;
|
||||
if (o >= outputs) return;
|
||||
const float* p = src + (o / inner) * outer_stride + (o % inner) * inner_stride;
|
||||
float m = 0.0f;
|
||||
for (int r = 0; r < reduce; ++r) m = fmaxf(m, p[r * reduce_stride]);
|
||||
dst[o] = m;
|
||||
}
|
||||
|
||||
// Orthographic max projection. The volume is non-negative, so float bit
|
||||
// patterns order like the values and an integer atomicMax does the job. The
|
||||
// pixel is found in double, so it does not hinge on float rounding.
|
||||
extern "C" __global__ void project_oblique(
|
||||
const float* __restrict__ volume,
|
||||
const double* __restrict__ gx, const double* __restrict__ gy, const double* __restrict__ gz,
|
||||
const int nx, const int nz, const int voxels,
|
||||
const double ux, const double uy, const double uz, const double vx, const double vy, const double vz,
|
||||
const double u0, const double v0, const double inv_pixel, const int nu, const int nv,
|
||||
unsigned int* __restrict__ image)
|
||||
{
|
||||
const int voxel = blockIdx.x * blockDim.x + threadIdx.x;
|
||||
if (voxel >= voxels) return;
|
||||
const float value = volume[voxel];
|
||||
if (!(value > 0.0f)) return;
|
||||
const int row = voxel / nx;
|
||||
const double x = gx[voxel - row * nx];
|
||||
const double y = gy[row / nz];
|
||||
const double z = gz[row % nz];
|
||||
const int pu = __double2int_rn((ux * x + uy * y + uz * z - u0) * inv_pixel);
|
||||
const int pv = __double2int_rn((vx * x + vy * y + vz * z - v0) * inv_pixel);
|
||||
if (pu < 0 || pu >= nu || pv < 0 || pv >= nv) return;
|
||||
atomicMax(image + pv * nu + pu, __float_as_uint(value));
|
||||
}
|
||||
)CUDA";
|
||||
|
||||
constexpr int kBlockSize = 256;
|
||||
|
||||
void check(CUresult result, const char* what) {
|
||||
if (result != CUDA_SUCCESS) {
|
||||
const char* text = nullptr;
|
||||
cuGetErrorString(result, &text);
|
||||
throw std::runtime_error(std::string{"CUDA "} + what + ": " + (text != nullptr ? text : "unknown error"));
|
||||
}
|
||||
}
|
||||
|
||||
void check(nvrtcResult result, const char* what) {
|
||||
if (result != NVRTC_SUCCESS) {
|
||||
throw std::runtime_error(std::string{"NVRTC "} + what + ": " + nvrtcGetErrorString(result));
|
||||
}
|
||||
}
|
||||
|
||||
[[nodiscard]] auto checked_int(std::size_t value, const char* what) -> int {
|
||||
if (value > static_cast<std::size_t>(INT_MAX)) {
|
||||
throw std::runtime_error(std::string{"sar3d "} + what + " exceeds the 32-bit kernel indexing");
|
||||
}
|
||||
return static_cast<int>(value);
|
||||
}
|
||||
|
||||
// Native code when this NVRTC knows the GPU's architecture, otherwise PTX for
|
||||
// the newest architecture it knows, which the driver compiles on load.
|
||||
[[nodiscard]] auto compile_kernels(CUdevice device) -> std::string {
|
||||
int major = 0;
|
||||
int minor = 0;
|
||||
check(cuDeviceGetAttribute(&major, CU_DEVICE_ATTRIBUTE_COMPUTE_CAPABILITY_MAJOR, device), "compute capability");
|
||||
check(cuDeviceGetAttribute(&minor, CU_DEVICE_ATTRIBUTE_COMPUTE_CAPABILITY_MINOR, device), "compute capability");
|
||||
const int device_arch = 10 * major + minor;
|
||||
int arch_count = 0;
|
||||
check(nvrtcGetNumSupportedArchs(&arch_count), "supported architectures");
|
||||
std::vector<int> archs(static_cast<std::size_t>(arch_count));
|
||||
check(nvrtcGetSupportedArchs(archs.data()), "supported architectures");
|
||||
int target = 0;
|
||||
for (const int arch : archs) {
|
||||
if (arch <= device_arch) {
|
||||
target = std::max(target, arch);
|
||||
}
|
||||
}
|
||||
if (target == 0) {
|
||||
throw std::runtime_error(
|
||||
"GPU sm_" + std::to_string(device_arch) + " is older than every architecture this NVRTC supports"
|
||||
);
|
||||
}
|
||||
const bool native = target == device_arch;
|
||||
const std::string arch_option =
|
||||
std::string{native ? "--gpu-architecture=sm_" : "--gpu-architecture=compute_"} + std::to_string(target);
|
||||
|
||||
nvrtcProgram raw_program = nullptr;
|
||||
check(nvrtcCreateProgram(&raw_program, kKernelSource, "sar3d.cu", 0, nullptr, nullptr), "create program");
|
||||
const auto destroy = [](nvrtcProgram program) { nvrtcDestroyProgram(&program); };
|
||||
const std::unique_ptr<std::remove_pointer_t<nvrtcProgram>, decltype(destroy)> program(raw_program, destroy);
|
||||
|
||||
const char* options[] = {arch_option.c_str()};
|
||||
if (nvrtcCompileProgram(program.get(), 1, options) != NVRTC_SUCCESS) {
|
||||
std::size_t log_size = 0U;
|
||||
nvrtcGetProgramLogSize(program.get(), &log_size);
|
||||
std::string log(log_size, '\0');
|
||||
nvrtcGetProgramLog(program.get(), log.data());
|
||||
throw std::runtime_error("NVRTC failed to compile the sar3d kernels:\n" + log);
|
||||
}
|
||||
const auto image_size = native ? nvrtcGetCUBINSize : nvrtcGetPTXSize;
|
||||
const auto image_data = native ? nvrtcGetCUBIN : nvrtcGetPTX;
|
||||
std::size_t size = 0U;
|
||||
check(image_size(program.get(), &size), "kernel image size");
|
||||
std::string image(size, '\0');
|
||||
check(image_data(program.get(), image.data()), "kernel image");
|
||||
return image;
|
||||
}
|
||||
|
||||
// Device allocation that only grows, so steady-state frames never reallocate.
|
||||
class DeviceBuffer {
|
||||
public:
|
||||
DeviceBuffer() = default;
|
||||
DeviceBuffer(const DeviceBuffer&) = delete;
|
||||
auto operator=(const DeviceBuffer&) -> DeviceBuffer& = delete;
|
||||
~DeviceBuffer() {
|
||||
if (pointer_ != 0U) {
|
||||
cuMemFree(pointer_);
|
||||
}
|
||||
}
|
||||
|
||||
auto reserve(std::size_t bytes) -> CUdeviceptr {
|
||||
if (bytes > capacity_) {
|
||||
if (pointer_ != 0U) {
|
||||
check(cuMemFree(pointer_), "free");
|
||||
pointer_ = 0U;
|
||||
capacity_ = 0U;
|
||||
}
|
||||
check(cuMemAlloc(&pointer_, bytes), "allocate");
|
||||
capacity_ = bytes;
|
||||
}
|
||||
return pointer_;
|
||||
}
|
||||
|
||||
template <class T>
|
||||
auto upload(std::span<const T> values) -> CUdeviceptr {
|
||||
reserve(values.size_bytes());
|
||||
if (!values.empty()) {
|
||||
check(cuMemcpyHtoD(pointer_, values.data(), values.size_bytes()), "upload");
|
||||
}
|
||||
return pointer_;
|
||||
}
|
||||
|
||||
[[nodiscard]] auto pointer() const -> CUdeviceptr {
|
||||
return pointer_;
|
||||
}
|
||||
|
||||
private:
|
||||
CUdeviceptr pointer_ = 0U;
|
||||
std::size_t capacity_ = 0U;
|
||||
};
|
||||
|
||||
template <class T>
|
||||
[[nodiscard]] auto download(CUdeviceptr source, std::size_t count) -> std::vector<T> {
|
||||
std::vector<T> values(count);
|
||||
check(cuMemcpyDtoH(values.data(), source, sizeof(T) * count), "download");
|
||||
return values;
|
||||
}
|
||||
|
||||
// One thread per item; argument types must match the kernel signature exactly.
|
||||
template <class... Args>
|
||||
void launch(CUfunction kernel, int items, Args... args) {
|
||||
void* parameters[] = {static_cast<void*>(&args)...};
|
||||
const auto blocks = static_cast<unsigned>((items + kBlockSize - 1) / kBlockSize);
|
||||
check(cuLaunchKernel(kernel, blocks, 1U, 1U, kBlockSize, 1U, 1U, 0U, nullptr, parameters, nullptr), "launch");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
struct Gpu::Impl {
|
||||
// Declared first, destroyed last: buffers and the module need the context.
|
||||
struct Context {
|
||||
CUdevice device = 0;
|
||||
CUcontext handle = nullptr;
|
||||
Context() {
|
||||
check(cuInit(0U), "init");
|
||||
check(cuDeviceGet(&device, 0), "device");
|
||||
check(cuDevicePrimaryCtxRetain(&handle, device), "context");
|
||||
if (const CUresult result = cuCtxSetCurrent(handle); result != CUDA_SUCCESS) {
|
||||
cuDevicePrimaryCtxRelease(device);
|
||||
check(result, "context");
|
||||
}
|
||||
}
|
||||
~Context() { cuDevicePrimaryCtxRelease(device); }
|
||||
} context;
|
||||
struct Module {
|
||||
CUmodule handle = nullptr;
|
||||
explicit Module(const std::string& image) { check(cuModuleLoadData(&handle, image.data()), "load kernels"); }
|
||||
~Module() { cuModuleUnload(handle); }
|
||||
[[nodiscard]] auto function(const char* name) const -> CUfunction {
|
||||
CUfunction kernel = nullptr;
|
||||
check(cuModuleGetFunction(&kernel, handle, name), name);
|
||||
return kernel;
|
||||
}
|
||||
} module{compile_kernels(context.device)};
|
||||
CUfunction inverse_dft = module.function("inverse_dft");
|
||||
CUfunction backproject = module.function("backproject");
|
||||
CUfunction smooth = module.function("smooth");
|
||||
CUfunction max_project = module.function("max_project");
|
||||
CUfunction project_oblique = module.function("project_oblique");
|
||||
int smooth_radius = 0;
|
||||
DeviceBuffer taps;
|
||||
DeviceBuffer bands;
|
||||
DeviceBuffer axes;
|
||||
DeviceBuffer contributions;
|
||||
DeviceBuffer ascans;
|
||||
DeviceBuffer volume;
|
||||
DeviceBuffer scratch;
|
||||
DeviceBuffer images;
|
||||
|
||||
~Impl() { cuCtxSetCurrent(context.handle); }
|
||||
};
|
||||
|
||||
Gpu::Gpu(double smooth_sigma_voxels) : impl_(std::make_unique<Impl>()) {
|
||||
// scipy.ndimage.gaussian_filter1d taps: radius int(4 sigma + 0.5), normalized.
|
||||
const int radius = static_cast<int>(4.0 * smooth_sigma_voxels + 0.5);
|
||||
std::vector<double> weights{};
|
||||
for (int k = -radius; k <= radius; ++k) {
|
||||
weights.push_back(std::exp(-0.5 * k * k / (smooth_sigma_voxels * smooth_sigma_voxels)));
|
||||
}
|
||||
double total = 0.0;
|
||||
for (const double weight : weights) {
|
||||
total += weight;
|
||||
}
|
||||
std::vector<float> taps{};
|
||||
for (const double weight : weights) {
|
||||
taps.push_back(static_cast<float>(weight / total));
|
||||
}
|
||||
impl_->smooth_radius = radius;
|
||||
impl_->taps.upload(std::span<const float>(taps));
|
||||
}
|
||||
|
||||
Gpu::~Gpu() = default;
|
||||
|
||||
auto Gpu::inverse_dft(
|
||||
std::span<const std::complex<float>> bands,
|
||||
std::size_t points,
|
||||
std::size_t first_bin,
|
||||
std::size_t size
|
||||
) -> std::vector<std::complex<float>> {
|
||||
Impl& gpu = *impl_;
|
||||
check(cuCtxSetCurrent(gpu.context.handle), "context");
|
||||
const std::size_t samples = bands.size() / points * size;
|
||||
const int items = checked_int(samples, "A-scan stack");
|
||||
const CUdeviceptr input = gpu.bands.upload(bands);
|
||||
const CUdeviceptr output = gpu.ascans.reserve(sizeof(std::complex<float>) * samples);
|
||||
launch(gpu.inverse_dft, items, input, checked_int(points, "band"), checked_int(first_bin, "first bin"),
|
||||
checked_int(size, "A-scan"), items, output);
|
||||
return download<std::complex<float>>(output, samples);
|
||||
}
|
||||
|
||||
auto Gpu::project(const VolumeRequest& request) -> Projections {
|
||||
Impl& gpu = *impl_;
|
||||
check(cuCtxSetCurrent(gpu.context.handle), "context");
|
||||
|
||||
const int nx = checked_int(request.x.size(), "grid");
|
||||
const int ny = checked_int(request.y.size(), "grid");
|
||||
const int nz = checked_int(request.z.size(), "grid");
|
||||
const int voxels = checked_int(request.x.size() * request.y.size() * request.z.size(), "grid");
|
||||
const int samples = checked_int(request.samples_per_ascan, "A-scan");
|
||||
|
||||
std::vector<double> axes(request.x.begin(), request.x.end());
|
||||
axes.insert(axes.end(), request.y.begin(), request.y.end());
|
||||
axes.insert(axes.end(), request.z.begin(), request.z.end());
|
||||
const CUdeviceptr gx = gpu.axes.upload(std::span<const double>(axes));
|
||||
const CUdeviceptr gy = gx + sizeof(double) * request.x.size();
|
||||
const CUdeviceptr gz = gy + sizeof(double) * request.y.size();
|
||||
const CUdeviceptr contributions = gpu.contributions.upload(request.contributions);
|
||||
const CUdeviceptr ascans = gpu.ascans.upload(request.ascans);
|
||||
const CUdeviceptr volume = gpu.volume.reserve(sizeof(float) * static_cast<std::size_t>(voxels));
|
||||
const CUdeviceptr scratch = gpu.scratch.reserve(sizeof(float) * static_cast<std::size_t>(voxels));
|
||||
|
||||
launch(gpu.backproject, voxels, ascans, samples, contributions,
|
||||
checked_int(request.contributions.size(), "aperture"), gx, gy, gz, nx, nz, voxels,
|
||||
std::cos(request.pitch_rad), std::sin(request.pitch_rad), request.ground_depth_m,
|
||||
request.min_depth_m, request.max_depth_m, static_cast<float>(request.samples_per_metre),
|
||||
static_cast<int>(request.use_compensation), request.comp_range_power, request.comp_weight_max, volume);
|
||||
// Separable Gaussian with sigma (0, s, s) over [y][z][x]: Z first, then X, like scipy.
|
||||
const CUdeviceptr taps = gpu.taps.pointer();
|
||||
launch(gpu.smooth, voxels, volume, scratch, taps, gpu.smooth_radius, nx, nz, voxels, 1);
|
||||
launch(gpu.smooth, voxels, scratch, volume, taps, gpu.smooth_radius, nx, nz, voxels, 0);
|
||||
|
||||
const ObliqueView& view = request.oblique;
|
||||
const int front_size = ny * nx;
|
||||
const int side_size = ny * nz;
|
||||
const int top_size = nz * nx;
|
||||
const int oblique_size = checked_int(static_cast<std::size_t>(view.nv) * static_cast<std::size_t>(view.nu), "view");
|
||||
const auto image_floats = static_cast<std::size_t>(front_size + side_size + top_size + oblique_size);
|
||||
const CUdeviceptr front = gpu.images.reserve(sizeof(float) * image_floats);
|
||||
const CUdeviceptr side = front + sizeof(float) * static_cast<std::size_t>(front_size);
|
||||
const CUdeviceptr top = side + sizeof(float) * static_cast<std::size_t>(side_size);
|
||||
const CUdeviceptr oblique = top + sizeof(float) * static_cast<std::size_t>(top_size);
|
||||
const int plane = nz * nx;
|
||||
launch(gpu.max_project, front_size, volume, front, front_size, nx, plane, 1, nz, nx);
|
||||
launch(gpu.max_project, side_size, volume, side, side_size, nz, plane, nx, nx, 1);
|
||||
launch(gpu.max_project, top_size, volume, top, top_size, nx, nx, 1, ny, plane);
|
||||
check(cuMemsetD32(oblique, 0U, static_cast<std::size_t>(oblique_size)), "clear");
|
||||
launch(gpu.project_oblique, voxels, volume, gx, gy, gz, nx, nz, voxels, view.u[0], view.u[1], view.u[2],
|
||||
view.v[0], view.v[1], view.v[2], view.u0, view.v0, 1.0 / view.pixel_m, view.nu, view.nv, oblique);
|
||||
|
||||
return Projections{
|
||||
.front = download<float>(front, static_cast<std::size_t>(front_size)),
|
||||
.side = download<float>(side, static_cast<std::size_t>(side_size)),
|
||||
.top = download<float>(top, static_cast<std::size_t>(top_size)),
|
||||
.oblique = download<float>(oblique, static_cast<std::size_t>(oblique_size)),
|
||||
};
|
||||
}
|
||||
|
||||
#else // !SAR3D_WITH_CUDA
|
||||
|
||||
struct Gpu::Impl {};
|
||||
|
||||
Gpu::Gpu(double /*smooth_sigma_voxels*/) {
|
||||
throw std::runtime_error(
|
||||
"data_processor was built without CUDA (nvrtc.h, cuda.h, libnvrtc or libcuda not found), "
|
||||
"so sar3d cannot run; install the CUDA requirements and rebuild"
|
||||
);
|
||||
}
|
||||
|
||||
Gpu::~Gpu() = default;
|
||||
|
||||
auto Gpu::inverse_dft(std::span<const std::complex<float>>, std::size_t, std::size_t, std::size_t)
|
||||
-> std::vector<std::complex<float>> {
|
||||
return {};
|
||||
}
|
||||
|
||||
auto Gpu::project(const VolumeRequest& /*request*/) -> Projections {
|
||||
return {};
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
} // namespace radar::processing::sar3d
|
||||
@@ -0,0 +1,91 @@
|
||||
#pragma once
|
||||
|
||||
#include <array>
|
||||
#include <complex>
|
||||
#include <cstddef>
|
||||
#include <memory>
|
||||
#include <span>
|
||||
#include <vector>
|
||||
|
||||
// GPU half of the sar3d processor: A-scans of the band spectra, and the
|
||||
// backprojection of an aperture into the 3D volume, smoothed and projected.
|
||||
// Only the projections of the volume leave the GPU.
|
||||
namespace radar::processing::sar3d {
|
||||
|
||||
// One (aperture frame, Tx/Rx pair) term of the backprojection sum. The layout
|
||||
// is shared with the CUDA kernel.
|
||||
struct Contribution {
|
||||
std::array<float, 3> tx{}; // Tx in radar axes, shifted by the frame offset [m]
|
||||
std::array<float, 3> rx{}; // Rx in radar axes, shifted by the frame offset [m]
|
||||
float weight = 0.0F; // aperture frame weight
|
||||
float inv_reference_ranges = 0.0F; // 1 / (Rtx * Rrx) at the compensation reference point
|
||||
};
|
||||
static_assert(sizeof(Contribution) == 8U * sizeof(float));
|
||||
|
||||
// Orthographic view of the volume: a voxel at scene point p lands in pixel
|
||||
// (round((u.p - u0) / pixel), round((v.p - v0) / pixel)).
|
||||
struct ObliqueView {
|
||||
std::array<double, 3> u{}; // screen right, scene axes (X, Y, Z)
|
||||
std::array<double, 3> v{}; // screen up
|
||||
double u0 = 0.0;
|
||||
double v0 = 0.0;
|
||||
double pixel_m = 0.0;
|
||||
int nu = 0;
|
||||
int nv = 0;
|
||||
};
|
||||
|
||||
struct VolumeRequest {
|
||||
// Scene axes [m]: X across, Y height above the ground, Z along the ground.
|
||||
std::span<const double> x;
|
||||
std::span<const double> y;
|
||||
std::span<const double> z;
|
||||
double pitch_rad = 0.0; // radar tilt that maps scene axes onto radar axes
|
||||
double ground_depth_m = 0.0; // height of the radar axes origin above the ground
|
||||
double min_depth_m = 0.0; // radar-depth gate; voxels outside it stay empty
|
||||
double max_depth_m = 0.0;
|
||||
std::span<const Contribution> contributions;
|
||||
// One A-scan per contribution, back to back; sample k is the Tx+Rx path k / samples_per_metre.
|
||||
std::span<const std::complex<float>> ascans;
|
||||
std::size_t samples_per_ascan = 0U;
|
||||
double samples_per_metre = 0.0;
|
||||
bool use_compensation = true;
|
||||
float comp_range_power = 0.0F;
|
||||
float comp_weight_max = 0.0F;
|
||||
ObliqueView oblique{};
|
||||
};
|
||||
|
||||
// Max-projections of the smoothed, not yet normalized volume (row-major).
|
||||
struct Projections {
|
||||
std::vector<float> front; // [y][x], maximum along Z
|
||||
std::vector<float> side; // [y][z], maximum along X
|
||||
std::vector<float> top; // [z][x], maximum along Y
|
||||
std::vector<float> oblique; // [v][u]
|
||||
};
|
||||
|
||||
// CUDA context with the kernels compiled for the installed GPU. Construction
|
||||
// throws when no usable GPU is present or the processor was built without CUDA.
|
||||
class Gpu {
|
||||
public:
|
||||
explicit Gpu(double smooth_sigma_voxels);
|
||||
~Gpu();
|
||||
Gpu(const Gpu&) = delete;
|
||||
auto operator=(const Gpu&) -> Gpu& = delete;
|
||||
|
||||
// numpy.fft.ifft of each band spectrum placed at bins [first_bin, first_bin +
|
||||
// points) of a `size`-point spectrum; `bands` holds the bands back to back.
|
||||
[[nodiscard]] auto inverse_dft(
|
||||
std::span<const std::complex<float>> bands,
|
||||
std::size_t points,
|
||||
std::size_t first_bin,
|
||||
std::size_t size
|
||||
) -> std::vector<std::complex<float>>;
|
||||
|
||||
// Backprojects, applies the Gaussian smoothing over (Z, X) and projects.
|
||||
[[nodiscard]] auto project(const VolumeRequest& request) -> Projections;
|
||||
|
||||
private:
|
||||
struct Impl;
|
||||
std::unique_ptr<Impl> impl_;
|
||||
};
|
||||
|
||||
} // namespace radar::processing::sar3d
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,349 +0,0 @@
|
||||
// Parity check of the sar3d processor against the reference notebook
|
||||
// Multi_antenna_3D_clean_v1.ipynb.
|
||||
//
|
||||
// Feeds a preprocessed snapshot of an extracted replay dataset through
|
||||
// Sar3dProcessor exactly the way the live pipeline would (geometry and combo
|
||||
// order from the dataset's config_profile.json, per-trace capture windows from
|
||||
// the snapshot's meta.json) and dumps the finished volume for comparison with
|
||||
// the notebook's *_volume.npz export:
|
||||
//
|
||||
// sar3d_parity <dataset_root> <center_snapshot> [--dump-dir <dir>]
|
||||
// python_app/scripts/sar3d_volume_compare.py <dir>/sar3d_volume_<id> <ref.npz>
|
||||
//
|
||||
// <dataset_root> contains config_profile.json and preprocessed/<snapshot>/.
|
||||
// Defaults mirror the parameters the reference volumes were built with
|
||||
// (f 2..6 GHz, depth 2.5..8 m, speed 1.45 m/s, look angle 17.5 deg).
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <complex>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <dirent.h>
|
||||
#include <fstream>
|
||||
#include <span>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include "gpr/config.hpp"
|
||||
#include "gpr/geometry.hpp"
|
||||
#include "processing_live_config.hpp"
|
||||
#include "run_config.hpp"
|
||||
#include "sar3d_processor.hpp"
|
||||
#include "shared_types.hpp"
|
||||
|
||||
namespace {
|
||||
|
||||
// Minimal numpy .npy loader (v1.0, 1-D complex128/complex64/float64/float32).
|
||||
struct NpyArray {
|
||||
std::vector<double> f64{};
|
||||
std::vector<std::complex<double>> c128{};
|
||||
};
|
||||
|
||||
NpyArray load_npy(const std::string& path) {
|
||||
std::ifstream in(path, std::ios::binary);
|
||||
if (!in) {
|
||||
throw std::runtime_error("cannot open " + path);
|
||||
}
|
||||
char magic[6];
|
||||
in.read(magic, 6);
|
||||
std::uint8_t version[2];
|
||||
in.read(reinterpret_cast<char*>(version), 2);
|
||||
std::uint16_t header_len = 0;
|
||||
in.read(reinterpret_cast<char*>(&header_len), 2);
|
||||
std::string header(header_len, '\0');
|
||||
in.read(header.data(), header_len);
|
||||
const auto shape_pos = header.find("'shape':");
|
||||
const auto left = header.find('(', shape_pos);
|
||||
const auto right = header.find(')', shape_pos);
|
||||
const long count = std::stol(header.substr(left + 1, right - left - 1));
|
||||
|
||||
NpyArray array{};
|
||||
const auto has = [&header](const char* token) { return header.find(token) != std::string::npos; };
|
||||
if (has("c8")) {
|
||||
array.c128.resize(count);
|
||||
std::vector<std::complex<float>> tmp(count);
|
||||
in.read(reinterpret_cast<char*>(tmp.data()), count * sizeof(std::complex<float>));
|
||||
for (long i = 0; i < count; ++i) {
|
||||
array.c128[i] = std::complex<double>(tmp[i].real(), tmp[i].imag());
|
||||
}
|
||||
} else if (has("c16")) {
|
||||
array.c128.resize(count);
|
||||
in.read(reinterpret_cast<char*>(array.c128.data()), count * sizeof(std::complex<double>));
|
||||
} else if (has("f4")) {
|
||||
array.f64.resize(count);
|
||||
std::vector<float> tmp(count);
|
||||
in.read(reinterpret_cast<char*>(tmp.data()), count * sizeof(float));
|
||||
for (long i = 0; i < count; ++i) {
|
||||
array.f64[i] = tmp[i];
|
||||
}
|
||||
} else {
|
||||
array.f64.resize(count);
|
||||
in.read(reinterpret_cast<char*>(array.f64.data()), count * sizeof(double));
|
||||
}
|
||||
return array;
|
||||
}
|
||||
|
||||
// Live config matching the reference notebook run (npz meta parameters).
|
||||
// The default program grid is a fixed window (X -2..2, Z 0..8), while the
|
||||
// notebook derives its bounds from the antenna span and the depth gate — so
|
||||
// the notebook bounds are recomputed here and passed explicitly.
|
||||
auto build_live_config(const radar::config::RunConfig& run_config)
|
||||
-> radar::processing::ProcessingLiveConfig {
|
||||
radar::processing::ProcessingLiveConfig live{};
|
||||
live.processor_mode = "sar3d";
|
||||
live.gpr_start_freq_mhz = 2000.0F;
|
||||
live.gpr_stop_freq_mhz = 6000.0F;
|
||||
live.gpr_min_depth_m = 2.5F;
|
||||
live.gpr_max_depth_m = 8.0F;
|
||||
live.gpr_speed_m_s = 1.45F;
|
||||
live.gpr_look_angle_deg = 17.5F;
|
||||
live.gpr_direction_sign = 1.0F;
|
||||
live.gpr_apply_freq_phase_correction = true;
|
||||
|
||||
gpr::GeometryConfig geom{};
|
||||
geom.input_idx.clear();
|
||||
geom.output_idx.clear();
|
||||
geom.rx_positions.clear();
|
||||
geom.tx_positions.clear();
|
||||
for (const auto& entry : run_config.gpr.rx_geometry) {
|
||||
geom.input_idx.push_back(static_cast<int>(entry.input_pos));
|
||||
geom.rx_positions.push_back({entry.x_m, entry.y_m, entry.z_m});
|
||||
}
|
||||
for (const auto& entry : run_config.gpr.tx_geometry) {
|
||||
geom.output_idx.push_back(static_cast<int>(entry.output_pos));
|
||||
geom.tx_positions.push_back({entry.x_m, entry.y_m, entry.z_m});
|
||||
}
|
||||
geom.finalize();
|
||||
gpr::BPConfig bp{};
|
||||
bp.min_depth = live.gpr_min_depth_m;
|
||||
bp.max_depth = live.gpr_max_depth_m;
|
||||
gpr::SARConfig sar{};
|
||||
double x_min = 0.0, x_max = 0.0, z_min = 0.0, z_max = 0.0;
|
||||
gpr::notebook_grid_bounds(geom, bp, sar, live.gpr_look_angle_deg, /*x_margin_m=*/2.0,
|
||||
x_min, x_max, z_min, z_max);
|
||||
live.sar3d_x_min_m = x_min;
|
||||
live.sar3d_x_max_m = x_max;
|
||||
live.sar3d_z_min_m = z_min;
|
||||
live.sar3d_z_max_m = z_max;
|
||||
return live;
|
||||
}
|
||||
|
||||
auto load_collection(
|
||||
const radar::config::RunConfig& run_config,
|
||||
const std::string& root,
|
||||
const std::string& snapshot,
|
||||
std::uint64_t collection_id
|
||||
) -> radar::ipc::PreprocessedCollection {
|
||||
radar::ipc::PreprocessedCollection collection{};
|
||||
collection.collection_id = collection_id;
|
||||
|
||||
// Per-trace capture windows from the snapshot's meta.json; the segmented
|
||||
// timing model is reconstructed from them exactly like in the notebook.
|
||||
std::map<std::pair<std::uint32_t, std::uint32_t>, std::pair<std::uint64_t, std::uint64_t>> windows{};
|
||||
{
|
||||
std::ifstream in(root + "/preprocessed/" + snapshot + "/meta.json");
|
||||
if (in) {
|
||||
nlohmann::json meta{};
|
||||
in >> meta;
|
||||
for (const auto& trace : meta.value("traces", nlohmann::json::array())) {
|
||||
if (!trace.contains("capture_start_ns") || !trace.contains("capture_end_ns")) {
|
||||
continue;
|
||||
}
|
||||
windows[{trace.at("input").get<std::uint32_t>(), trace.at("output").get<std::uint32_t>()}] = {
|
||||
trace.at("capture_start_ns").get<std::uint64_t>(),
|
||||
trace.at("capture_end_ns").get<std::uint64_t>(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::uint64_t first = 0U;
|
||||
std::uint64_t last = 0U;
|
||||
for (const auto& [key, window] : windows) {
|
||||
if (first == 0U || window.first < first) {
|
||||
first = window.first;
|
||||
}
|
||||
last = std::max(last, window.second);
|
||||
}
|
||||
if (first == 0U) {
|
||||
// No meta labels: fall back to the `_ns<...>` folder suffix.
|
||||
const auto ns_pos = snapshot.rfind("_ns");
|
||||
if (ns_pos == std::string::npos) {
|
||||
throw std::runtime_error("snapshot has no meta.json and no _ns suffix: " + snapshot);
|
||||
}
|
||||
last = static_cast<std::uint64_t>(std::stoull(snapshot.substr(ns_pos + 3)));
|
||||
first = last;
|
||||
}
|
||||
collection.capture_start_ns = first;
|
||||
collection.capture_end_ns = last;
|
||||
collection.monotonic_ns = (first + last) / 2U;
|
||||
|
||||
for (const auto& combo : run_config.run_combos) {
|
||||
char path[512];
|
||||
std::snprintf(path, sizeof(path), "%s/preprocessed/%s/i%u_o%u_s21.npy",
|
||||
root.c_str(), snapshot.c_str(), combo.input_pos, combo.output_pos);
|
||||
const auto s21 = load_npy(path);
|
||||
std::snprintf(path, sizeof(path), "%s/preprocessed/%s/i%u_o%u_freq.npy",
|
||||
root.c_str(), snapshot.c_str(), combo.input_pos, combo.output_pos);
|
||||
const auto freq = load_npy(path);
|
||||
|
||||
radar::ipc::SweepTraceBlock trace{};
|
||||
trace.combo = combo;
|
||||
trace.frequency_hz.assign(freq.f64.begin(), freq.f64.end());
|
||||
trace.s21.reserve(s21.c128.size());
|
||||
for (const auto& sample : s21.c128) {
|
||||
trace.s21.push_back(radar::ipc::Complex32{
|
||||
.re = static_cast<float>(sample.real()),
|
||||
.im = static_cast<float>(sample.imag()),
|
||||
});
|
||||
}
|
||||
trace.s11.resize(trace.s21.size());
|
||||
if (const auto found = windows.find({combo.input_pos, combo.output_pos}); found != windows.end()) {
|
||||
trace.capture_start_ns = found->second.first;
|
||||
trace.capture_end_ns = found->second.second;
|
||||
}
|
||||
collection.traces.push_back(std::move(trace));
|
||||
}
|
||||
return collection;
|
||||
}
|
||||
|
||||
void print_projection_argmax(
|
||||
const radar::ipc::ResultPayload& payload,
|
||||
const char* col_axis_name,
|
||||
const char* row_axis_name
|
||||
) {
|
||||
std::size_t argmax = 0U;
|
||||
float best = -1.0F;
|
||||
for (std::size_t index = 0U; index < payload.image_values.size(); ++index) {
|
||||
if (payload.image_values[index] > best) {
|
||||
best = payload.image_values[index];
|
||||
argmax = index;
|
||||
}
|
||||
}
|
||||
const auto nx = payload.image_x_axis.size();
|
||||
const auto col = argmax % nx;
|
||||
const auto row = argmax / nx;
|
||||
std::printf(
|
||||
"%s: max=%.6f at %s=%.1f cm, %s=%.1f cm\n",
|
||||
payload.processing_name.c_str(),
|
||||
static_cast<double>(best),
|
||||
col_axis_name,
|
||||
static_cast<double>(payload.image_x_axis[col]) * 100.0,
|
||||
row_axis_name,
|
||||
static_cast<double>(payload.image_y_axis[row]) * 100.0
|
||||
);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
if (argc < 3) {
|
||||
std::printf("usage: %s <dataset_root> <center_snapshot> [--dump-dir <dir>]\n", argv[0]);
|
||||
return 1;
|
||||
}
|
||||
const std::string root = argv[1];
|
||||
const std::string center = argv[2];
|
||||
std::string dump_dir{};
|
||||
for (int index = 3; index + 1 < argc; ++index) {
|
||||
if (std::string{argv[index]} == "--dump-dir") {
|
||||
dump_dir = argv[index + 1];
|
||||
}
|
||||
}
|
||||
|
||||
const std::string preprocessed = root + "/preprocessed";
|
||||
std::vector<std::string> snapshots{};
|
||||
if (DIR* dir = opendir(preprocessed.c_str())) {
|
||||
for (dirent* entry = nullptr; (entry = readdir(dir)) != nullptr;) {
|
||||
const std::string name = entry->d_name;
|
||||
if (!name.empty() && (std::isdigit(name[0]) != 0)) {
|
||||
snapshots.push_back(name);
|
||||
}
|
||||
}
|
||||
closedir(dir);
|
||||
}
|
||||
std::sort(snapshots.begin(), snapshots.end());
|
||||
const auto center_it = std::find(snapshots.begin(), snapshots.end(), center);
|
||||
if (center_it == snapshots.end()) {
|
||||
std::printf("center %s not found in %s\n", center.c_str(), preprocessed.c_str());
|
||||
return 1;
|
||||
}
|
||||
const auto center_index = static_cast<std::size_t>(center_it - snapshots.begin());
|
||||
|
||||
const auto run_config = radar::config::load_run_config(root + "/config_profile.json");
|
||||
if (run_config.gpr.tx_geometry.empty() || run_config.gpr.rx_geometry.empty()) {
|
||||
std::printf("FAIL: config_profile.json has no gpr geometry\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
auto live = build_live_config(run_config);
|
||||
live.sar3d_volume_dump_dir = dump_dir;
|
||||
const auto radius = static_cast<std::size_t>(live.sar3d_frame_radius);
|
||||
if (center_index < radius || center_index + radius >= snapshots.size()) {
|
||||
std::printf("not enough snapshots around center for radius %zu\n", radius);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// The processor centers the aperture `radius` frames before the newest
|
||||
// collection, so feeding [center-r .. center+r] in order reproduces the
|
||||
// symmetric aperture around `center`.
|
||||
std::vector<radar::ipc::PreprocessedCollection> history{};
|
||||
for (std::size_t index = center_index - radius; index <= center_index + radius; ++index) {
|
||||
history.push_back(load_collection(run_config, root, snapshots[index], index));
|
||||
}
|
||||
std::printf("aperture: %zu frames around %s\n", history.size(), center.c_str());
|
||||
|
||||
radar::processing::Sar3dProcessor processor{};
|
||||
radar::ipc::ResultCollection result{};
|
||||
for (std::size_t index = 0U; index < history.size(); ++index) {
|
||||
result = processor.process_collection(
|
||||
run_config,
|
||||
history[index],
|
||||
std::span<const radar::ipc::PreprocessedCollection>(history.data(), index),
|
||||
live
|
||||
);
|
||||
}
|
||||
|
||||
if (result.collection_payloads.empty()) {
|
||||
std::printf("FAIL: sar3d produced no payloads\n");
|
||||
return 1;
|
||||
}
|
||||
for (const auto& payload : result.collection_payloads) {
|
||||
if (payload.kind == radar::ipc::ResultKind::ScalarF32) {
|
||||
std::printf("%s (volume level): %.9e\n",
|
||||
payload.processing_name.c_str(), static_cast<double>(payload.scalar_value));
|
||||
}
|
||||
}
|
||||
for (const auto& payload : result.collection_payloads) {
|
||||
if (payload.kind == radar::ipc::ResultKind::ImageF32 && payload.processing_name == "sar3d_proj_xz") {
|
||||
print_projection_argmax(payload, "X", "Z");
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom view across the selectable angles: the target's u coordinate must
|
||||
// move smoothly from its X (front, 0 deg) to its Z (side, 90 deg).
|
||||
const auto last_previous =
|
||||
std::span<const radar::ipc::PreprocessedCollection>(history.data(), history.size() - 1U);
|
||||
for (const float angle_deg : {0.0F, 30.0F, 60.0F, 90.0F}) {
|
||||
auto live_angle = live;
|
||||
live_angle.sar3d_volume_dump_dir.clear();
|
||||
live_angle.sar3d_bottom_view_deg = angle_deg;
|
||||
const auto angle_result =
|
||||
processor.process_collection(run_config, history.back(), last_previous, live_angle);
|
||||
for (const auto& payload : angle_result.collection_payloads) {
|
||||
if (payload.kind == radar::ipc::ResultKind::ImageF32 && payload.processing_name == "sar3d_proj_bottom") {
|
||||
std::printf("bottom @ %2.0f deg -> ", static_cast<double>(angle_deg));
|
||||
print_projection_argmax(payload, "u", "Y");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!dump_dir.empty()) {
|
||||
std::printf("volume dumped to %s/sar3d_volume_%llu.{f32,json}\n",
|
||||
dump_dir.c_str(),
|
||||
static_cast<unsigned long long>(history.back().collection_id));
|
||||
}
|
||||
std::printf("OK: sar3d processor path completed\n");
|
||||
return 0;
|
||||
}
|
||||
Reference in New Issue
Block a user