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:
Ayzen
2026-10-02 16:05:22 +03:00
parent 61ef8720e1
commit 45861f4047
50 changed files with 1737 additions and 8144 deletions
@@ -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.
@@ -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;
}