Port the SAR3D background gate, CLEAN and object boxes from Multi_depthnorm_clean

- BG_GATE_ENABLE: frame peak over its median background (sar3d_background); the
  GUI scales the frame with bg_gate_gain and its cloud threshold.
- SAR_CLEAN_ENABLE: CLEAN of a one-frame aperture on a 4x10x4 cm search grid over
  the whole scene; objects in their own scale (sar3d_objects).
- SAR_MARK_ENABLE: boxes by class around object groups (sar3d_marks), with the
  legend under the views.
- SAR_VOXEL_M as three steps X/Y/Z with the notebook defaults 0.025/0.02/0.02.
- GPU backend: image/download/load/project API, per-plane block reduction for the
  maximum, depth gate without FMA; sar3d host code built with -ffp-contract=off.
- References regenerated from notebook cells 1 and 5, with new CLEAN scenes and a
  synthetic box-rule case; docs updated.
This commit is contained in:
Ayzen
2026-10-16 12:08:57 +03:00
parent 9a9b8ce5c7
commit b88d704126
26 changed files with 1895 additions and 407 deletions
+9 -2
View File
@@ -25,6 +25,7 @@ endif
# Rewritten only when the CUDA setup changes, so installing (or removing) CUDA
# rebuilds the sar3d GPU code instead of keeping a stale build.
SAR3D_CUDA_STAMP := $(BUILD_DIR)/sar3d_cuda.flags
SAR3D_HOST_FLAGS := -ffp-contract=off
$(shell mkdir -p $(BUILD_DIR) && echo '$(SAR3D_CXXFLAGS) $(SAR3D_LDFLAGS)' | cmp -s - $(SAR3D_CUDA_STAMP) || echo '$(SAR3D_CXXFLAGS) $(SAR3D_LDFLAGS)' > $(SAR3D_CUDA_STAMP))
INCLUDES := \
@@ -57,6 +58,7 @@ PROCESSOR_SOURCES := \
data_acq_and_processing/processing/processors/src/gpr_processor.cpp \
data_acq_and_processing/processing/processors/src/passthrough_processor.cpp \
data_acq_and_processing/processing/processors/src/sar3d_processor.cpp \
data_acq_and_processing/processing/processors/src/sar3d_clean.cpp \
data_acq_and_processing/processing/processors/src/sar3d_gpu.cpp \
data_acq_and_processing/processing/data_processor/src/processing_live_config.cpp \
data_acq_and_processing/processing/data_processor/src/data_processor.cpp \
@@ -122,13 +124,14 @@ check-native: $(NATIVE_BUILD_DIR)/native_regressions $(BIN_DIR)/collector_regres
BACKPROJECTION_TEST_SOURCES := data_acq_and_processing/tests/backprojection_fixture.cpp \
data_acq_and_processing/common_cpp/ipc/src/shared_types.cpp \
data_acq_and_processing/processing/processors/src/sar3d_processor.cpp \
data_acq_and_processing/processing/processors/src/sar3d_clean.cpp \
data_acq_and_processing/processing/processors/src/sar3d_gpu.cpp \
data_acq_and_processing/processing/processors/src/gpr_processor.cpp
BACKPROJECTION_TEST_OBJS := $(addprefix $(NATIVE_BUILD_DIR)/,$(BACKPROJECTION_TEST_SOURCES:.cpp=.o))
DEPFILES += $(BACKPROJECTION_TEST_OBJS:.o=.d)
# The sar3d processor on the real CUDA backend against the notebook reference;
# without a usable GPU the test checks the warning and skips the rest.
# The sar3d processor on the real CUDA backend against the notebook reference; without a
# usable GPU the test checks the warning and the box rules and skips the rest.
check-sar3d: $(NATIVE_BUILD_DIR)/backprojection_fixture
BACKPROJECTION_FIXTURE="$(abspath $<)" $(PYTHON) data_acq_and_processing/tests/test_backprojection.py Sar3dTest
@@ -139,6 +142,7 @@ $(NATIVE_BUILD_DIR)/backprojection_fixture: $(BACKPROJECTION_TEST_OBJS)
$(CXX) $(NATIVE_CXXFLAGS) $^ -o $@ $(LDFLAGS) $(SAR3D_LDFLAGS)
$(NATIVE_BUILD_DIR)/data_acq_and_processing/processing/processors/src/sar3d_gpu.o: NATIVE_CXXFLAGS += $(SAR3D_CXXFLAGS)
$(NATIVE_BUILD_DIR)/data_acq_and_processing/processing/processors/src/sar3d_%.o: NATIVE_CXXFLAGS += $(SAR3D_HOST_FLAGS)
$(NATIVE_BUILD_DIR)/data_acq_and_processing/processing/processors/src/sar3d_gpu.o: $(SAR3D_CUDA_STAMP)
$(NATIVE_BUILD_DIR)/native_regressions: $(NATIVE_TEST_OBJS)
@@ -159,6 +163,9 @@ $(BIN_DIR)/data_processor: $(DATA_PROCESSOR_OBJS) $(SAR3D_CUDA_STAMP)
$(BUILD_DIR)/data_acq_and_processing/processing/processors/src/sar3d_gpu.o: CXXFLAGS += $(SAR3D_CXXFLAGS)
$(BUILD_DIR)/data_acq_and_processing/processing/processors/src/sar3d_gpu.o: $(SAR3D_CUDA_STAMP)
# The sar3d host code breaks the same ties as NumPy (grid corners on the depth gate, range
# rings), so it is not contracted into fused multiply-adds where the target has them.
$(BUILD_DIR)/data_acq_and_processing/processing/processors/src/sar3d_%.o: CXXFLAGS += $(SAR3D_HOST_FLAGS)
# Keep source-relative paths so moving a source also replaces its dependencies.
$(RPH_BUILD_DIR)/%.o: %.cpp
+38 -13
View File
@@ -71,13 +71,13 @@ controls, processing settings and a copy of the live plot through an embedded we
- **gpr** — coherent back-projection on the ground plane, as in `Horns_motion_4x4_ground.py`,
with object detection. Radar height and look angle define the slice in radar X/Z axes;
the wave speed is 3×10⁸ m/s (air).
- **sar3d** — 3D SAR of the reference notebook `Multi_antenna_demonstrate_depthnorm.ipynb`
- **sar3d** — 3D SAR of the reference notebook `Multi_depthnorm_clean.ipynb`
on an NVIDIA GPU through CUDA: front (X-Y), side (Z-Y), top (X-Z)
and oblique maximum projections of the imaged
volume. Its settings carry the notebook's parameter names; the scene window is
set by the selected antennas with a 2 m X margin and height −0.5…2 m above the ground; its range extent follows
`MIN_DEPTH`/`MAX_DEPTH`, and the voxels are cubes with edge `SAR_VOXEL_M`
(default 2 cm). The radar motion is corrected from each trace's capture
`MIN_DEPTH`/`MAX_DEPTH`, and the voxel steps `SAR_VOXEL_M` X, Y and Z default to the
notebook's 2.5, 2 and 2 cm. The radar motion is corrected from each trace's capture
timestamps, so the sweep timing needs no parameters.
Both coherent GPR and SAR apply `PAIR_DEPTH_EQUALIZE` (on by default) before the
@@ -100,7 +100,27 @@ redraw the existing result without recalculating it. Tx/Rx markers appear on the
front, side and top projections in ground coordinates. A hollow square marks the
peak; its size follows the contiguous half-maximum width along the volume axes
(FWHM, at least one voxel). This measures the imaged response rather than the
reflector's physical dimensions.
reflector's physical dimensions. The square is left out when CLEAN objects are shown
or when the background gate empties the frame.
**BG_GATE_ENABLE** shows a frame relative to its background, the median of the volume
over its working voxels: below 6 backgrounds the frame is empty, at 6 its maximum
appears at the cloud threshold, and from 8.5 the scale is full. **SAR_CLEAN_ENABLE**
runs the notebook's CLEAN on a one-frame aperture: always with `SAR_FRAME_RADIUS` 0,
otherwise while no previous frame is available, as after Start. It subtracts the strongest point response
from the A-scans of every pair, step by step on a 4 × 10 × 4 cm grid over the whole scene
region, until the residual is background. The search ignores `SAR_X_SIZE_M` and
`SAR_Z_SIZE_M`, so reflectors just outside a narrowed scene are subtracted too. Each object is then imaged apart and drawn in its own 0–1
scale over the residual, so a weak object is not hidden by the sidelobes of a strong
one. **SAR_MARK_ENABLE** draws boxes around groups of CLEAN objects: red for very low
objects (like a mine), orange on the ground, blue above it, dashed grey at the scene
boundary, where the response may lie partly outside. The legend is in the title of the
3D view. Without boxes the objects are numbered with their level in backgrounds. All
three switches are on by default, like in the notebook, and the status line reports the
background ratio and the CLEAN result. The gate and CLEAN are processing settings; boxes
only redraw. Their thresholds and rules are the notebook's fixed values. Both cost time:
on a GTX 1060 with the default scene of 10 million voxels, the gate adds about 0.1 s per
frame and CLEAN about 0.4 s more.
The desktop uses a light theme with white plot canvases. **Mode** stays below the
**Actions** buttons while the settings sections scroll; the settings panel and
@@ -222,7 +242,7 @@ The preprocessor still requires S21 calibration/reference sets. Start the GUI:
Open **Preprocessing…**, use **Create Neutral S21 Sets**, then **Start**. This makes
identity calibration + zero reference, preserving the simulated S21 samples.
Finer voxels increase SAR3D work as 1/`SAR_VOXEL_M`³. Increase the voxel size when
SAR3D work grows inversely with the product of the three `SAR_VOXEL_M` steps. Increase them when
a faster preview matters more than spatial resolution. Processing failures appear
above the plots and a failed single capture
finishes with its explanation while retaining the acquired data.
@@ -469,19 +489,24 @@ physical timing. Real devices, GPIO and the CUDA backend need their own hardware
`check-sar3d` runs the real CUDA processor (`data_acq_and_processing/tests/test_backprojection.py`).
It compares it with `sar3d_reference.npz`: compact float32/complex64 inputs, the three
maximum projections of the notebook's volume, its joint peak/level and response
widths for six compensation/normalization, equalization, background, scene and
timing cases. The expected values come from unchanged functions in cells 1 and 14
maximum projections of the notebook's displayed volume, its peak/level, response
widths and background ratio for ten cases. Six cover compensation/normalization,
equalization, background, scene and timing. Four are frames of point reflectors in noise
for CLEAN, which also compare the objects and their boxes: with and without the background
gate, with a scene where the stop at each range's background adds steps, and with a
narrowed scene. The box rules are also checked
one by one on synthetic objects. The expected values come from unchanged functions in cells 1 and 5
of the original notebook, with each frame's previous-collection mean subtracted
from its input spectra by NumPy. The same test checks the published
status for invalid input, a result too large for the ring slot, an empty and a very weak
signal. Without a usable GPU it checks only the CUDA warning and skips the rest; the
notebook itself is not needed to run it. To regenerate the reference, pass the
original notebook (it is kept outside the repository):
status for invalid input, a volume beyond float32, a result too large for the ring slot,
an empty and a very weak signal, and that the views are normalized at their peak on
finer grids. Without a usable GPU it checks only the CUDA warning and the box rules and
skips the rest; the notebook itself is not needed to run it. To regenerate the references,
pass the original notebook and the Horns script (both are kept outside the repository):
```bash
.venv/bin/python data_acq_and_processing/tests/generate_backprojection_reference.py \
--notebook /path/to/Multi_antenna_demonstrate_depthnorm.ipynb \
--notebook /path/to/Multi_depthnorm_clean.ipynb \
--gpr-script /path/to/Horns_motion_4x4_ground.py
```
@@ -60,12 +60,14 @@ struct ProcessingLiveConfig {
float gpr_visible_z_min_m = 0.0F;
float gpr_visible_z_max_m = 14.0F;
// 3D SAR (sar3d processor) parameters, named after the reference notebook
// Multi_antenna_demonstrate_depthnorm.ipynb (SAR_FRAME_RADIUS, BP_GROUND_DEPTH_M, ...).
// Multi_depthnorm_clean.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;
// Edge of the cubic voxel: the notebook's SAR_VOXEL_M with one value for X, Y, Z.
float sar3d_voxel_m = 0.02F;
// SAR_VOXEL_M: the voxel steps along X, Y and Z.
float sar3d_voxel_x_m = 0.025F;
float sar3d_voxel_y_m = 0.02F;
float sar3d_voxel_z_m = 0.02F;
float sar3d_bp_ground_depth_m = 1.25F;
float sar3d_comp_range_power = 0.3F;
bool sar3d_use_compensation = true;
@@ -74,6 +76,8 @@ struct ProcessingLiveConfig {
float sar3d_y_min_m = -0.5F;
float sar3d_y_max_m = 2.0F;
float sar3d_z_size_m = 0.0F;
bool sar3d_bg_gate_enabled = true; // BG_GATE_ENABLE: peak-to-background ratio for the display
bool sar3d_clean_enabled = true; // SAR_CLEAN_ENABLE: CLEAN of a one-frame aperture into objects
// 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.
@@ -231,7 +231,9 @@ auto parse_geometry(const Json& value, const char* position_field, std::uint32_t
config.sar3d_frame_radius = parse_u32_number(*found, "processing.sar3d_frame_radius");
}
for (const auto& [key, target] : {
std::pair{"sar3d_voxel_m", &config.sar3d_voxel_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_bp_ground_depth_m", &config.sar3d_bp_ground_depth_m},
std::pair{"sar3d_comp_range_power", &config.sar3d_comp_range_power},
std::pair{"sar3d_x_size_m", &config.sar3d_x_size_m},
@@ -244,6 +246,8 @@ auto parse_geometry(const Json& value, const char* position_field, std::uint32_t
for (const auto& [key, target] : {
std::pair{"sar3d_use_compensation", &config.sar3d_use_compensation},
std::pair{"sar3d_normalize_each_frame", &config.sar3d_normalize_each_frame},
std::pair{"sar3d_bg_gate_enabled", &config.sar3d_bg_gate_enabled},
std::pair{"sar3d_clean_enabled", &config.sar3d_clean_enabled},
}) {
assign_optional(root, key, *target);
}
@@ -8,11 +8,13 @@
namespace radar::processing {
// 3D SAR of the reference notebook Multi_antenna_demonstrate_depthnorm.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.
// 3D SAR of the reference notebook Multi_depthnorm_clean.ipynb on an NVIDIA GPU.
// Publishes max-projections of the normalized volume, or of the CLEAN display, 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); sar3d_peak = [level, X, Y, Z] of the shown peak; the frame's
// sar3d_background ratio and the CLEAN sar3d_objects and sar3d_marks tables (see
// python_app/gui/controllers/renderers/sar3d.py).
class Sar3dProcessor final : public ProcessorInterface {
public:
Sar3dProcessor();
@@ -3,8 +3,10 @@
#include "shared_types.hpp"
#include <algorithm>
#include <array>
#include <cmath>
#include <complex>
#include <cstddef>
#include <cstdint>
#include <span>
#include <map>
@@ -12,6 +14,7 @@
#include <numbers>
#include <stdexcept>
#include <unordered_map>
#include <utility>
#include <vector>
namespace radar::processing::bp {
@@ -19,6 +22,16 @@ namespace radar::processing::bp {
constexpr double kWaveSpeed = 3e8; // v in both reference algorithms
using Background = std::unordered_map<ipc::ComboKey, std::vector<std::complex<double>>, ipc::ComboKeyHash>;
// numpy.median: the middle value, or the mean of the middle two for an even count.
template <class Value>
[[nodiscard]] auto median(std::vector<Value> values) -> double {
if (values.empty()) { throw std::logic_error("median of no values"); }
const auto middle = values.begin() + static_cast<std::ptrdiff_t>(values.size() / 2U);
std::nth_element(values.begin(), middle, values.end());
if (values.size() % 2U == 1U) { return static_cast<double>(*middle); }
return 0.5 * (static_cast<double>(*middle) + static_cast<double>(*std::max_element(values.begin(), middle)));
}
// Mean of up to count previous collections, separately for each physical pair.
inline auto background_mean(std::span<const ipc::PreprocessedCollection> previous, bool enabled,
std::size_t count) -> Background {
@@ -159,13 +172,20 @@ inline auto equalize_depth_limit(double max_depth, double x_min, double x_max, d
return std::sqrt(max_depth * max_depth + x_far * x_far + 2.5 * 2.5) + 1.5 + 0.75;
}
// Radar axes (Y, Z) of a scene point at height y above the ground and z along it, for a radar
// pitched by `pitch` with its origin `height` above the ground (sar_scene_to_radar_yz).
inline auto radar_yz(double y, double z, double height, double pitch) -> std::array<double, 2> {
const double y0 = y - height;
return {y0 * std::cos(pitch) + z * std::sin(pitch), -y0 * std::sin(pitch) + z * std::cos(pitch)};
}
// bp_plane_y, Horns_motion_4x4_ground.py: the ground plane in radar axes.
inline auto ground_y(double z, double height, double pitch) -> double {
if (std::abs(std::cos(pitch)) < 1e-6) { throw std::runtime_error("Ground BP is undefined at a look angle of 90 degrees"); }
return (-height + z * std::sin(pitch)) / std::cos(pitch);
}
// equalize_pairs_by_depth, Multi_antenna_demonstrate_depthnorm.ipynb, cell 1.
// equalize_pairs_by_depth, Multi_depthnorm_clean.ipynb, cell 1.
// All time axes start at zero; nearest median filtering uses the upper median
// for even windows, as scipy.ndimage.median_filter does.
template <class Real>
@@ -220,10 +240,7 @@ void normalize_pair(std::span<std::complex<Real>> samples, double dz, double min
for (const auto sample : samples) { magnitudes.push_back(std::abs(std::complex<double>(sample))); }
}
if (magnitudes.empty()) { return; }
const auto middle = magnitudes.begin() + magnitudes.size() / 2;
std::nth_element(magnitudes.begin(), middle, magnitudes.end());
double scale = *middle;
if (magnitudes.size() % 2 == 0) { scale = 0.5 * (scale + *std::max_element(magnitudes.begin(), middle)); }
double scale = median(std::move(magnitudes));
if (!std::isfinite(scale) || scale <= 1e-15) { scale = 1.0; }
for (auto& sample : samples) { sample /= static_cast<Real>(scale + 1e-15); }
}
@@ -0,0 +1,558 @@
#include "sar3d_clean.hpp"
#include "backprojection.hpp"
#include <algorithm>
#include <cmath>
#include <functional>
#include <iterator>
#include <limits>
#include <numeric>
#include <stdexcept>
#include <utility>
// Port of the CLEAN and frame parts of notebook cell 5. Comments name the notebook's
// functions and constants; the constants are values the notebook checked on scenes with
// several objects, not settings.
namespace radar::processing::sar3d {
namespace {
// CLEAN (sar_clean_components, sar_clean_frame).
constexpr double kCleanStop = 5.5; // SAR_CLEAN_STOP_BG, backgrounds of the whole volume
constexpr double kCleanRangeStop = 4.5; // SAR_CLEAN_STOP_RANGE_BG, backgrounds at the voxel range
constexpr std::size_t kCleanMaxSteps = 15U; // SAR_CLEAN_MAX_STEPS
constexpr std::array<double, 3> kCleanMerge{0.35, 0.50, 0.40}; // _SAR_CLEAN_MERGE_M (X, Y, Z)
constexpr std::array<double, 3> kCleanBox{0.40, 0.50, 0.45}; // _SAR_CLEAN_BOX_M (X, Y, Z), half-size
constexpr double kCleanPick = 0.05; // _SAR_CLEAN_PICK_M, pair peak window around the delay
constexpr double kCleanSearchSigma = 1.0; // _SAR_CLEAN_SEARCH_SIGMA, voxels
constexpr double kCleanRing = 0.25; // _SAR_CLEAN_RING_M, range ring width
constexpr std::size_t kCleanRingSmooth = 5U; // _SAR_CLEAN_RING_SMOOTH, rings
constexpr std::size_t kCleanRingMinVoxels = 300U; // _SAR_CLEAN_RING_MIN_VOXELS
// Boxes (sar_mark_groups).
constexpr double kMarkLevel = 0.6; // _SAR_MARK_LEVEL, response height level of the peak
constexpr double kMarkMemberRel = 0.4; // _SAR_MARK_MEMBER_REL
constexpr double kMarkPad = 0.25; // _SAR_MARK_PAD_M, along X and Z
constexpr double kMarkEdgeFar = 0.25; // _SAR_MARK_EDGE_FAR_M
constexpr double kMarkEdgeNear = 0.10; // _SAR_MARK_EDGE_NEAR_M
constexpr double kMarkEdgeX = 0.20; // _SAR_MARK_EDGE_X_M
constexpr double kMarkEdgeTop = 0.40; // _SAR_MARK_EDGE_TOP_M
constexpr std::array<double, 2> kMarkGroup{0.8, 0.5}; // SAR_MARK_GROUP_M (X, Z)
constexpr double kMarkGround = 0.35; // SAR_MARK_GROUND_M
constexpr double kMarkLow = 0.15; // SAR_MARK_LOW_M
constexpr double kMarkLowTop = 0.45; // SAR_MARK_LOW_TOP_M
using Scans = std::vector<std::complex<float>>;
// numpy.interp: linear between the points of increasing `xp`, constant beyond them.
[[nodiscard]] auto interp(double x, std::span<const double> xp, std::span<const double> fp) -> double {
if (x <= xp.front()) {
return fp.front();
}
if (x >= xp.back()) {
return fp.back();
}
const auto j = static_cast<std::size_t>(std::ranges::upper_bound(xp, x) - xp.begin()) - 1U;
if (xp[j] == x) {
return fp[j];
}
return (fp[j + 1U] - fp[j]) / (xp[j + 1U] - xp[j]) * (x - xp[j]) + fp[j];
}
// Radar axes (Y, Z) of a scene point of the frame.
[[nodiscard]] auto radar_yz(const VolumeRequest& frame, double y, double z) -> std::array<double, 2> {
return bp::radar_yz(y, z, frame.ground_depth_m, frame.pitch_rad);
}
// Axis indices [first, first + count).
struct Range {
std::size_t first = 0U;
std::size_t count = 0U;
};
// The axis points within `half_width` of `center`; they are contiguous on a monotonic axis.
[[nodiscard]] auto within(std::span<const double> axis, double center, double half_width) -> Range {
Range range;
for (std::size_t index = 0U; index < axis.size(); ++index) {
if (std::abs(axis[index] - center) <= half_width) {
if (range.count == 0U) {
range.first = index;
}
range.count = index - range.first + 1U;
}
}
return range;
}
// scan += amplitude * numpy.roll(pulse, shift)
void add_pulse(std::span<std::complex<float>> scan, std::span<const std::complex<float>> pulse, std::size_t shift,
std::complex<double> amplitude) {
const std::size_t length = scan.size();
for (std::size_t sample = 0U; sample < length; ++sample) {
const std::complex<double> response = pulse[(sample + length - shift) % length];
scan[sample] = std::complex<float>(std::complex<double>(scan[sample]) + amplitude * response);
}
}
// Range rings of the search grid (sar_clean_range_rings): every voxel's range from the
// radar, the ring centres and the working voxels (depth gate, reference planes) of each ring.
struct Rings {
std::vector<double> range;
std::vector<double> centers;
std::vector<std::vector<std::size_t>> members;
};
[[nodiscard]] auto range_rings(const VolumeRequest& frame, const Axes& grid) -> Rings {
Rings rings;
std::vector<std::size_t> work;
for (std::size_t iy = 0U; iy < grid.y.size(); ++iy) {
const bool reference = reference_plane(grid.y, frame.norm_edge_margin_m, iy);
for (const double z : grid.z) {
const auto [y_radar, z_radar] = radar_yz(frame, grid.y[iy], z);
const bool working = reference && z_radar >= frame.min_depth_m && z_radar <= frame.max_depth_m;
for (const double x : grid.x) {
if (working) {
work.push_back(rings.range.size());
}
rings.range.push_back(std::sqrt(x * x + y_radar * y_radar + z_radar * z_radar));
}
}
}
// Ring edges numpy.arange(0, R.max() + 2 ring, ring); the voxel ring is numpy.digitize - 1, clipped.
const auto edges = static_cast<std::size_t>(
std::ceil((*std::ranges::max_element(rings.range) + 2.0 * kCleanRing) / kCleanRing));
rings.members.resize(edges - 1U);
for (std::size_t ring = 0U; ring + 1U < edges; ++ring) {
rings.centers.push_back((static_cast<double>(ring) + 0.5) * kCleanRing);
}
for (const auto voxel : work) {
const auto ring = std::min(static_cast<std::size_t>(rings.range[voxel] / kCleanRing), edges - 2U);
rings.members[ring].push_back(voxel);
}
return rings;
}
// Background at the centre of every ring (sar_clean_range_background): the median of the
// ring, filled in over sparse rings and averaged over neighbouring ones; empty when no ring
// has enough voxels.
[[nodiscard]] auto range_background(std::span<const float> volume, const Rings& rings) -> std::vector<double> {
std::vector<double> known_rings;
std::vector<double> known_medians;
for (std::size_t ring = 0U; ring < rings.members.size(); ++ring) {
const auto& members = rings.members[ring];
if (members.size() >= kCleanRingMinVoxels) {
std::vector<float> values;
values.reserve(members.size());
for (const auto voxel : members) {
values.push_back(volume[voxel]);
}
known_rings.push_back(static_cast<double>(ring));
known_medians.push_back(bp::median(std::move(values)));
}
}
if (known_rings.empty()) {
return {};
}
const std::size_t count = rings.members.size();
std::vector<double> medians(count);
for (std::size_t ring = 0U; ring < count; ++ring) {
medians[ring] = interp(static_cast<double>(ring), known_rings, known_medians);
}
// Moving average over kCleanRingSmooth rings with edge padding (numpy.convolve 'valid'
// with numpy.ones(k) / k).
const auto half = static_cast<std::ptrdiff_t>(kCleanRingSmooth / 2U);
const auto last = static_cast<std::ptrdiff_t>(count) - 1;
const double weight = 1.0 / static_cast<double>(kCleanRingSmooth);
std::vector<double> smoothed(count);
for (std::size_t ring = 0U; ring < count; ++ring) {
for (std::ptrdiff_t tap = -half; tap <= half; ++tap) {
const auto index = std::clamp(static_cast<std::ptrdiff_t>(ring) + tap, std::ptrdiff_t{0}, last);
smoothed[ring] += medians[static_cast<std::size_t>(index)] * weight;
}
}
return smoothed;
}
// The search voxel highest above its stop threshold, the lower of kCleanStop backgrounds
// of the whole volume and kCleanRangeStop backgrounds at its range; its value in units of
// that threshold. Empty voxels score 0.
[[nodiscard]] auto strongest(std::span<const float> volume, const Rings& rings) -> std::pair<std::size_t, double> {
std::vector<float> positive;
std::ranges::copy_if(volume, std::back_inserter(positive), [](float value) { return value > 0.0F; });
const double overall = positive.empty() ? 0.0 : kCleanStop * bp::median(std::move(positive));
const auto local = range_background(volume, rings);
std::pair<std::size_t, double> best{0U, 0.0};
for (std::size_t voxel = 0U; voxel < volume.size(); ++voxel) {
if (!(volume[voxel] > 0.0F)) {
continue;
}
double threshold = overall;
if (!local.empty()) {
threshold = std::min(overall, kCleanRangeStop * interp(rings.range[voxel], rings.centers, local));
}
const double score = volume[voxel] / (threshold + 1e-30);
if (score > best.second) {
best = {voxel, score};
}
}
return best;
}
// One subtracted point response: the pair, its peak sample and the pulse amplitude there.
struct Part {
std::size_t pair = 0U;
std::size_t sample = 0U;
std::complex<double> amplitude{};
};
struct Component {
std::array<double, 3> point{}; // search voxel, scene X, Y, Z
std::vector<Part> parts;
};
// CLEAN steps on the search grid (sar_clean_components). Each step takes the search voxel
// furthest above its stop threshold and subtracts a point response from the A-scan of every
// pair at the pair's own peak near the voxel delay; `residual` keeps what remains.
template <class Image>
[[nodiscard]] auto subtract_components(const Image& image, const CleanRequest& request, Scans& residual)
-> std::vector<Component> {
const VolumeRequest& frame = request.frame;
const Axes& search = request.search;
const std::size_t length = request.pulse.size();
const std::size_t plane = search.x.size() * search.z.size();
const auto rings = range_rings(frame, search);
// Half-width of the pair peak window in samples: _SAR_CLEAN_PICK_M of range, which is
// twice that of Tx+Rx path.
const auto pick = std::max<std::ptrdiff_t>(
1, static_cast<std::ptrdiff_t>(std::nearbyint(2.0 * kCleanPick * frame.samples_per_metre)));
std::vector<Component> components;
while (true) {
auto volume = image(residual, search, kCleanSearchSigma);
for (std::size_t iy = 0U; iy < search.y.size(); ++iy) {
if (!reference_plane(search.y, frame.norm_edge_margin_m, iy)) {
std::fill_n(volume.begin() + static_cast<std::ptrdiff_t>(iy * plane), plane, 0.0F);
}
}
const auto [voxel, level] = strongest(volume, rings);
if (level < 1.0 || components.size() >= kCleanMaxSteps) {
return components;
}
const std::size_t ix = voxel % search.x.size();
const std::size_t iz = voxel / search.x.size() % search.z.size();
const std::size_t iy = voxel / plane;
Component component{.point = {search.x[ix], search.y[iy], search.z[iz]}, .parts = {}};
const auto [y_radar, z_radar] = radar_yz(frame, search.y[iy], search.z[iz]);
const std::array<double, 3> target{search.x[ix], y_radar, z_radar}; // in radar axes
const auto range = [&target](const std::array<float, 3>& antenna) {
const double dx = target[0] - antenna[0];
const double dy = target[1] - antenna[1];
const double dz = target[2] - antenna[2];
return std::sqrt(dx * dx + dy * dy + dz * dz);
};
for (std::size_t pair = 0U; pair < frame.contributions.size(); ++pair) {
const auto& term = frame.contributions[pair];
// Sample of the voxel's Tx+Rx path (sar_clean_pair_delays).
const auto delay = static_cast<std::ptrdiff_t>(
std::nearbyint((range(term.tx) + range(term.rx)) * frame.samples_per_metre));
const auto low = std::max<std::ptrdiff_t>(delay - pick, 0);
const auto high = std::min(delay + pick + 1, static_cast<std::ptrdiff_t>(length));
if (high <= low) {
continue;
}
const auto scan = std::span(residual).subspan(pair * length, length);
auto peak = static_cast<std::size_t>(low);
for (auto sample = peak + 1U; sample < static_cast<std::size_t>(high); ++sample) {
if (std::norm(std::complex<double>(scan[sample])) > std::norm(std::complex<double>(scan[peak]))) {
peak = sample;
}
}
const auto amplitude = std::complex<double>(scan[peak]) / std::complex<double>(request.pulse[0]);
add_pulse(scan, request.pulse, peak, -amplitude);
component.parts.push_back({.pair = pair, .sample = peak, .amplitude = amplitude});
}
components.push_back(std::move(component));
}
}
// Subtractions within _SAR_CLEAN_MERGE_M of an object's first subtraction belong to that
// object (sar_clean_group).
struct Group {
std::array<double, 3> seed{};
std::vector<const Component*> members;
};
[[nodiscard]] auto group(const std::vector<Component>& components) -> std::vector<Group> {
std::vector<Group> groups;
for (const auto& component : components) {
const auto near_seed = [&component](const Group& candidate) {
for (std::size_t axis = 0U; axis < 3U; ++axis) {
if (std::abs(component.point[axis] - candidate.seed[axis]) > kCleanMerge[axis]) {
return false;
}
}
return true;
};
if (const auto found = std::ranges::find_if(groups, near_seed); found != groups.end()) {
found->members.push_back(&component);
} else {
groups.push_back({.seed = component.point, .members = {&component}});
}
}
return groups;
}
} // namespace
auto frame_background(std::span<const float> values, std::span<const float> volume, const VolumeRequest& frame)
-> FrameBackground {
const Axes& grid = frame.grid;
const std::size_t plane = grid.x.size() * grid.z.size();
if (values.size() != volume.size() || volume.size() != plane * grid.y.size()) {
throw std::runtime_error("sar3d background volumes do not match the frame grid");
}
std::vector<float> work;
work.reserve(volume.size());
FrameBackground result;
for (std::size_t iy = 0U; iy < grid.y.size(); ++iy) {
if (!reference_plane(grid.y, frame.norm_edge_margin_m, iy)) {
continue;
}
for (std::size_t voxel = iy * plane; voxel < (iy + 1U) * plane; ++voxel) {
if (volume[voxel] > 0.0F) {
work.push_back(values[voxel]);
result.peak = std::max(result.peak, static_cast<double>(values[voxel]));
}
}
}
if (!work.empty()) {
result.median = bp::median(std::move(work));
}
return result;
}
auto clean(Gpu& gpu, const CleanRequest& request) -> Clean {
const VolumeRequest& frame = request.frame;
const Axes& grid = frame.grid;
const std::size_t pairs = frame.contributions.size();
const std::size_t length = request.pulse.size();
if (pairs == 0U || request.ascans.size() != pairs * length ||
request.volume.size() != grid.x.size() * grid.y.size() * grid.z.size() || !(request.level > 0.0)) {
throw std::runtime_error("sar3d CLEAN input does not match its frame");
}
// Smoothed |BP| of the frame pairs with other A-scans on another grid (sar_abs_volume).
// The A-scans are full-length: the search grid may reach past the frame grid that the
// frame's shortened A-scans cover.
const auto image = [&gpu, &frame, length](const Scans& scans, const Axes& axes, double sigma) {
VolumeRequest imaged = frame;
imaged.grid = axes;
imaged.ascans = scans;
imaged.samples_per_ascan = length;
imaged.smooth_sigma_voxels = sigma;
gpu.image(imaged);
return gpu.download();
};
Scans residual(request.ascans.begin(), request.ascans.end());
const auto components = subtract_components(image, request, residual);
Clean result;
result.steps = components.size();
if (components.empty()) {
result.residual_ratio = request.frame_background.ratio();
return result;
}
// The residual in units of the frame maximum, hidden when it is background.
const auto residual_image = image(residual, grid, frame.smooth_sigma_voxels);
result.residual_ratio = frame_background(residual_image, request.volume, frame).ratio();
result.display.assign(residual_image.size(), 0.0F);
if (!request.bg_gate || result.residual_ratio >= kBgGateShow) {
std::ranges::transform(residual_image, result.display.begin(),
[&request](float value) { return static_cast<float>(value / request.level); });
}
const std::size_t grid_nx = grid.x.size();
const std::size_t grid_nz = grid.z.size();
for (const auto& object : group(components)) {
const auto& seed = object.seed;
// The object apart from the others: the residual plus its own subtractions, imaged on
// the frame grid around it only (sar_clean_subgrid).
const Range window_x = within(grid.x, seed[0], 2.0 * kCleanBox[0]);
const Range window_y = within(grid.y, seed[1], 2.0 * kCleanBox[1]);
const Range window_z = within(grid.z, seed[2], 2.0 * kCleanBox[2]);
if (window_x.count < 2U || window_y.count < 2U || window_z.count < 2U) {
continue;
}
const Axes local{
.x = grid.x.subspan(window_x.first, window_x.count),
.y = grid.y.subspan(window_y.first, window_y.count),
.z = grid.z.subspan(window_z.first, window_z.count),
};
Scans scans = residual;
for (const Component* member : object.members) {
for (const Part& part : member->parts) {
add_pulse(std::span(scans).subspan(part.pair * length, length), request.pulse, part.sample,
part.amplitude);
}
}
const auto values = image(scans, local, frame.smooth_sigma_voxels);
const std::size_t nx = local.x.size();
const std::size_t nz = local.z.size();
const auto at = [&values, nx, nz](std::size_t iy, std::size_t iz, std::size_t ix) {
return values[(iy * nz + iz) * nx + ix];
};
// Peak within _SAR_CLEAN_BOX_M of the first subtraction.
const Range near_x = within(local.x, seed[0], kCleanBox[0]);
const Range near_y = within(local.y, seed[1], kCleanBox[1]);
const Range near_z = within(local.z, seed[2], kCleanBox[2]);
if (near_x.count == 0U || near_y.count == 0U || near_z.count == 0U) {
continue;
}
std::size_t py = near_y.first;
std::size_t pz = near_z.first;
std::size_t px = near_x.first;
for (std::size_t iy = near_y.first; iy < near_y.first + near_y.count; ++iy) {
for (std::size_t iz = near_z.first; iz < near_z.first + near_z.count; ++iz) {
for (std::size_t ix = near_x.first; ix < near_x.first + near_x.count; ++ix) {
if (at(iy, iz, ix) > at(py, pz, px)) {
py = iy;
pz = iz;
px = ix;
}
}
}
}
const double peak = at(py, pz, px);
if (!(peak > 0.0)) {
continue;
}
// Height of the response: where the maximum over 5 x 5 voxels in Z and X around the
// peak stays at _SAR_MARK_LEVEL of the peak.
const auto column = [&](std::size_t iy) {
float maximum = 0.0F;
for (std::size_t iz = pz < 2U ? 0U : pz - 2U; iz < std::min(pz + 3U, nz); ++iz) {
for (std::size_t ix = px < 2U ? 0U : px - 2U; ix < std::min(px + 3U, nx); ++ix) {
maximum = std::max(maximum, at(iy, iz, ix));
}
}
return maximum / peak;
};
std::size_t y_low = py;
std::size_t y_high = py;
while (y_low > 0U && column(y_low - 1U) >= kMarkLevel) {
--y_low;
}
while (y_high + 1U < local.y.size() && column(y_high + 1U) >= kMarkLevel) {
++y_high;
}
// The object in its own 0-1 scale over the box around its peak.
const Range box_x = within(local.x, local.x[px], kCleanBox[0]);
const Range box_y = within(local.y, local.y[py], kCleanBox[1]);
const Range box_z = within(local.z, local.z[pz], kCleanBox[2]);
for (std::size_t iy = box_y.first; iy < box_y.first + box_y.count; ++iy) {
for (std::size_t iz = box_z.first; iz < box_z.first + box_z.count; ++iz) {
for (std::size_t ix = box_x.first; ix < box_x.first + box_x.count; ++ix) {
float& shown = result.display[((window_y.first + iy) * grid_nz + window_z.first + iz) * grid_nx +
window_x.first + ix];
shown = std::max(shown, static_cast<float>(at(iy, iz, ix) / peak));
}
}
}
result.objects.push_back({
.index = {window_y.first + py, window_z.first + pz, window_x.first + px},
.position = {local.x[px], local.y[py], local.z[pz]},
.y_low = local.y[y_low],
.y_high = local.y[y_high],
.level = peak / (request.frame_background.median + 1e-30),
});
}
std::ranges::stable_sort(result.objects, std::ranges::greater{}, &CleanObject::level);
if (result.objects.empty()) {
result.display.clear();
}
return result;
}
auto marks(std::span<const CleanObject> objects, const VolumeRequest& frame) -> std::vector<Mark> {
// Objects within SAR_MARK_GROUP_M of each other along X and Z, at any height and through
// chains of them, are one item.
std::vector<std::size_t> parent(objects.size());
std::iota(parent.begin(), parent.end(), 0U);
const auto root = [&parent](std::size_t index) {
while (parent[index] != index) {
index = parent[index];
}
return index;
};
for (std::size_t i = 0U; i < objects.size(); ++i) {
for (std::size_t j = i + 1U; j < objects.size(); ++j) {
if (std::abs(objects[i].position[0] - objects[j].position[0]) <= kMarkGroup[0] &&
std::abs(objects[i].position[2] - objects[j].position[2]) <= kMarkGroup[1]) {
parent[root(i)] = root(j);
}
}
}
// Members in object order, so the strongest leads each group and the groups come in the
// order of their strongest members.
std::vector<std::size_t> roots;
std::vector<std::vector<std::size_t>> groups;
for (std::size_t index = 0U; index < objects.size(); ++index) {
const auto found = std::ranges::find(roots, root(index));
if (found == roots.end()) {
roots.push_back(root(index));
groups.push_back({index});
} else {
groups[static_cast<std::size_t>(found - roots.begin())].push_back(index);
}
}
const auto& x = frame.grid.x;
const auto& y = frame.grid.y;
std::vector<Mark> result;
for (const auto& members : groups) {
const CleanObject& top = objects[members.front()];
// Height of the centre: the mean height of the strong members weighted by level.
double weight = 0.0;
double weighted_height = 0.0;
double top_y = -std::numeric_limits<double>::infinity();
Mark mark;
mark.box = {std::numeric_limits<double>::infinity(), -std::numeric_limits<double>::infinity(),
std::numeric_limits<double>::infinity(), -std::numeric_limits<double>::infinity(),
std::numeric_limits<double>::infinity(), -std::numeric_limits<double>::infinity()};
for (const auto index : members) {
const CleanObject& member = objects[index];
if (member.level >= kMarkMemberRel * top.level) {
weight += member.level;
weighted_height += member.level * member.position[1];
top_y = std::max(top_y, member.y_high);
}
auto& box = mark.box;
box[0] = std::min(box[0], member.position[0] - kMarkPad);
box[1] = std::max(box[1], member.position[0] + kMarkPad);
box[2] = std::min(box[2], member.y_low);
box[3] = std::max(box[3], member.y_high);
box[4] = std::min(box[4], member.position[2] - kMarkPad);
box[5] = std::max(box[5], member.position[2] + kMarkPad);
}
const double centre_y = weighted_height / weight;
// The strongest point near the scene boundary may belong to a reflector partly
// outside it, so its position and height are not yet reliable.
const double depth = radar_yz(frame, top.position[1], top.position[2])[1];
const bool at_edge = depth >= frame.max_depth_m - kMarkEdgeFar || depth <= frame.min_depth_m + kMarkEdgeNear ||
top.position[0] <= x.front() + kMarkEdgeX || top.position[0] >= x.back() - kMarkEdgeX ||
top.position[1] >= y.back() - kMarkEdgeTop;
if (at_edge) {
mark.kind = MarkKind::edge;
} else if (centre_y <= kMarkLow && top_y <= kMarkLowTop) {
mark.kind = MarkKind::low;
} else if (centre_y <= kMarkGround) {
mark.kind = MarkKind::ground;
} else {
mark.kind = MarkKind::up;
}
result.push_back(mark);
}
return result;
}
} // namespace radar::processing::sar3d
@@ -0,0 +1,91 @@
#pragma once
#include <array>
#include <complex>
#include <cstddef>
#include <cstdint>
#include <span>
#include <vector>
#include "sar3d_gpu.hpp"
// Display stage of one 3D SAR frame (notebook Multi_depthnorm_clean.ipynb, cell 5): the
// frame's peak over its background, CLEAN of a one-frame image into separate objects, and
// the boxes drawn around them.
namespace radar::processing::sar3d {
// BG_GATE_3D_SHOW and BG_GATE_3D_FULL: peak-to-background ratios at which a frame appears
// and from which it is shown at full scale.
inline constexpr double kBgGateShow = 6.0;
inline constexpr double kBgGateFull = 8.5;
// SAR_CLEAN_GRID_M along X and Z, SAR_CLEAN_GRID_Y_M along Y: steps of the CLEAN search grid [m].
inline constexpr std::array<double, 3> kCleanSearchStep{0.04, 0.10, 0.04};
// Background of a frame: the median over its working voxels and the maximum there
// (frame_peak_over_background).
struct FrameBackground {
double median = 0.0;
double peak = 0.0;
[[nodiscard]] auto ratio() const -> double { return median > 0.0 ? peak / median : 0.0; }
};
// Background of `values` over the working voxels of `volume`, an image of `frame`: those it
// reaches from inside the depth gate (non-zero) on the reference planes (sar_work_mask).
[[nodiscard]] auto frame_background(std::span<const float> values, std::span<const float> volume,
const VolumeRequest& frame) -> FrameBackground;
struct CleanRequest {
// Request of the frame image, a one-frame aperture; CLEAN images other A-scans with it.
VolumeRequest frame;
std::span<const float> volume; // the frame image, unscaled
double level = 0.0; // its normalization (sar_finish_volume)
FrameBackground frame_background; // of that image
// Search grid over the full scene region around the frame grid (sar_clean_search_grid).
Axes search;
std::span<const std::complex<float>> ascans; // full A-scan of each frame pair, back to back
std::span<const std::complex<float>> pulse; // zero-delay point response, one A-scan long
bool bg_gate = true; // BG_GATE_ENABLE
};
struct CleanObject {
std::array<std::size_t, 3> index{}; // peak voxel on the frame grid, [y, z, x]
std::array<double, 3> position{}; // peak, scene X, Y, Z [m]
double y_low = 0.0; // heights where the response stays at
double y_high = 0.0; // _SAR_MARK_LEVEL of the peak [m]
double level = 0.0; // peak over the frame background (level_bg)
};
struct Clean {
std::vector<CleanObject> objects; // strongest first
// The residual in units of the frame maximum with every object over it in its own
// 0-1 scale, on the frame grid; empty without objects.
std::vector<float> display;
std::size_t steps = 0U; // subtractions
double residual_ratio = 0.0; // residual peak over its background
};
// CLEAN of a one-frame image (sar_clean_frame): subtracts the strongest point responses
// from the A-scans until the residual is background, then images every object apart.
// Imaging leaves one of these images, not the frame, as the current volume of `gpu`.
[[nodiscard]] auto clean(Gpu& gpu, const CleanRequest& request) -> Clean;
// Classes of the boxes; the values are their codes in the results.
enum class MarkKind : std::uint8_t {
low = 0U, // centre and top of the response near the ground (like a mine)
ground = 1U, // on the ground
up = 2U, // above the ground
edge = 3U, // strongest point near the scene boundary: not yet decidable
};
struct Mark {
MarkKind kind = MarkKind::edge;
std::array<double, 6> box{}; // X0, X1, Y0, Y1, Z0, Z1 [m]
};
// Boxes around groups of the CLEAN objects of an image of `frame` (sar_mark_groups);
// `objects` come strongest first, and so do the boxes.
[[nodiscard]] auto marks(std::span<const CleanObject> objects, const VolumeRequest& frame) -> std::vector<Mark>;
} // namespace radar::processing::sar3d
@@ -27,51 +27,31 @@ auto checked_product(std::size_t a, std::size_t b, std::size_t limit, const char
return a * b;
}
#ifdef SAR3D_WITH_CUDA
auto gaussian_taps(double sigma) -> std::vector<float> {
if (!(sigma > 0.0) || !std::isfinite(sigma) || sigma > 100.0) {
throw std::runtime_error("sar3d smoothing sigma must be between 0 and 100");
// Two points per axis, 32-bit voxel indices and coordinates representable in float.
void check_grid(const Axes& grid) {
const auto plane = checked_product(grid.x.size(), grid.z.size(), kMaxIndexedItems, "grid plane");
(void)checked_product(plane, grid.y.size(), kMaxIndexedItems, "grid (32-bit indexing; increase SAR_VOXEL_M)");
if (grid.x.size() < 2U || grid.y.size() < 2U || grid.z.size() < 2U) {
throw std::runtime_error("sar3d grid needs two points on every axis");
}
for (const auto axis : {grid.x, grid.y, grid.z}) {
for (const double coordinate : axis) {
if (!std::isfinite(coordinate) || std::abs(coordinate) > std::numeric_limits<float>::max()) {
throw std::runtime_error("sar3d grid contains an unsupported coordinate");
}
const int radius = static_cast<int>(4.0 * sigma + 0.5);
std::vector<double> weights;
double total = 0.0;
for (int k = -radius; k <= radius; ++k) {
weights.push_back(std::exp(-0.5 * k * k / (sigma * sigma)));
total += weights.back();
}
std::vector<float> taps;
for (const double weight : weights) {
taps.push_back(static_cast<float>(weight / total));
}
return taps;
}
auto reference_plane(const VolumeRequest& request, std::size_t iy) -> bool {
const double margin = request.norm_edge_margin_m;
if (request.y.size() == 2U || request.y.back() - request.y.front() <= 2.5 * margin) {
} // namespace
auto reference_plane(std::span<const double> y, double margin, std::size_t iy) -> bool {
if (y.size() == 2U || y.back() - y.front() <= 2.5 * margin) {
return true;
}
return request.y[iy] >= request.y.front() + margin && request.y[iy] <= request.y.back() - margin;
return y[iy] >= y.front() + margin && y[iy] <= y.back() - margin;
}
void choose_peak(Projections& result, const VolumeRequest& request,
std::span<const float> levels, std::span<const unsigned int> indices = {}) {
for (std::size_t iy = 0U; iy < request.y.size(); ++iy) {
if (!std::isfinite(levels[iy])) {
throw std::runtime_error("sar3d volume contains a non-finite value");
}
if (reference_plane(request, iy) && levels[iy] > result.level) {
result.level = levels[iy];
if (!indices.empty()) {
result.peak = {iy, indices[iy] / request.x.size(), indices[iy] % request.x.size()};
}
}
}
}
#endif
} // namespace
#ifdef SAR3D_WITH_CUDA
namespace {
@@ -134,11 +114,12 @@ extern "C" __global__ void backproject(
if (index >= (unsigned)voxels) return;
const int voxel = (int)index;
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.
// Scene -> radar axes (sar_scene_to_radar_yz). The depth gate is decided in double
// and rounded like NumPy, without a fused multiply-add: the grid corners lie on its
// edge by construction and must be 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;
const double depth = __dadd_rn(__dmul_rn(-y0, sin_p), __dmul_rn(zs, cos_p));
if (depth < min_depth || depth > max_depth) {
volume[voxel] = 0.0f;
return;
@@ -208,25 +189,48 @@ extern "C" __global__ void max_project(
dst[o] = maximum;
}
// Keep the first joint (y,z,x) maximum. Separate projections cannot locate it.
// Maximum of each Y plane and its first in-plane index, one block per plane, so the
// host finds the first joint (y,z,x) maximum; separate projections cannot locate it.
// A non-finite voxel makes its plane level NaN. Shared memory holds blockDim.x
// (a power of two) levels and as many indices.
extern "C" __global__ void peak_planes(
const float* volume, const int plane, const int ny, float* levels, unsigned int* indices)
const float* __restrict__ volume, const int plane, float* __restrict__ levels,
unsigned int* __restrict__ indices)
{
const unsigned thread = blockIdx.x * blockDim.x + threadIdx.x;
if (thread >= (unsigned)ny) return;
const int y = (int)thread;
const float* row = volume + y * plane;
extern __shared__ float best[];
unsigned int* where = (unsigned int*)(best + blockDim.x);
__shared__ int non_finite;
if (threadIdx.x == 0) non_finite = 0;
__syncthreads();
const float* row = volume + blockIdx.x * plane;
float level = 0.0f;
unsigned int index = 0u;
for (int i = 0; i < plane; ++i) {
if (!isfinite(row[i])) { levels[y] = row[i]; indices[y] = (unsigned int)i; return; }
if (row[i] > level) { level = row[i]; index = (unsigned int)i; }
for (int i = threadIdx.x; i < plane; i += blockDim.x) {
const float value = row[i];
if (!isfinite(value)) non_finite = 1;
if (value > level) { level = value; index = (unsigned int)i; }
}
best[threadIdx.x] = level;
where[threadIdx.x] = index;
__syncthreads();
for (unsigned int stride = blockDim.x / 2; stride > 0; stride /= 2) {
if (threadIdx.x < stride) {
const float other = best[threadIdx.x + stride];
const unsigned int other_index = where[threadIdx.x + stride];
if (other > best[threadIdx.x] || (other == best[threadIdx.x] && other_index < where[threadIdx.x])) {
best[threadIdx.x] = other;
where[threadIdx.x] = other_index;
}
}
__syncthreads();
}
if (threadIdx.x == 0) {
levels[blockIdx.x] = non_finite ? __int_as_float(0x7fc00000) : best[0];
indices[blockIdx.x] = where[0];
}
levels[y] = level;
indices[y] = index;
}
// Contiguous half-maximum interval through the joint peak on each scene axis.
// Contiguous half-maximum interval through the given peak voxel on each scene axis.
// Interpolate the two crossings; use one voxel as the resolution floor.
extern "C" __global__ void peak_widths(
const float* volume, const double* gx, const double* gy, const double* gz,
@@ -281,7 +285,26 @@ extern "C" __global__ void project_oblique(
}
)CUDA";
constexpr int kBlockSize = 256;
constexpr unsigned kBlockSize = 256U;
// scipy.ndimage.gaussian_filter1d weights (truncate = 4).
auto gaussian_taps(double sigma) -> std::vector<float> {
if (!(sigma > 0.0) || !std::isfinite(sigma) || sigma > 100.0) {
throw std::runtime_error("sar3d smoothing sigma must be between 0 and 100");
}
const int radius = static_cast<int>(4.0 * sigma + 0.5);
std::vector<double> weights;
double total = 0.0;
for (int k = -radius; k <= radius; ++k) {
weights.push_back(std::exp(-0.5 * k * k / (sigma * sigma)));
total += weights.back();
}
std::vector<float> taps;
for (const double weight : weights) {
taps.push_back(static_cast<float>(weight / total));
}
return taps;
}
void check(CUresult result, const char* what, bool device_required = false) {
if (result != CUDA_SUCCESS) {
@@ -398,18 +421,24 @@ class DeviceBuffer {
};
template <class T>
[[nodiscard]] auto download(CUdeviceptr source, std::size_t count) -> std::vector<T> {
[[nodiscard]] auto copy_to_host(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.
// `blocks` blocks of kBlockSize threads; argument types must match the kernel signature exactly.
template <class... Args>
void launch_blocks(CUfunction kernel, unsigned blocks, unsigned shared_bytes, Args... args) {
void* parameters[] = {static_cast<void*>(&args)...};
check(cuLaunchKernel(kernel, blocks, 1U, 1U, kBlockSize, 1U, 1U, shared_bytes, nullptr, parameters, nullptr),
"launch");
}
// One thread per item.
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 - 1U) / kBlockSize;
check(cuLaunchKernel(kernel, blocks, 1U, 1U, kBlockSize, 1U, 1U, 0U, nullptr, parameters, nullptr), "launch");
launch_blocks(kernel, (static_cast<unsigned>(items) + kBlockSize - 1U) / kBlockSize, 0U, args...);
}
} // namespace
@@ -447,129 +476,193 @@ struct Gpu::Impl {
CUfunction peak_planes = module.function("peak_planes");
CUfunction peak_widths = module.function("peak_widths");
CUfunction project_oblique = module.function("project_oblique");
int smooth_radius = 0;
DeviceBuffer taps;
DeviceBuffer bands;
DeviceBuffer axes;
DeviceBuffer axes; // of the current volume: x, then y, then z
DeviceBuffer contributions;
DeviceBuffer ascans;
DeviceBuffer volume;
DeviceBuffer volume; // the current volume
DeviceBuffer scratch;
DeviceBuffer images;
DeviceBuffer peak_stats;
DeviceBuffer plane_stats;
int nx = 0; // current volume size; 0 before the first
int ny = 0;
int nz = 0;
~Impl() { cuCtxSetCurrent(context.handle); }
explicit Impl(std::span<const float> weights) {
smooth_radius = static_cast<int>(weights.size() / 2U);
taps.upload(weights);
}
auto inverse_dft(std::span<const std::complex<float>>, std::size_t, std::size_t, std::size_t) -> std::vector<std::complex<float>>;
auto project(const VolumeRequest&) -> Projections;
};
auto Gpu::Impl::inverse_dft(
std::span<const std::complex<float>> bands,
[[nodiscard]] auto gx() const -> CUdeviceptr { return axes.pointer(); }
[[nodiscard]] auto gy() const -> CUdeviceptr { return gx() + sizeof(double) * static_cast<std::size_t>(nx); }
[[nodiscard]] auto gz() const -> CUdeviceptr { return gy() + sizeof(double) * static_cast<std::size_t>(ny); }
[[nodiscard]] auto voxels() const -> int { return nx * ny * nz; }
void use_context() const { check(cuCtxSetCurrent(context.handle), "context"); }
void require_volume() const {
if (voxels() == 0) {
throw std::runtime_error("sar3d has no current volume");
}
}
// There is no current volume until an update of it has succeeded.
void forget_volume() { nx = ny = nz = 0; }
// Makes `grid` the grid of the current volume; returns the volume, sized for it.
auto use_grid(const Axes& grid) -> CUdeviceptr {
forget_volume();
std::vector<double> values(grid.x.begin(), grid.x.end());
values.insert(values.end(), grid.y.begin(), grid.y.end());
values.insert(values.end(), grid.z.begin(), grid.z.end());
axes.upload(std::span<const double>(values));
const CUdeviceptr output = volume.reserve(sizeof(float) * grid.x.size() * grid.y.size() * grid.z.size());
nx = static_cast<int>(grid.x.size());
ny = static_cast<int>(grid.y.size());
nz = static_cast<int>(grid.z.size());
return output;
}
// Maximum of the current volume over the reference planes of `y`, and its first voxel.
auto plane_peak(std::span<const double> y, double margin) -> Peak {
const auto planes = static_cast<std::size_t>(ny);
const CUdeviceptr levels = plane_stats.reserve(2U * sizeof(float) * planes);
const CUdeviceptr indices = levels + sizeof(float) * planes;
launch_blocks(peak_planes, static_cast<unsigned>(ny), static_cast<unsigned>(2U * kBlockSize * sizeof(float)),
volume.pointer(), nz * nx, levels, indices);
const auto plane_levels = copy_to_host<float>(levels, planes);
const auto plane_indices = copy_to_host<unsigned int>(indices, planes);
const auto row = static_cast<unsigned int>(nx);
Peak peak;
for (std::size_t iy = 0U; iy < planes; ++iy) {
if (!std::isfinite(plane_levels[iy])) {
throw std::runtime_error("sar3d volume contains a non-finite value");
}
if (reference_plane(y, margin, iy) && plane_levels[iy] > peak.level) {
peak.level = plane_levels[iy];
peak.index = {iy, plane_indices[iy] / row, plane_indices[iy] % row};
}
}
return peak;
}
auto inverse_dft(
std::span<const std::complex<float>> spectra,
std::size_t points,
std::size_t first_bin,
std::size_t size
) -> std::vector<std::complex<float>> {
Impl& gpu = *this;
check(cuCtxSetCurrent(gpu.context.handle), "context");
const std::size_t samples = bands.size() / points * size;
) -> std::vector<std::complex<float>> {
use_context();
const std::size_t samples = spectra.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_kernel, items, input, checked_int(points, "band"), checked_int(first_bin, "first bin"),
const CUdeviceptr input = bands.upload(spectra);
const CUdeviceptr output = ascans.reserve(sizeof(std::complex<float>) * samples);
launch(inverse_dft_kernel, 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);
}
return copy_to_host<std::complex<float>>(output, samples);
}
auto Gpu::Impl::project(const VolumeRequest& request) -> Projections {
Impl& gpu = *this;
check(cuCtxSetCurrent(gpu.context.handle), "context");
const int nx = checked_int(request.x.size(), "grid");
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,
auto image(const VolumeRequest& request) -> Peak {
const auto weights = gaussian_taps(request.smooth_sigma_voxels);
use_context();
try {
const CUdeviceptr output = use_grid(request.grid);
const CUdeviceptr work = scratch.reserve(sizeof(float) * static_cast<std::size_t>(voxels()));
const CUdeviceptr terms = contributions.upload(request.contributions);
const CUdeviceptr scans = ascans.upload(request.ascans);
launch(backproject, voxels(), scans, checked_int(request.samples_per_ascan, "A-scan"), terms,
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);
const int plane = nz * nx;
const auto stats = gpu.peak_stats.reserve(2U * sizeof(float) * static_cast<std::size_t>(ny));
launch(gpu.peak_planes, ny, volume, plane, ny, stats, stats + sizeof(float) * static_cast<std::size_t>(ny));
Projections raw_peak;
const auto raw_levels = download<float>(stats, static_cast<std::size_t>(ny));
choose_peak(raw_peak, request, raw_levels);
static_cast<int>(request.use_compensation), request.comp_range_power, request.comp_weight_max,
output);
const float raw_level = plane_peak(request.grid.y, request.norm_edge_margin_m).level;
// 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 CUdeviceptr kernel = taps.upload(std::span<const float>(weights));
const int radius = static_cast<int>(weights.size() / 2U);
launch(smooth, voxels(), output, work, kernel, radius, nx, nz, voxels(), 1);
launch(smooth, voxels(), work, output, kernel, radius, nx, nz, voxels(), 0);
Peak peak = plane_peak(request.grid.y, request.norm_edge_margin_m);
peak.raw_level = raw_level;
return peak;
} catch (...) {
forget_volume();
throw;
}
}
const ObliqueView& view = request.oblique;
auto download() -> std::vector<float> {
use_context();
require_volume();
return copy_to_host<float>(volume.pointer(), static_cast<std::size_t>(voxels()));
}
void load(const Axes& grid, std::span<const float> values) {
use_context();
try {
check(cuMemcpyHtoD(use_grid(grid), values.data(), values.size_bytes()), "upload");
} catch (...) {
forget_volume();
throw;
}
}
auto project(const std::array<std::size_t, 3>& peak, const ObliqueView& view) -> Projections {
use_context();
require_volume();
const auto [py, pz, px] = peak;
if (py >= static_cast<std::size_t>(ny) || pz >= static_cast<std::size_t>(nz) ||
px >= static_cast<std::size_t>(nx)) {
throw std::runtime_error("sar3d peak lies outside the volume");
}
const int plane = nz * nx;
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 + 2U * ny;
const CUdeviceptr front = gpu.images.reserve(sizeof(float) * image_floats);
const int top_size = plane;
const int oblique_size = view.nv * view.nu;
const auto floats = static_cast<std::size_t>(front_size) + side_size + top_size + oblique_size + 3U;
const CUdeviceptr front = images.reserve(sizeof(float) * 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 CUdeviceptr levels = oblique + sizeof(float) * static_cast<std::size_t>(oblique_size);
const CUdeviceptr indices = levels + sizeof(float) * static_cast<std::size_t>(ny);
launch(gpu.peak_planes, ny, volume, plane, ny, levels, indices);
Projections result;
result.raw_level = raw_peak.level;
const auto plane_levels = download<float>(levels, static_cast<std::size_t>(ny));
const auto plane_indices = download<unsigned int>(indices, static_cast<std::size_t>(ny));
choose_peak(result, request, plane_levels, plane_indices);
if (result.level > 0.0F) {
const auto widths = gpu.peak_stats.reserve(3U * sizeof(float));
launch(gpu.peak_widths, 3, volume, gx, gy, gz, nx, ny, nz,
static_cast<int>(result.peak[2]), static_cast<int>(result.peak[0]), static_cast<int>(result.peak[1]), widths);
const auto measured = download<float>(widths, 3U);
std::copy(measured.begin(), measured.end(), result.target_width.begin());
}
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);
const CUdeviceptr widths = oblique + sizeof(float) * static_cast<std::size_t>(oblique_size);
const CUdeviceptr values = volume.pointer();
launch(max_project, front_size, values, front, front_size, nx, plane, 1, nz, nx);
launch(max_project, side_size, values, side, side_size, nz, plane, nx, nx, 1);
launch(max_project, top_size, values, 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],
launch(project_oblique, voxels(), values, 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);
result.front = download<float>(front, static_cast<std::size_t>(front_size));
result.side = download<float>(side, static_cast<std::size_t>(side_size));
result.top = download<float>(top, static_cast<std::size_t>(top_size));
result.oblique = download<float>(oblique, static_cast<std::size_t>(oblique_size));
launch(peak_widths, 3, values, gx(), gy(), gz(), nx, ny, nz,
static_cast<int>(px), static_cast<int>(py), static_cast<int>(pz), widths);
Projections result;
result.front = copy_to_host<float>(front, static_cast<std::size_t>(front_size));
result.side = copy_to_host<float>(side, static_cast<std::size_t>(side_size));
result.top = copy_to_host<float>(top, static_cast<std::size_t>(top_size));
result.oblique = copy_to_host<float>(oblique, static_cast<std::size_t>(oblique_size));
const auto measured = copy_to_host<float>(widths, 3U);
std::copy(measured.begin(), measured.end(), result.target_width.begin());
return result;
}
}
};
#else
struct Gpu::Impl {};
// Built without CUDA the constructor throws, so these are never reached.
struct Gpu::Impl {
[[noreturn]] static void missing() { throw CudaUnavailable("SAR3D requires an NVIDIA GPU with CUDA"); }
auto inverse_dft(std::span<const std::complex<float>>, std::size_t, std::size_t, std::size_t)
-> std::vector<std::complex<float>> { missing(); }
auto image(const VolumeRequest&) -> Peak { missing(); }
auto download() -> std::vector<float> { missing(); }
void load(const Axes&, std::span<const float>) { missing(); }
auto project(const std::array<std::size_t, 3>&, const ObliqueView&) -> Projections { missing(); }
};
#endif
Gpu::Gpu(double smooth_sigma_voxels) {
Gpu::Gpu() {
#ifdef SAR3D_WITH_CUDA
impl_ = std::make_unique<Impl>(gaussian_taps(smooth_sigma_voxels));
impl_ = std::make_unique<Impl>();
#else
(void)smooth_sigma_voxels;
throw CudaUnavailable("SAR3D requires an NVIDIA GPU with CUDA; this program was built without "
"nvrtc.h, cuda.h, libnvrtc or libcuda. Install CUDA and rebuild.");
#endif
@@ -577,7 +670,6 @@ Gpu::Gpu(double smooth_sigma_voxels) {
Gpu::~Gpu() = default;
// Without CUDA the constructor throws, so the #else branches below only satisfy the compiler.
auto Gpu::inverse_dft(std::span<const std::complex<float>> bands, std::size_t points,
std::size_t first_bin, std::size_t size) -> std::vector<std::complex<float>> {
if (points == 0U || bands.empty() || bands.size() % points != 0U || !std::has_single_bit(size) ||
@@ -585,36 +677,20 @@ auto Gpu::inverse_dft(std::span<const std::complex<float>> bands, std::size_t po
throw std::runtime_error("sar3d invalid inverse FFT dimensions");
}
(void)checked_product(bands.size() / points, size, kMaxIndexedItems, "A-scan stack");
#ifdef SAR3D_WITH_CUDA
return impl_->inverse_dft(bands, points, first_bin, size);
#else
throw CudaUnavailable("SAR3D requires an NVIDIA GPU with CUDA");
#endif
}
auto Gpu::project(const VolumeRequest& request) -> Projections {
const auto nx = request.x.size(), ny = request.y.size(), nz = request.z.size();
const auto plane = checked_product(nx, nz, kMaxIndexedItems, "grid plane");
(void)checked_product(plane, ny, kMaxIndexedItems, "grid (32-bit indexing; increase SAR_VOXEL_M)");
const auto& view = request.oblique;
if (nx < 2U || ny < 2U || nz < 2U || request.samples_per_ascan < 2U ||
request.contributions.empty() || request.samples_per_metre <= 0.0 ||
!std::isfinite(request.samples_per_metre) || request.samples_per_metre > std::numeric_limits<float>::max() ||
view.nu <= 0 || view.nv <= 0 || !(view.pixel_m > 0.0) || !std::isfinite(view.pixel_m)) {
throw std::runtime_error("sar3d invalid grid, aperture, A-scan or oblique dimensions");
auto Gpu::image(const VolumeRequest& request) -> Peak {
check_grid(request.grid);
if (request.samples_per_ascan < 2U || request.contributions.empty() || request.samples_per_metre <= 0.0 ||
!std::isfinite(request.samples_per_metre) || request.samples_per_metre > std::numeric_limits<float>::max()) {
throw std::runtime_error("sar3d invalid aperture or A-scan dimensions");
}
const auto scan_count = checked_product(request.contributions.size(), request.samples_per_ascan,
kMaxIndexedItems, "A-scan stack");
if (request.ascans.size() != scan_count) {
throw std::runtime_error("sar3d A-scan stack does not match the aperture");
}
for (const auto axis : {request.x, request.y, request.z}) {
for (const double coordinate : axis) {
if (!std::isfinite(coordinate) || std::abs(coordinate) > std::numeric_limits<float>::max()) {
throw std::runtime_error("sar3d grid contains an unsupported coordinate");
}
}
}
for (const auto& term : request.contributions) {
if (!std::isfinite(term.weight) || term.weight < 0.0F ||
!std::isfinite(term.inv_reference_ranges) || term.inv_reference_ranges <= 0.0F) {
@@ -626,13 +702,32 @@ auto Gpu::project(const VolumeRequest& request) -> Projections {
}
}
}
return impl_->image(request);
}
auto Gpu::download() -> std::vector<float> {
return impl_->download();
}
void Gpu::load(const Axes& grid, std::span<const float> values) {
check_grid(grid);
if (values.size() != grid.x.size() * grid.y.size() * grid.z.size()) {
throw std::runtime_error("sar3d volume does not match its grid");
}
// The oblique projection orders values by their bit patterns, which needs them non-negative.
if (!std::ranges::all_of(values, [](float value) { return std::isfinite(value) && value >= 0.0F; })) {
throw std::runtime_error("sar3d volume must be finite and non-negative");
}
impl_->load(grid, values);
}
auto Gpu::project(const std::array<std::size_t, 3>& peak, const ObliqueView& view) -> Projections {
if (view.nu <= 0 || view.nv <= 0 || !(view.pixel_m > 0.0) || !std::isfinite(view.pixel_m)) {
throw std::runtime_error("sar3d invalid oblique view");
}
(void)checked_product(static_cast<std::size_t>(view.nu), static_cast<std::size_t>(view.nv),
kMaxIndexedItems, "oblique image");
#ifdef SAR3D_WITH_CUDA
return impl_->project(request);
#else
throw CudaUnavailable("SAR3D requires an NVIDIA GPU with CUDA");
#endif
return impl_->project(peak, view);
}
} // namespace radar::processing::sar3d
@@ -9,8 +9,8 @@
#include <vector>
// CUDA backend of the sar3d processor: A-scans of the band spectra, and the
// backprojection of an aperture into the 3D volume, smoothed and projected.
// Only the projections, peak and its axial half-maximum widths leave the backend.
// backprojection of an aperture into a smoothed 3D volume. The volume stays on the
// device for its projections and is downloaded only when its voxels are needed.
namespace radar::processing::sar3d {
class CudaUnavailable : public std::runtime_error {
@@ -40,44 +40,57 @@ struct ObliqueView {
int nv = 0;
};
struct VolumeRequest {
// Scene axes [m]: X across, Y height above the ground, Z along the ground.
// Scene axes of a volume [m]: X across, Y height above the ground, Z along the ground.
// Volumes are [y][z][x] with x fastest.
struct Axes {
std::span<const double> x;
std::span<const double> y;
std::span<const double> z;
};
// Whether Y plane `iy` is clear of the grid's top and bottom `margin`: a constant-range
// ridge leaves the grid there and would set a false maximum (sar_norm_reference_mask).
[[nodiscard]] auto reference_plane(std::span<const double> y, double margin, std::size_t iy) -> bool;
struct VolumeRequest {
Axes grid{};
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;
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::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;
double norm_edge_margin_m = 0.0;
ObliqueView oblique{};
double norm_edge_margin_m = 0.0; // SAR_NORM_EDGE_MARGIN_M of reference_plane
double smooth_sigma_voxels = 0.0; // Gaussian over Z and X
};
// Maximum projections of the smoothed volume (row-major), before normalization.
// Maximum of a volume over its reference planes.
struct Peak {
float level = 0.0F; // of the smoothed volume
float raw_level = 0.0F; // before smoothing
std::array<std::size_t, 3> index{}; // first voxel at `level`, [y, z, x]
};
// Maximum projections of the current volume (row-major), unscaled.
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]
float level = 0.0F;
float raw_level = 0.0F;
std::array<std::size_t, 3> peak{}; // joint voxel maximum, [y, z, x]
std::array<float, 3> target_width{}; // contiguous FWHM through the peak, [X, Y, Z], metres
std::array<float, 3> target_width{}; // contiguous FWHM through the given peak, [X, Y, Z], metres
};
// CUDA context and runtime-compiled kernels. A missing device raises
// CudaUnavailable; computation and compilation failures remain ordinary errors.
class Gpu {
public:
explicit Gpu(double smooth_sigma_voxels);
Gpu();
~Gpu();
Gpu(const Gpu&) = delete;
auto operator=(const Gpu&) -> Gpu& = delete;
@@ -91,8 +104,18 @@ class Gpu {
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;
// Backprojects and smooths over (Z, X) into the current volume; returns its maximum over
// the reference planes.
auto image(const VolumeRequest& request) -> Peak;
// The current volume, [y][z][x].
[[nodiscard]] auto download() -> std::vector<float>;
// Makes non-negative host values on `grid` the current volume.
void load(const Axes& grid, std::span<const float> values);
// Max projections of the current volume and its half-maximum widths through `peak` ([y, z, x]).
[[nodiscard]] auto project(const std::array<std::size_t, 3>& peak, const ObliqueView& view) -> Projections;
private:
struct Impl;
@@ -9,23 +9,28 @@
#include <cstddef>
#include <cstdint>
#include <exception>
#include <iomanip>
#include <iostream>
#include <limits>
#include <map>
#include <memory>
#include <numbers>
#include <optional>
#include <set>
#include <span>
#include <sstream>
#include <stdexcept>
#include <string>
#include <string_view>
#include <tuple>
#include <utility>
#include <vector>
#include "sar3d_clean.hpp"
#include "sar3d_gpu.hpp"
// Port of the 3D SAR part of the reference notebook Multi_antenna_demonstrate_depthnorm.ipynb
// (cells 1 and 14). Comments name the notebook functions and constants each step
// Port of the 3D SAR part of the reference notebook Multi_depthnorm_clean.ipynb
// (cells 1 and 5). Comments name the notebook functions and constants each step
// follows; the parameters keep the notebook names in the GUI.
namespace radar::processing {
namespace {
@@ -62,7 +67,7 @@ struct Settings {
double look_angle_rad = 0.0; // LOOK_ANGLE_DEG
double ground_depth_m = 0.0; // BP_GROUND_DEPTH_M
std::size_t frame_radius = 0U; // SAR_FRAME_RADIUS
double voxel_m = 0.0; // SAR_VOXEL_M, one edge for X, Y and Z
std::array<double, 3> voxel_m{}; // SAR_VOXEL_M, steps along X, Y and Z
float comp_range_power = 0.0F; // COMP_RANGE_POWER
bool use_compensation = true; // SAR_USE_COMPENSATION
bool normalize_each_frame = true; // SAR_NORMALIZE_EACH_FRAME
@@ -72,6 +77,8 @@ struct Settings {
std::size_t mean_count = 10U;
double x_size_m = 0.0, z_size_m = 0.0; // 0: notebook's automatic extent
double y_min_m = -0.5, y_max_m = 2.0; // SAR_Y_RANGE_M
bool bg_gate_enabled = true; // BG_GATE_ENABLE
bool clean_enabled = true; // SAR_CLEAN_ENABLE
// Everything the cached A-scans depend on.
[[nodiscard]] auto ascan_key() const {
@@ -126,7 +133,7 @@ void select_antennas(
settings.look_angle_rad = static_cast<double>(live.gpr_look_angle_deg) * (pi / 180.0);
settings.ground_depth_m = live.sar3d_bp_ground_depth_m;
settings.frame_radius = live.sar3d_frame_radius;
settings.voxel_m = live.sar3d_voxel_m;
settings.voxel_m = {live.sar3d_voxel_x_m, live.sar3d_voxel_y_m, live.sar3d_voxel_z_m};
settings.comp_range_power = live.sar3d_comp_range_power;
settings.use_compensation = live.sar3d_use_compensation;
settings.normalize_each_frame = live.sar3d_normalize_each_frame;
@@ -137,6 +144,8 @@ void select_antennas(
settings.z_size_m = live.sar3d_z_size_m;
settings.y_min_m = live.sar3d_y_min_m;
settings.y_max_m = live.sar3d_y_max_m;
settings.bg_gate_enabled = live.sar3d_bg_gate_enabled;
settings.clean_enabled = live.sar3d_clean_enabled;
if (settings.x_size_m < 0.0 || settings.z_size_m < 0.0 || !(settings.y_max_m > settings.y_min_m)) {
throw std::runtime_error("SAR scene sizes must be non-negative and SAR_Y_RANGE_M must increase");
}
@@ -146,23 +155,20 @@ void select_antennas(
if (!(settings.max_depth_m > settings.min_depth_m)) {
throw std::runtime_error("MAX_DEPTH must be above MIN_DEPTH");
}
if (!(settings.voxel_m > 0.0)) {
throw std::runtime_error("SAR_VOXEL_M must be positive");
if (!std::ranges::all_of(settings.voxel_m, [](double step) { return step > 0.0; })) {
throw std::runtime_error("SAR_VOXEL_M must be positive along X, Y and Z");
}
return settings;
}
// numpy.percentile with linear interpolation.
[[nodiscard]] auto percentile(std::vector<double> values, double q) -> double {
const double position = q / 100.0 * static_cast<double>(values.size() - 1U);
const auto lower = static_cast<std::size_t>(position);
const auto lower_it = values.begin() + static_cast<std::ptrdiff_t>(lower);
std::nth_element(values.begin(), lower_it, values.end());
if (lower + 1U == values.size()) {
return *lower_it;
// numpy.hanning (BP_WINDOW).
[[nodiscard]] auto hann(std::size_t count) -> std::vector<double> {
std::vector<double> window(count);
const auto last = static_cast<double>(count - 1U);
for (std::size_t index = 0U; index < count; ++index) {
window[index] = 0.5 - 0.5 * std::cos(2.0 * pi * static_cast<double>(index) / last);
}
const double upper = *std::min_element(lower_it + 1, values.end());
return *lower_it + (position - static_cast<double>(lower)) * (upper - *lower_it);
return window;
}
struct Band {
@@ -197,7 +203,7 @@ struct Band {
steps.push_back(static_cast<double>(frequency[points[index + 1U]]) - frequency[points[index]]);
}
Band band{};
band.df_hz = percentile(std::move(steps), 50.0);
band.df_hz = bp::median(std::move(steps));
if (!(band.df_hz > 0.0)) {
throw std::runtime_error("trace frequencies do not increase");
}
@@ -219,17 +225,13 @@ struct Band {
auto samples = bp::subtract_background(trace, background);
bp::apply_motion_phase(samples, frequency, clock, settings.speed_m_s, settings.look_angle_rad,
1.0, false, settings.f_start_hz, settings.f_stop_hz);
const auto last = static_cast<double>(points.size() - 1U);
const auto window = hann(points.size());
for (std::size_t index = 0U; index < points.size(); ++index) {
const std::size_t point = points[index];
const double hann = 0.5 - 0.5 * std::cos(2.0 * pi * static_cast<double>(index) / last);
const auto sample = samples[point];
const auto sample = samples[points[index]];
if (!std::isfinite(sample.real()) || !std::isfinite(sample.imag())) {
throw std::runtime_error("S21 contains a non-finite sample");
}
band.spectrum.emplace_back(
sample * hann
);
band.spectrum.emplace_back(sample * window[index]);
}
return band;
}
@@ -240,6 +242,8 @@ struct Frame {
double dt_s = 0.0;
std::size_t samples = 0U; // per A-scan
std::vector<std::complex<float>> ascans{}; // [pair][sample]
// A-scan of a point reflector at zero delay, the same band and window (sar_clean_unit_pulse).
std::vector<std::complex<float>> pulse{};
};
[[nodiscard]] auto compute_frame(
@@ -275,11 +279,17 @@ struct Frame {
}
spectra.insert(spectra.end(), band.spectrum.begin(), band.spectrum.end());
}
// The pulse is the A-scan of a flat unit spectrum, computed after the pairs.
for (const double weight : hann(first.spectrum.size())) {
spectra.emplace_back(static_cast<float>(weight));
}
Frame frame{};
frame.center_ns = timing.center_ns;
frame.samples = first.size;
frame.dt_s = 1.0 / (static_cast<double>(first.size) * first.df_hz);
frame.ascans = backend.inverse_dft(spectra, first.spectrum.size(), first.first_bin, first.size);
frame.pulse.assign(frame.ascans.end() - static_cast<std::ptrdiff_t>(frame.samples), frame.ascans.end());
frame.ascans.resize(frame.ascans.size() - frame.samples);
std::vector<std::span<std::complex<float>>> pairs;
for (std::size_t offset = 0; offset < frame.ascans.size(); offset += frame.samples) {
pairs.push_back(std::span(frame.ascans).subspan(offset, frame.samples));
@@ -316,9 +326,12 @@ struct Frame {
return axis;
}
[[nodiscard]] auto checked_axis_size(double count) -> std::size_t {
// Every kernel uses signed 32-bit indices; `remedy` tells how to stay within them.
constexpr std::string_view kVoxelRemedy = "increase SAR_VOXEL_M";
[[nodiscard]] auto checked_axis_size(double count, std::string_view remedy) -> std::size_t {
if (!std::isfinite(count) || count < 1.0 || count > std::numeric_limits<int>::max()) {
throw std::runtime_error("SAR axis exceeds 32-bit kernel indexing; increase SAR_VOXEL_M");
throw std::runtime_error("SAR axis exceeds 32-bit kernel indexing; " + std::string(remedy));
}
return static_cast<std::size_t>(count);
}
@@ -327,12 +340,19 @@ struct Grid {
std::vector<double> x{};
std::vector<double> y{};
std::vector<double> z{};
[[nodiscard]] auto axes() const -> sar3d::Axes {
return {x, y, z};
}
};
// Scene grid (sar_build_full_region_grid, 'ground' axes): Y = 0 is the ground,
// Bounds [low, high] of the scene along X, Y and Z.
using Region = std::array<std::array<double, 2>, 3>;
// Scene region (sar_build_full_region_grid, 'ground' axes): Y = 0 is the ground,
// Z runs along the ground and spans every point of the window whose radar depth
// is inside [MIN_DEPTH, MAX_DEPTH] at some height of the window.
[[nodiscard]] auto scene_grid(const Settings& settings) -> Grid {
[[nodiscard]] auto scene_region(const Settings& settings) -> Region {
const double cos_p = std::cos(settings.look_angle_rad);
const double sin_p = std::sin(settings.look_angle_rad);
if (std::abs(cos_p) < 1e-6) {
@@ -357,24 +377,47 @@ struct Grid {
z_max = std::max(z_max, z);
}
}
const auto resize = [](double& low, double& high, double size) {
if (size > 0.0) { const double center = (low + high) / 2.0; low = center - size / 2.0; high = center + size / 2.0; }
};
resize(x_min, x_max, settings.x_size_m);
resize(z_min, z_max, settings.z_size_m);
// Match sar_build_full_region_grid and reject oversized products before
// allocating the axes. Every downstream kernel uses signed 32-bit indices.
const auto axis_size = [&](double span) {
return checked_axis_size(std::max(std::nearbyint(span / settings.voxel_m) + 1.0, 2.0));
};
const auto nx = axis_size(x_max - x_min);
const auto ny = axis_size(settings.y_max_m - settings.y_min_m);
const auto nz = axis_size(z_max - z_min);
if (nx > static_cast<std::size_t>(std::numeric_limits<int>::max()) / ny / nz) {
throw std::runtime_error("SAR grid exceeds 32-bit kernel indexing; increase SAR_VOXEL_M");
return {{{x_min, x_max}, {settings.y_min_m, settings.y_max_m}, {z_min, z_max}}};
}
// Region of the frame grid: the scene region with X and Z resized about their centres to
// SAR_X_SIZE_M and SAR_Z_SIZE_M where those are set.
[[nodiscard]] auto frame_region(Region region, const Settings& settings) -> Region {
const auto resize = [](std::array<double, 2>& bounds, double size) {
if (size > 0.0) {
const double center = (bounds[0] + bounds[1]) / 2.0;
bounds = {center - size / 2.0, center + size / 2.0};
}
return {linspace(x_min, x_max, nx), linspace(settings.y_min_m, settings.y_max_m, ny),
linspace(z_min, z_max, nz)};
};
resize(region[0], settings.x_size_m);
resize(region[2], settings.z_size_m);
return region;
}
// Region of the CLEAN search: the scene region around the frame grid (sar_clean_search_grid),
// grown to a frame that SAR_X_SIZE_M or SAR_Z_SIZE_M widen past it.
[[nodiscard]] auto search_region(const Region& scene, const Region& frame) -> Region {
Region region{};
for (std::size_t axis = 0U; axis < 3U; ++axis) {
region[axis] = {std::min(scene[axis][0], frame[axis][0]), std::max(scene[axis][1], frame[axis][1])};
}
return region;
}
// Grid over the region with the given steps along X, Y and Z (sar_build_full_region_grid).
// Oversized products are rejected before the axes are allocated.
[[nodiscard]] auto region_grid(const Region& region, const std::array<double, 3>& step, std::string_view remedy)
-> Grid {
std::array<std::size_t, 3> count{};
for (std::size_t axis = 0U; axis < 3U; ++axis) {
const auto [low, high] = region[axis];
count[axis] = checked_axis_size(std::max(std::nearbyint((high - low) / step[axis]) + 1.0, 2.0), remedy);
}
if (count[0] > static_cast<std::size_t>(std::numeric_limits<int>::max()) / count[1] / count[2]) {
throw std::runtime_error("SAR grid exceeds 32-bit kernel indexing; " + std::string(remedy));
}
return {linspace(region[0][0], region[0][1], count[0]), linspace(region[1][0], region[1][1], count[1]),
linspace(region[2][0], region[2][1], count[2])};
}
// Cosine taper over the aperture: 1 at the centre frame, SAR_EDGE_FRAME_WEIGHT at
@@ -456,10 +499,10 @@ struct Oblique {
}
}
}
view.nu = static_cast<int>(checked_axis_size(std::round((u_max - view.u0) / view.pixel_m) + 1.0));
view.nv = static_cast<int>(checked_axis_size(std::round((v_max - view.v0) / view.pixel_m) + 1.0));
view.nu = static_cast<int>(checked_axis_size(std::round((u_max - view.u0) / view.pixel_m) + 1.0, kVoxelRemedy));
view.nv = static_cast<int>(checked_axis_size(std::round((v_max - view.v0) / view.pixel_m) + 1.0, kVoxelRemedy));
if (view.nu > std::numeric_limits<int>::max() / view.nv) {
throw std::runtime_error("SAR oblique image exceeds 32-bit kernel indexing; increase SAR_VOXEL_M");
throw std::runtime_error("SAR oblique image exceeds 32-bit kernel indexing; " + std::string(kVoxelRemedy));
}
for (int index = 0; index < view.nu; ++index) {
oblique.u.push_back(view.u0 + index * view.pixel_m);
@@ -512,6 +555,21 @@ struct Oblique {
return payload;
}
// CLEAN part of the status line (sar_display_note, sar_clean_report).
[[nodiscard]] auto clean_note(const sar3d::Clean& clean) -> std::string {
std::ostringstream note;
note << std::fixed << std::setprecision(1) << "CLEAN: ";
if (clean.objects.empty()) {
note << "no objects";
} else {
note << clean.objects.size() << (clean.objects.size() == 1U ? " object" : " objects")
<< ", each in its own scale";
}
note << " (" << clean.steps << (clean.steps == 1U ? " subtraction" : " subtractions")
<< ", residual maximum " << clean.residual_ratio << " backgrounds)";
return note.str();
}
[[nodiscard]] auto status_payload(float code, const std::string& message) -> ipc::ResultPayload {
std::vector<float> values{code};
for (const unsigned char byte : message) {
@@ -521,6 +579,71 @@ struct Oblique {
return table_payload("sar3d_status", columns, std::move(values));
}
// What the views show of one frame image (sar_prepare_display).
struct Display {
sar3d::Projections views; // the image in units of its maximum, or the CLEAN display
std::array<std::size_t, 3> peak{}; // marked voxel [y, z, x]: the maximum or the strongest object
std::optional<sar3d::FrameBackground> background; // when BG_GATE_ENABLE or CLEAN needs it
std::optional<sar3d::Clean> clean; // when CLEAN ran
[[nodiscard]] auto has_objects() const -> bool { return clean.has_value() && !clean->objects.empty(); }
};
// Views of the frame image of `request`, the current volume of `backend` with maximum
// `peak`. Measures the frame background when BG_GATE_ENABLE or CLEAN needs it and, when
// the aperture is the one frame `single` (nullptr for a longer aperture, as sar_clean_frame),
// runs CLEAN, whose objects then replace the image on the views.
[[nodiscard]] auto display_frame(
sar3d::Gpu& backend,
const sar3d::VolumeRequest& request,
const sar3d::Peak& peak,
double normalization,
const sar3d::ObliqueView& view,
const Settings& settings,
const Region& search_area,
const Frame* single
) -> Display {
Display display{.views = backend.project(peak.index, view), .peak = peak.index, .background = {}, .clean = {}};
const bool run_clean = settings.clean_enabled && single != nullptr;
if (peak.level > 0.0F && (settings.bg_gate_enabled || run_clean)) {
const auto volume = backend.download();
display.background = sar3d::frame_background(volume, volume, request);
if (run_clean) {
// The search spans the whole scene region, so responses just outside a narrowed frame
// are subtracted too.
const Grid search = region_grid(search_area, sar3d::kCleanSearchStep,
"narrow the scene or turn SAR_CLEAN_ENABLE off");
display.clean = sar3d::clean(backend, {
.frame = request,
.volume = volume,
.level = normalization,
.frame_background = *display.background,
.search = search.axes(),
.ascans = single->ascans,
.pulse = single->pulse,
.bg_gate = settings.bg_gate_enabled,
});
if (display.has_objects()) {
// CLEAN left one of its own images current; the display replaces it.
display.peak = display.clean->objects.front().index;
backend.load(request.grid, display.clean->display);
display.views = backend.project(display.peak, view);
return display;
}
}
}
auto& views = display.views;
for (auto* image : {&views.front, &views.side, &views.top, &views.oblique}) {
for (auto& value : *image) {
value = static_cast<float>(static_cast<double>(value) / normalization);
if (!std::isfinite(value)) {
throw std::runtime_error("SAR normalization exceeds the float32 range");
}
}
}
return display;
}
} // namespace
struct Sar3dProcessor::State {
@@ -570,7 +693,7 @@ struct Sar3dProcessor::State {
auto gpu() -> sar3d::Gpu& {
if (device == nullptr && device_error == nullptr) {
try { device = std::make_unique<sar3d::Gpu>(kSmoothSigma); }
try { device = std::make_unique<sar3d::Gpu>(); }
catch (...) { device_error = std::current_exception(); }
}
if (device == nullptr) { std::rethrow_exception(device_error); }
@@ -617,15 +740,17 @@ struct Sar3dProcessor::State {
offsets_s.push_back(1e-9 * static_cast<double>(delta_ns));
}
const auto weights = frame_weights(offsets_s);
const Grid grid = scene_grid(settings);
const Region scene = scene_region(settings);
const Region frame_area = frame_region(scene, settings);
const Grid grid = region_grid(frame_area, settings.voxel_m, kVoxelRemedy);
const double cos_p = std::cos(settings.look_angle_rad);
const double sin_p = std::sin(settings.look_angle_rad);
std::vector<std::array<double, 3>> corners{}; // of the scene box, in radar axes
for (const double x : {grid.x.front(), grid.x.back()}) {
for (const double y : {grid.y.front(), grid.y.back()}) {
for (const double z : {grid.z.front(), grid.z.back()}) {
const double y0 = y - settings.ground_depth_m;
corners.push_back({x, y0 * cos_p + z * sin_p, -y0 * sin_p + z * cos_p});
const auto [y_radar, z_radar] = bp::radar_yz(y, z, settings.ground_depth_m, settings.look_angle_rad);
corners.push_back({x, y_radar, z_radar});
}
}
}
@@ -666,11 +791,8 @@ struct Sar3dProcessor::State {
}
}
const Oblique oblique = oblique_view(grid, settings.voxel_m);
auto images = backend.project({
.x = grid.x,
.y = grid.y,
.z = grid.z,
const sar3d::VolumeRequest request{
.grid = grid.axes(),
.pitch_rad = settings.look_angle_rad,
.ground_depth_m = settings.ground_depth_m,
.min_depth_m = settings.min_depth_m,
@@ -683,44 +805,72 @@ struct Sar3dProcessor::State {
.comp_range_power = settings.comp_range_power,
.comp_weight_max = kCompRangeWeightMax,
.norm_edge_margin_m = kNormEdgeMargin,
.oblique = oblique.view,
});
.smooth_sigma_voxels = kSmoothSigma,
};
// Normalization level and maximum over the Y planes clear of the window
// edges, where a constant-range ridge leaves the grid and would set a
// false maximum (sar_norm_reference_mask, sar_finish_volume,
// sar_global_max_index). The level is the volume maximum, so the
// Values at excluded Y edges can exceed 1, as in sar_finish_volume.
const float level = images.level;
const auto [peak_y, peak_z, peak_x] = images.peak;
// values at excluded Y edges can exceed 1, as in sar_finish_volume.
const auto peak = backend.image(request);
// The notebook normalizes before and after the linear Gaussian pass.
// Combining both factors in double preserves its epsilons and avoids
// float32 reciprocal overflow for a weak, nonzero signal.
const double normalization = static_cast<double>(level) + (images.raw_level + 1e-12) * 1e-12;
for (auto* image : {&images.front, &images.side, &images.top, &images.oblique}) {
for (auto& value : *image) {
value = static_cast<float>(static_cast<double>(value) / normalization);
if (!std::isfinite(value)) { throw std::runtime_error("SAR normalization exceeds the float32 range"); }
}
}
const double normalization = static_cast<double>(peak.level) + (peak.raw_level + 1e-12) * 1e-12;
// Pixels of the largest voxel step leave no gaps between projected voxels.
const Oblique oblique = oblique_view(grid, std::ranges::max(settings.voxel_m));
auto display = display_frame(backend, request, peak, normalization, oblique.view, settings,
search_region(scene, frame_area), aperture.size() == 1U ? newest.get() : nullptr);
std::vector<ipc::ResultPayload> payloads{};
payloads.push_back(image_payload("sar3d_front", grid.x, grid.y, std::move(images.front)));
payloads.push_back(image_payload("sar3d_side", grid.z, grid.y, std::move(images.side)));
payloads.push_back(image_payload("sar3d_top", grid.x, grid.z, std::move(images.top)));
payloads.push_back(image_payload("sar3d_oblique", oblique.u, oblique.v, std::move(images.oblique)));
payloads.push_back(image_payload("sar3d_front", grid.x, grid.y, std::move(display.views.front)));
payloads.push_back(image_payload("sar3d_side", grid.z, grid.y, std::move(display.views.side)));
payloads.push_back(image_payload("sar3d_top", grid.x, grid.z, std::move(display.views.top)));
payloads.push_back(image_payload("sar3d_oblique", oblique.u, oblique.v, std::move(display.views.oblique)));
std::vector<float> basis(oblique.view.u.begin(), oblique.view.u.end());
basis.insert(basis.end(), oblique.view.v.begin(), oblique.view.v.end());
payloads.push_back(table_payload("sar3d_oblique_basis", 3U, std::move(basis)));
if (level > 0.0F) {
payloads.push_back(table_payload("sar3d_target_width", 3U,
{images.target_width[0], images.target_width[1], images.target_width[2]}));
payloads.push_back(table_payload("sar3d_peak", 4U, {static_cast<float>(normalization), static_cast<float>(grid.x[peak_x]),
static_cast<float>(grid.y[peak_y]),
static_cast<float>(grid.z[peak_z])}));
if (!(peak.level > 0.0F)) {
payloads.push_back(status_payload(1.0F, "No signal inside the selected depth range"));
return payloads;
}
payloads.push_back(status_payload(level > 0.0F ? 0.0F : 1.0F,
level > 0.0F ? "CUDA" : "No signal inside the selected depth range"));
const auto [peak_y, peak_z, peak_x] = display.peak;
payloads.push_back(table_payload("sar3d_peak", 4U, {
static_cast<float>(normalization), static_cast<float>(grid.x[peak_x]),
static_cast<float>(grid.y[peak_y]), static_cast<float>(grid.z[peak_z]),
}));
if (settings.bg_gate_enabled) {
payloads.push_back(table_payload("sar3d_background", 3U, {
static_cast<float>(display.background.value().ratio()),
static_cast<float>(sar3d::kBgGateShow), static_cast<float>(sar3d::kBgGateFull),
}));
}
if (display.has_objects()) {
// The objects replace the peak marker, so its width is not published.
std::vector<float> object_rows{};
for (const auto& object : display.clean->objects) {
for (const double coordinate : object.position) { object_rows.push_back(static_cast<float>(coordinate)); }
object_rows.push_back(static_cast<float>(object.level));
}
payloads.push_back(table_payload("sar3d_objects", 4U, std::move(object_rows)));
std::vector<float> mark_rows{};
for (const auto& mark : sar3d::marks(display.clean->objects, request)) {
mark_rows.push_back(static_cast<float>(mark.kind));
for (const double edge : mark.box) { mark_rows.push_back(static_cast<float>(edge)); }
}
payloads.push_back(table_payload("sar3d_marks", 7U, std::move(mark_rows)));
} else {
const auto& widths = display.views.target_width;
payloads.push_back(table_payload("sar3d_target_width", 3U, {widths[0], widths[1], widths[2]}));
}
std::string status = "CUDA";
if (display.clean.has_value()) {
status += "; " + clean_note(*display.clean);
} else if (settings.clean_enabled) {
status += "; CLEAN skipped: it needs a one-frame aperture, this one has " +
std::to_string(aperture.size()) + " frames";
}
payloads.push_back(status_payload(0.0F, status));
return payloads;
}
@@ -1,9 +1,11 @@
#include "sar3d_processor.hpp"
#include "gpr_processor.hpp"
#include "../processing/processors/src/sar3d_clean.hpp"
#include <algorithm>
#include <complex>
#include <iostream>
#include <numbers>
#include <vector>
#include <nlohmann/json.hpp>
@@ -40,11 +42,37 @@ auto collection(const Json& source) -> ipc::PreprocessedCollection {
}
return result;
}
// Boxes around given CLEAN objects, rows [x, y, z, y_low, y_high, level], in a scene with the
// given X and Y bounds and radar placement (sar3d::marks).
auto marks(const Json& input) -> Json {
std::vector<processing::sar3d::CleanObject> objects;
for (const auto& row : input.at("objects")) {
objects.push_back({.index = {}, .position = {row.at(0), row.at(1), row.at(2)},
.y_low = row.at(3), .y_high = row.at(4), .level = row.at(5)});
}
const auto x = input.at("x").get<std::vector<double>>();
const auto y = input.at("y").get<std::vector<double>>();
const processing::sar3d::VolumeRequest frame{
.grid = {x, y, {}},
.pitch_rad = input.at("pitch").get<double>() * (std::numbers::pi / 180.0),
.ground_depth_m = input.at("ground"),
.min_depth_m = input.at("min_depth"),
.max_depth_m = input.at("max_depth"),
};
Json rows = Json::array();
for (const auto& mark : processing::sar3d::marks(objects, frame)) {
Json row = Json::array({static_cast<int>(mark.kind)});
for (const double edge : mark.box) { row.push_back(edge); }
rows.push_back(std::move(row));
}
return Json{{"marks", rows}};
}
}
// Runs GPR or sar3d on one request or a batch, retaining processor caches. Each request holds the antenna
// geometry, the live settings and the latest and previous collections; the
// published payloads go to stdout as JSON.
// published payloads go to stdout as JSON. A request with mode "marks" boxes given objects instead.
int main() {
try {
const Json source = Json::parse(std::cin);
@@ -54,6 +82,10 @@ int main() {
processing::Sar3dProcessor sar;
processing::GprProcessor gpr;
for (const auto& input : requests) {
if (input.value("mode", "sar3d") == "marks") {
results.push_back(marks(input));
continue;
}
config::RunConfig config;
config.rings.results.slot_size_bytes = input.value("slot", config.rings.results.slot_size_bytes);
for (const auto& row : input.at("tx")) {
@@ -62,6 +94,7 @@ int main() {
for (const auto& row : input.at("rx")) {
config.gpr.rx_geometry.push_back({row.at(0), row.at(1), row.at(2), row.at(3)});
}
const bool gpr_mode = input.value("mode", "sar3d") == "gpr";
processing::ProcessingLiveConfig live;
live.gpr_start_freq_mhz = input.at("f_start").get<double>() / 1e6;
live.gpr_stop_freq_mhz = input.at("f_stop").get<double>() / 1e6;
@@ -69,31 +102,39 @@ int main() {
live.gpr_max_depth_m = input.at("max_depth");
live.gpr_speed_m_s = input.at("speed");
live.gpr_look_angle_deg = input.at("pitch");
live.sar3d_voxel_m = input.at("voxel");
live.sar3d_bp_ground_depth_m = input.at("ground");
live.sar3d_frame_radius = input.at("radius");
live.sar3d_comp_range_power = input.at("power");
live.sar3d_use_compensation = input.at("compensation");
live.sar3d_normalize_each_frame = input.at("normalize");
live.pair_depth_equalize = input.value("equalize", true);
live.gpr_background_subtract_enabled = input.value("subtract_mean", false);
live.gpr_background_mean_count = input.value("mean_count", 10U);
live.sar3d_x_size_m = input.value("x_size", 0.0F);
live.sar3d_y_min_m = input.value("y_min", -0.5F);
live.sar3d_y_max_m = input.value("y_max", 2.0F);
live.sar3d_z_size_m = input.value("z_size", 0.0F);
if (gpr_mode) {
live.gpr_bp_ground_depth_m = input.at("ground");
live.gpr_range_comp_power = input.at("power");
live.gpr_motion_mode = input.value("motion", "int_minus");
live.gpr_object_approach_min_frames = 1;
live.gpr_remove_sidelobe_objects_enabled = false;
} else {
const auto& voxel = input.at("voxel");
live.sar3d_voxel_x_m = voxel.at(0);
live.sar3d_voxel_y_m = voxel.at(1);
live.sar3d_voxel_z_m = voxel.at(2);
live.sar3d_bp_ground_depth_m = input.at("ground");
live.sar3d_frame_radius = input.at("radius");
live.sar3d_comp_range_power = input.at("power");
live.sar3d_use_compensation = input.at("compensation");
live.sar3d_normalize_each_frame = input.at("normalize");
live.sar3d_x_size_m = input.value("x_size", 0.0F);
live.sar3d_y_min_m = input.value("y_min", -0.5F);
live.sar3d_y_max_m = input.value("y_max", 2.0F);
live.sar3d_z_size_m = input.value("z_size", 0.0F);
live.sar3d_bg_gate_enabled = input.value("bg_gate", live.sar3d_bg_gate_enabled);
live.sar3d_clean_enabled = input.value("clean", live.sar3d_clean_enabled);
}
std::vector<ipc::PreprocessedCollection> previous;
for (const auto& member : input.at("previous")) {
previous.push_back(collection(member));
}
const auto latest = collection(input.at("latest"));
for (const auto& trace : latest.traces) { config.run_combos.push_back(trace.combo); }
processing::ProcessorInterface& processor = input.value("mode", "sar3d") == "gpr"
processing::ProcessorInterface& processor = gpr_mode
? static_cast<processing::ProcessorInterface&>(gpr) : static_cast<processing::ProcessorInterface&>(sar);
const auto result = processor.process_collection(config, latest, previous, live);
Json payloads = Json::array();
@@ -1,6 +1,6 @@
"""Generate SAR3D/GPR references from notebook cells 1/14 and Horns coherent BP.
"""Generate SAR3D/GPR references from notebook cells 1/5 and Horns coherent BP.
Requires the original, untracked Multi_antenna_demonstrate_depthnorm.ipynb; a missing notebook
Requires the original, untracked Multi_depthnorm_clean.ipynb; a missing notebook
is an error. NumPy/SciPy execute its function/class ASTs unchanged. Only parameter
values are replaced; plotting imports and the interactive top-level runs are
excluded. No production SAR implementation or hand-transcribed oracle is used.
@@ -16,6 +16,7 @@ import hashlib
import json
from pathlib import Path
import tempfile
from types import SimpleNamespace
import numpy as np
import scipy
@@ -24,7 +25,7 @@ ROOT = Path(__file__).resolve().parents[2]
REFERENCE = Path(__file__).with_name("sar3d_reference.npz")
def reference_functions(source_path: Path, parameters: dict, cells: tuple[int, ...] = (1, 14)) -> tuple[dict, dict]:
def reference_functions(source_path: Path, parameters: dict, cells: tuple[int, ...] = (1, 5)) -> tuple[dict, dict]:
contents = source_path.read_text(encoding="utf-8")
sources = [(str(i), "".join(json.loads(contents)["cells"][i]["source"])) for i in cells] if cells else [("script", contents)]
namespace = {"__name__": __name__}
@@ -56,8 +57,9 @@ def scene(f_start=1e9, f_stop=4e9, points=201) -> dict:
tx=[[0, -.4, 0, 0], [1, .45, .2, .05]],
rx=[[0, -.2, .1, 0], [1, .3, .35, .02]], f_start=f_start, f_stop=f_stop,
min_depth=1., max_depth=3., speed=.4, pitch=13., ground=.3,
voxel=.4, radius=2, power=1.5, compensation=True, normalize=True,
voxel=[.4, .4, .4], radius=2, power=1.5, compensation=True, normalize=True,
equalize=True, subtract_mean=False, mean_count=1, motion="int_minus",
bg_gate=True, clean=False,
)
# Match the float32 settings/geometry accepted by the native fixture.
for key in ("tx", "rx"):
@@ -79,6 +81,45 @@ def scene(f_start=1e9, f_stop=4e9, points=201) -> dict:
return request
# Point reflectors (X, height Y, Z, amplitude) at heights of 0, 0.6 and 0.25 m.
REFLECTORS = [(.3, 0., 2.2, 1.), (-.8, .6, 2.6, .5), (1.3, .25, 1.9, .7)]
def point_scene(targets: list[tuple[float, float, float, float]], noise: float, seed: int, **settings) -> dict:
"""Settings of one static frame of point reflectors (X, height Y, Z, amplitude) in noise, for CLEAN.
Three Tx and three Rx at different heights resolve Y in a single frame. The voxel steps
keep the notebook's proportions, 2.5 : 2 : 2.
"""
request = dict(
tx=[[0, -.6, 0., 0.], [1, 0., .35, .02], [2, .6, .1, 0.]],
rx=[[0, -.3, .2, 0.], [1, .3, .45, .03], [2, .9, 0., .01]],
min_depth=1., max_depth=3.5, speed=0., pitch=13., ground=1., voxel=[.0625, .05, .05], radius=0,
clean=True, previous=[],
)
request.update(settings)
for key in ("tx", "rx"):
request[key] = np.asarray(request[key], dtype=np.float32).astype(float).tolist()
frequency = np.linspace(1e9, 4e9, 201, dtype=np.float32)
cos_p, sin_p = np.cos(np.radians(request["pitch"])), np.sin(np.radians(request["pitch"]))
random = np.random.default_rng(seed)
traces = []
for tx in request["tx"]:
for rx in request["rx"]:
samples = noise * (random.standard_normal(frequency.size) + 1j * random.standard_normal(frequency.size))
for x, height, z, amplitude in targets:
# Scene to radar axes, as sar_scene_to_radar_yz.
y0 = height - request["ground"]
point = np.array([x, y0 * cos_p + z * sin_p, -y0 * sin_p + z * cos_p])
path = np.linalg.norm(point - tx[1:]) + np.linalg.norm(point - rx[1:])
samples += amplitude * np.exp(-2j * np.pi * frequency.astype(float) * path / 3e8)
samples = samples.astype(np.complex64)
traces.append(dict(input=int(rx[0]), output=int(tx[0]), start=10_000_000_000, end=10_165_000_000,
frequency=frequency.tolist(), s21=np.column_stack((samples.real, samples.imag)).tolist()))
request["latest"] = dict(id=1, timestamp=10_165_000_000, traces=traces)
return request
def write_dataset(directory: Path, request: dict) -> list[Path]:
"""Supply the notebook's original config_profile/meta/NPY input contract."""
profile = {"gpr": {}, "run": {"combos": []}}
@@ -115,6 +156,8 @@ def reference(notebook: Path, request: dict, directory: Path) -> tuple[dict, dic
snapshots = write_dataset(directory, request)
center = snapshots[-1]
parameters = dict(
SAR_Y_RANGE_M=(request.get("y_min", -.5), request.get("y_max", 2.)),
BG_GATE_ENABLE=request["bg_gate"], SAR_CLEAN_ENABLE=request["clean"], SAR_MARK_ENABLE=True,
INPUT_IDX=[int(row[0]) for row in request["rx"]],
OUTPUT_IDX=[int(row[0]) for row in request["tx"]],
F_START=request["f_start"], F_STOP=request["f_stop"],
@@ -122,7 +165,7 @@ def reference(notebook: Path, request: dict, directory: Path) -> tuple[dict, dic
PAIR_NORM_DEPTH_MIN=request["min_depth"], PAIR_NORM_DEPTH_MAX=request["max_depth"],
SPEED_M_S=request["speed"], LOOK_ANGLE_DEG=request["pitch"],
BP_GROUND_DEPTH_M=request["ground"], COMP_RANGE_POWER=request["power"],
SAR_VOXEL_M=(request["voxel"],) * 3, SAR_FRAME_RADIUS=request["radius"],
SAR_VOXEL_M=tuple(request["voxel"]), SAR_FRAME_RADIUS=request["radius"],
SAR_USE_COMPENSATION=request["compensation"], SAR_NORMALIZE_EACH_FRAME=request["normalize"],
PAIR_DEPTH_EQUALIZE=request["equalize"],
SAR_SCAN_ROOT=directory, DATA_PATH=center, BG_PATH=directory, v=3e8,
@@ -150,31 +193,76 @@ def reference(notebook: Path, request: dict, directory: Path) -> tuple[dict, dic
motion_phase_sign=ns["MOTION_PHASE_SIGN"],
)
timestamps, _ = ns["compute_pair_timestamps_for_snapshot"](center, config)
grid = ns["sar_build_full_region_grid"](config, y_range_m=(request.get("y_min", -.5), request.get("y_max", 2.)))
for axis in ("x", "z"):
size = request.get(f"{axis}_size", 0.)
grid = ns["sar_build_full_region_grid"](config)
for index, axis in ((0, "x"), (2, "z")):
size, step = request.get(f"{axis}_size", 0.), request["voxel"][index]
if size:
center_axis = .5 * (grid[axis][0] + grid[axis][-1])
grid[axis] = np.linspace(center_axis - size/2, center_axis + size/2, max(round(size/request["voxel"])+1, 2))
grid[axis] = np.linspace(center_axis - size/2, center_axis + size/2, max(round(size/step)+1, 2))
grid["XX"], grid["ZZ"] = np.meshgrid(grid["x"], grid["z"])
ns["x_min"], ns["x_max"] = grid["x"][0], grid["x"][-1]
result = ns["sar_compute_volume"](center, None, config, timestamps, grid=grid, verbose=False)
volume, grid = result["volume"], result["grid"]
iy, iz, ix = ns["sar_global_max_index"](volume, grid)
# What the processor publishes: the frame volume, or the CLEAN display when CLEAN found
# objects, and the peak it marks; the background gain stays to the GUI.
ns["sar_prepare_display"](result, None, timestamps)
objects = result["clean_objects"] or []
volume, grid = result["display_volume"] if objects else result["volume"], result["grid"]
iy, iz, ix = result["display_max_idx"]
expected = {axis: grid[axis] for axis in "xyz"}
expected.update(front=volume.max(axis=1), side=volume.max(axis=2), top=volume.max(axis=0),
peak=np.array([result["level"], grid["x"][ix], grid["y"][iy], grid["z"][iz]]))
if not objects:
# Width of the response through the marked maximum; objects are marked instead.
from scipy.signal import peak_widths
widths = []
for axis, peak_index, profile in (("x", ix, volume[iy, iz, :]), ("y", iy, volume[:, iz, ix]), ("z", iz, volume[iy, :, ix])):
for axis, peak_index, profile in (("x", ix, volume[iy, iz, :]), ("y", iy, volume[:, iz, ix]),
("z", iz, volume[iy, :, ix])):
profile = np.asarray(profile, dtype=np.float64)
width, *_ = peak_widths(profile, [peak_index], prominence_data=(np.array([profile[peak_index]]),
np.array([0], dtype=np.intp), np.array([len(profile)-1], dtype=np.intp)))
widths.append(max(float(width[0]), 1.) * (grid[axis][1]-grid[axis][0]))
expected = {axis: grid[axis] for axis in "xyz"}
expected["target_width"] = np.asarray(widths)
expected.update(front=volume.max(axis=1), side=volume.max(axis=2), top=volume.max(axis=0),
peak=np.array([result["level"], grid["x"][ix], grid["y"][iy], grid["z"][iz]]))
if request["bg_gate"]:
expected["background"] = np.array([result["bg_ratio"], ns["BG_GATE_3D_SHOW"], ns["BG_GATE_3D_FULL"]])
if result["clean_info"] is not None:
expected["clean"] = np.array([result["clean_info"]["steps"], result["clean_info"]["residual_ratio"]])
if objects:
kinds = {"low": 0, "ground": 1, "up": 2, "edge": 3}
expected["objects"] = np.array([[item["x"], item["y"], item["z"], item["level_bg"]] for item in objects])
expected["marks"] = np.array([[kinds[group["kind"]], *group["box"]] for group in result["mark_groups"]])
return expected, hashes
def marks_reference(notebook: Path) -> tuple[dict, np.ndarray]:
"""sar_mark_groups on synthetic CLEAN objects, each of which tests one rule of the boxes.
Returns the fixture's "marks" request and the expected rows [class, X0, X1, Y0, Y1, Z0, Z1].
"""
scene = dict(x=[-2.6, 2.9], y=[-.5, 2.], pitch=13., ground=1., min_depth=1., max_depth=3.5)
objects = [ # [x, y, z, y_low, y_high, level], strongest first
[0., 0., 2., -.1, .3, 60.], # low
[1., .05, 2.2, -.1, .6, 55.], # centre low, top above SAR_MARK_LOW_TOP_M: ground
[-1., .3, 2.4, .1, .5, 50.], # ground
[-.2, .45, 3., .25, .65, 45.], # up while its weak member (last) stays out of the centre
[-1.6, 0., 1.3, -.1, .3, 40.], # a chain of three: the ends are 1.4 m apart, still one box
[-.9, .1, 1.3, -.1, .35, 35.],
[-.2, .05, 1.3, -.15, .3, 30.],
[-2.5, .2, 2., 0., .4, 28.], # near the X minimum: edge
[2.75, .2, 2., 0., .4, 26.], # near the X maximum: edge
[2.2, .1, 3.2, -.1, .3, 24.], # near MAX_DEPTH: edge
[2.2, 0., .85, -.2, .2, 22.], # near MIN_DEPTH: edge
[1.2, 1.7, 2.8, 1.5, 1.9, 20.], # near the top: edge
[.2, -.4, 3.1, -.5, -.25, 15.], # weak member of the "up" box, below 40 % of its top
]
ns, _ = reference_functions(notebook, dict(BP_GROUND_DEPTH_M=scene["ground"], MIN_DEPTH=scene["min_depth"],
MAX_DEPTH=scene["max_depth"]))
groups = ns["sar_mark_groups"](
[dict(zip(("x", "y", "z", "y_lo", "y_hi", "level_bg"), row)) for row in objects],
{axis: np.asarray(scene[axis]) for axis in "xy"}, SimpleNamespace(look_angle_deg=scene["pitch"]))
kinds = {"low": 0, "ground": 1, "up": 2, "edge": 3}
return dict(mode="marks", objects=objects, **scene), np.array([[kinds[g["kind"]], *g["box"]] for g in groups])
def previous_mean(request: dict) -> dict:
samples = {}
if request["subtract_mean"] and request["mean_count"]:
@@ -258,17 +346,32 @@ def generate(notebook: Path, output: Path) -> None:
cases = [
dict(compensation=True, normalize=True),
dict(compensation=True, normalize=False, equalize=False, speed=-.2, pitch=-7., ground=.5),
dict(compensation=False, normalize=True, f_start=1.3e9, f_stop=3.7e9),
# Three different voxel steps: each axis keeps its own.
dict(compensation=False, normalize=True, f_start=1.3e9, f_stop=3.7e9, voxel=[.45, .4, .35]),
dict(compensation=False, normalize=False, speed=0., pitch=0., radius=0),
dict(compensation=True, normalize=True, subtract_mean=True, mean_count=1),
dict(compensation=True, normalize=True, x_size=3., z_size=2., y_min=-.2, y_max=1.4),
# CLEAN stops below its thresholds; the background gate hides the residual.
point_scene(REFLECTORS, noise=.02, seed=1),
# CLEAN stops at SAR_CLEAN_MAX_STEPS; without the gate the residual stays visible.
point_scene(REFLECTORS, noise=.01, seed=3, bg_gate=False),
# The stop at the background of each range (SAR_CLEAN_STOP_RANGE_BG) adds two steps to
# those of the stop at the background of the whole volume.
point_scene([(.2, 0., 2., 1.), (-.5, .1, 4.4, .08)], noise=.01, seed=4, max_depth=5.),
# A narrowed frame: CLEAN still searches the whole scene, so the reflector at X = -0.8 m
# just outside the frame is subtracted instead of its sidelobe becoming an object.
point_scene(REFLECTORS, noise=.02, seed=19, x_size=1.6, z_size=.8),
]
arrays, configurations = {}, []
for index, settings in enumerate(cases):
request = scene()
request.update(settings)
for key in ("min_depth", "max_depth", "speed", "pitch", "ground", "voxel", "power"):
# Match the float32 live settings of the native fixture.
for key in ("min_depth", "max_depth", "speed", "pitch", "ground", "power",
"x_size", "z_size", "y_min", "y_max"):
if key in request:
request[key] = float(np.float32(request[key]))
request["voxel"] = [float(np.float32(step)) for step in request["voxel"]]
if index == 1:
# A separate, unequal capture window per output instead of interleaving.
for member in [*request["previous"], request["latest"]]:
@@ -288,10 +391,13 @@ def generate(notebook: Path, output: Path) -> None:
))
arrays.update({f"case_{index}_{key}": value for key, value in expected.items()})
arrays["cases"] = np.array(json.dumps(configurations, separators=(",", ":")))
marks_request, arrays["marks"] = marks_reference(notebook)
arrays["marks_request"] = np.array(json.dumps(marks_request))
arrays["provenance"] = np.array(json.dumps(dict(
notebook=notebook.name, cell_source_sha256=hashes, cells=[1, 14], cases=len(cases),
notebook=notebook.name, cell_source_sha256=hashes, cells=[1, 5], cases=len(cases),
numpy=np.__version__, scipy=scipy.__version__,
method="Unchanged notebook AST functions; per-frame NumPy previous-mean subtraction; max projections of sar_compute_volume output",
method="Unchanged notebook AST functions; per-frame NumPy previous-mean subtraction; "
"max projections of the sar_prepare_display volume, its background, CLEAN objects and marks",
), sort_keys=True))
output.parent.mkdir(parents=True, exist_ok=True)
np.savez_compressed(output, **arrays)
@@ -300,7 +406,7 @@ def generate(notebook: Path, output: Path) -> None:
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--notebook", type=Path, default=ROOT / "Multi_antenna_demonstrate_depthnorm.ipynb")
parser.add_argument("--notebook", type=Path, default=ROOT / "Multi_depthnorm_clean.ipynb")
parser.add_argument("--output", type=Path, default=REFERENCE)
parser.add_argument("--gpr-script", type=Path, default=ROOT / "Horns_motion_4x4_ground.py")
parser.add_argument("--gpr-output", type=Path, default=REFERENCE.with_name("gpr_reference.npz"))
Binary file not shown.
@@ -210,9 +210,12 @@ void test_configuration(const std::string& seed_path, const Workspace& workspace
}
const auto live_path = workspace.path / "live.json";
std::ofstream(live_path) << Json{{"bscan_gain", 2}, {"settings_revision", 11},
{"sar3d_bg_gate_enabled", false}, {"sar3d_clean_enabled", false},
{"legacy_gpr_mode", false}, {"relative_permittivity", "obsolete"}};
processing::ProcessingLiveConfigLoader loader(live_path.string(), config);
require(loader.refresh_if_needed().bscan_gain == 2, "valid live config did not load");
const auto loaded = loader.refresh_if_needed();
require(loaded.bscan_gain == 2 && !loaded.sar3d_bg_gate_enabled && !loaded.sar3d_clean_enabled,
"valid live config did not load");
const auto revision = loader.revision();
for (const auto& invalid : {Json{{"bscan_gain", 1e100}},
Json{{"gpr_input_positions", {0.0000001}}}, Json{{"bscan_gain", true}},
Binary file not shown.
@@ -2,8 +2,9 @@
Every case runs build/native_tests/backprojection_fixture (BACKPROJECTION_FIXTURE overrides it).
Without a usable GPU the processor publishes sar3d_status 3: then only that
warning is checked and the remaining tests are skipped. The notebook is needed
only to regenerate sar3d_reference.npz with generate_backprojection_reference.py.
warning and the box rules, which are host code, are checked, and the remaining tests
are skipped. The notebook is needed only to regenerate the references with
generate_backprojection_reference.py.
"""
from __future__ import annotations
@@ -11,6 +12,7 @@ import copy
import json
import os
from pathlib import Path
import re
import subprocess
import unittest
@@ -90,22 +92,80 @@ class Sar3dTest(unittest.TestCase):
rtol=2e-6, atol=1e-6)
np.testing.assert_allclose(np.asarray(payload["image"]).reshape(expected.shape),
expected, rtol=2e-3, atol=3e-4)
np.testing.assert_allclose(payloads["sar3d_target_width"]["table"],
self.reference[f"case_{index}_target_width"], rtol=2e-3, atol=2e-4)
widths = self.reference.get(f"case_{index}_target_width")
if widths is None:
self.assertNotIn("sar3d_target_width", payloads)
else:
np.testing.assert_allclose(payloads["sar3d_target_width"]["table"], widths, rtol=2e-3, atol=2e-4)
peak = payloads["sar3d_peak"]["table"]
expected_peak = self.reference[f"case_{index}_peak"]
np.testing.assert_allclose(peak[1:], expected_peak[1:], rtol=0, atol=2e-6)
np.testing.assert_allclose(peak[0], expected_peak[0], rtol=2e-3, atol=0)
self.check_display(index, payloads)
def check_display(self, index: int, payloads: dict[str, dict]) -> None:
"""Background, CLEAN objects and boxes against sar_prepare_display.
Steps at the noise level may pick a neighbouring A-scan sample where two samples are
equal to float32 precision; the reference scenes were checked to keep their decisions
with the kernels compiled without fused multiply-adds.
"""
background = self.reference.get(f"case_{index}_background")
if background is None:
self.assertNotIn("sar3d_background", payloads)
else:
np.testing.assert_allclose(payloads["sar3d_background"]["table"], background, rtol=2e-3, atol=0)
objects = self.reference.get(f"case_{index}_objects")
if objects is None:
self.assertNotIn("sar3d_objects", payloads)
self.assertNotIn("sar3d_marks", payloads)
else:
table = np.reshape(payloads["sar3d_objects"]["table"], (-1, 4))
np.testing.assert_allclose(table[:, :3], objects[:, :3], rtol=0, atol=2e-6)
np.testing.assert_allclose(table[:, 3], objects[:, 3], rtol=2e-3, atol=0)
np.testing.assert_allclose(np.reshape(payloads["sar3d_marks"]["table"], (-1, 7)),
self.reference[f"case_{index}_marks"], rtol=0, atol=2e-6)
clean = self.reference.get(f"case_{index}_clean")
if clean is not None:
message = status(payloads)[1]
match = re.search(r"\((\d+) subtractions?, residual maximum ([\d.]+) backgrounds\)", message)
assert match is not None, message
self.assertEqual(int(match[1]), clean[0])
self.assertAlmostEqual(float(match[2]), clean[1], delta=0.051)
def test_clean_needs_a_one_frame_aperture(self) -> None:
# The actual aperture decides, as in sar_clean_frame: three frames skip CLEAN, while the
# first frame of a run is a one-frame aperture even with SAR_FRAME_RADIUS = 2.
skipped = run(self.scene(clean=True))
code, message = status(skipped)
self.assertEqual(code, 0, message)
self.assertIn("CLEAN skipped: it needs a one-frame aperture, this one has 3 frames", message)
self.assertNotIn("sar3d_objects", skipped)
code, message = status(run(self.scene(clean=True, previous=[])))
self.assertEqual(code, 0, message)
self.assertIn("; CLEAN: ", message)
def test_boxes_follow_the_notebook_rules(self) -> None:
# Host code only, so it runs without a GPU as well.
request = json.loads(self.reference["marks_request"].item())
output = subprocess.run([str(FIXTURE)], input=json.dumps(request), text=True,
capture_output=True, timeout=30, check=True)
np.testing.assert_allclose(json.loads(output.stdout)["marks"], self.reference["marks"], rtol=0, atol=1e-9)
def test_invalid_input_is_an_error_with_a_reason(self) -> None:
def no_traces(request): request["latest"]["traces"] = []
def empty_window(request): request["latest"]["traces"][0]["end"] = request["latest"]["traces"][0]["start"]
def falling_frequency(request): request["latest"]["traces"][0]["frequency"].reverse()
def overflow(request):
for trace in request["latest"]["traces"]:
samples = np.asarray(trace["s21"])
trace["s21"] = (samples / np.abs(samples).max() * 1e38).tolist()
for name, settings, damage in (
("no traces", {}, no_traces),
("empty capture window", {}, empty_window),
("falling frequency", {}, falling_frequency),
("grid beyond 32-bit indexing", {"voxel": 1e-8}, None),
("volume beyond float32", {"previous": [], "radius": 0, "normalize": False, "equalize": False}, overflow),
("grid beyond 32-bit indexing", {"voxel": [1e-8] * 3}, None),
("vertical look angle", {"pitch": 90}, None),
("result larger than the ring slot", {"slot": 1024}, None),
):
@@ -117,6 +177,20 @@ class Sar3dTest(unittest.TestCase):
self.assertEqual(code, 2, message)
self.assertTrue(message)
def test_views_are_normalized_at_the_peak_of_the_reference_planes(self) -> None:
# Fine grids spread every Y plane over all threads of the per-plane maximum.
for voxel in (.05, .06, .07, .08, .09, .11):
with self.subTest(voxel=voxel):
payloads = run(self.scene(voxel=[voxel] * 3))
self.assertEqual(status(payloads)[0], 0, status(payloads))
front = payloads["sar3d_front"]
x, y = np.asarray(front["x"]), np.asarray(front["y"])
image = np.where(((y >= y[0] + .3) & (y <= y[-1] - .3))[:, None],
np.reshape(front["image"], (len(y), len(x))), -1.)
row, column = np.unravel_index(np.argmax(image), image.shape)
self.assertAlmostEqual(float(image[row, column]), 1., places=5)
np.testing.assert_allclose(payloads["sar3d_peak"]["table"][1:3], [x[column], y[row]], rtol=0, atol=1e-6)
def test_cached_aperture_updates_when_previous_mean_context_changes(self) -> None:
request = self.scene(subtract_mean=True, mean_count=1)
shorter = copy.deepcopy(request)
@@ -133,6 +207,7 @@ class Sar3dTest(unittest.TestCase):
payloads = run(request)
self.assertEqual(status(payloads)[0], 1)
self.assertNotIn("sar3d_peak", payloads)
self.assertNotIn("sar3d_background", payloads)
for name in VIEWS:
self.assertFalse(np.any(payloads[name]["image"]))
+22 -8
View File
@@ -287,16 +287,30 @@ GUI публикует настройки через `processing_live.json`; ф
действуют в обоих режимах: среднее предыдущих коллекций вычитается до IFFT.
Плоскость coherent GPR — земля с высотой `gpr_bp_ground_depth_m` и наклоном
`gpr_look_angle_deg`. SAR использует `sar3d_frame_radius` предыдущих кадров и
текущий. Размеры сцены: `sar3d_x_size_m`, `sar3d_z_size_m` (0 — автоматически),
текущий; шаги вокселя по X, Y и Z — `sar3d_voxel_x_m`, `sar3d_voxel_y_m`, `sar3d_voxel_z_m`. Размеры сцены: `sar3d_x_size_m`, `sar3d_z_size_m` (0 — автоматически),
`sar3d_y_min_m`, `sar3d_y_max_m` — наземные координаты, независимо от depth gate.
`sar3d_bg_gate_enabled` (BG_GATE_ENABLE) и `sar3d_clean_enabled` (SAR_CLEAN_ENABLE),
boolean, по умолчанию true: отношение максимума кадра к его фону и CLEAN апертуры
из одного кадра.
SAR публикует `sar3d_target_width` как TableF32 с тремя колонками [X,Y,Z]:
ширины непрерывного отклика на половине максимума по осевым сечениям через
совместный максимум сглаженного объёма (метры, не меньше одного вокселя).
`sar3d_peak` по-прежнему содержит [уровень,X,Y,Z]. GUI использует ширины для
квадратного контура цели. Зум и `SAR_CLOUD_THRESHOLD` — настройки отображения
desktop/веба; в live-файл не входят, ревизию и расчёт не меняют. RSL3 не меняется:
новая таблица использует существующий тип payload.
SAR публикует `sar3d_peak` как TableF32 [уровень нормировки,X,Y,Z] отмеченного
максимума и, если объектов CLEAN нет, `sar3d_target_width` с тремя колонками [X,Y,Z]:
ширины непрерывного отклика на половине максимума по осевым сечениям через этот
максимум сглаженного объёма (метры, не меньше одного вокселя). GUI использует ширины
для квадратного контура цели. Без сигнала (статус 1) этих таблиц нет. Остальные
таблицы TableF32:
- `sar3d_background` — [максимум/фон, BG_GATE_3D_SHOW, BG_GATE_3D_FULL], только при
`sar3d_bg_gate_enabled` и сигнале; множитель шкалы считает GUI с порогом облака.
- `sar3d_objects` — объекты CLEAN, сильнейший первым, строки [X,Y,Z,уровень в фонах].
Если они есть, проекции — остаток в долях максимума кадра с каждым объектом в своей
шкале 0–1, а `sar3d_peak` указывает на первый объект.
- `sar3d_marks` — рамки групп объектов, строки [класс,X0,X1,Y0,Y1,Z0,Z1]; класс 0 —
очень низкий, 1 — на земле, 2 — выше земли, 3 — у границы сцены.
Зум, `SAR_CLOUD_THRESHOLD` и `SAR_MARK_ENABLE` — настройки отображения desktop/веба;
в live-файл не входят, ревизию и расчёт не меняют. RSL3 не меняется: таблицы
используют существующий тип payload.
## 7. Почему поле мощности читается, но не используется
+169 -24
View File
@@ -9,23 +9,33 @@ away from the Y boundaries:
- ``sar3d_oblique``: orthographic projection along the camera of the notebook's
3D scene; ``sar3d_oblique_basis`` (2x3) gives its image axes u and v as scene
directions, which places the scene box, the ground and the maximum on it;
- ``sar3d_target_width``: axial FWHM [X, Y, Z] in metres through the joint peak.
- ``sar3d_peak``: [level, X, Y, Z] of that maximum before normalization.
- ``sar3d_peak``: [level, X, Y, Z] of the marked maximum; level is the normalization.
- ``sar3d_target_width``: axial FWHM [X, Y, Z] in metres through that maximum,
without CLEAN objects.
- ``sar3d_background``: [peak over background, BG_GATE_3D_SHOW, BG_GATE_3D_FULL]
while BG_GATE_ENABLE is on; the frame is scaled by ``bg_gate_gain``.
- ``sar3d_objects``: CLEAN objects, strongest first, rows [X, Y, Z, level in
backgrounds]. The projections then show the residual with every object in its
own 0-1 scale, and the marked maximum is the first object.
- ``sar3d_marks``: boxes around groups of the objects, rows [class, X0, X1, Y0,
Y1, Z0, Z1], classes as in ``SAR3D_MARKS``.
- ``sar3d_status``: [code, UTF-8 bytes...] with ready CUDA (0), no signal (1),
processing error (2), or CUDA unavailable (3), including the explanation.
"""
from __future__ import annotations
from collections.abc import Callable
import itertools
import numpy as np
from numpy.typing import ArrayLike
import pyqtgraph as pg
from PyQt6.QtCore import QRectF, Qt
from PyQt6.QtWidgets import QGridLayout, QLabel, QVBoxLayout, QWidget
from python_app.gui.controllers.renderers.common import (
ANTENNA_STYLES, build_lut, create_plot, image_rect, selected_antenna_coordinates,
ANTENNA_STYLES, OVERLAY_FILL, build_lut, create_plot, image_rect, selected_antenna_coordinates,
)
from python_app.gui.result_payloads import collection_payload_by_name
from python_app.gui.theme import PLOT_FOREGROUND
@@ -36,6 +46,19 @@ from python_app.models.run_config_schema import GprModel
# Status codes published by the sar3d processor in its "sar3d_status" table.
SAR3D_STATES = {0: "ready", 1: "no signal", 2: "error", 3: "warning"}
# Box classes of "sar3d_marks" (notebook _SAR_MARK_STYLE): name, color, dashed. A box at the
# boundary is not yet decidable: part of its response may lie outside the scene.
SAR3D_MARKS: dict[int, tuple[str, tuple[int, int, int], bool]] = {
0: ("very low", (229, 38, 43), False),
1: ("on the ground", (255, 154, 31), False),
2: ("above the ground", (47, 111, 222), False),
3: ("at the boundary", (138, 138, 138), True),
}
# Under the views and inside the captured grid, so the web view shows it too.
_BOX_LEGEND = "Boxes: " + "&nbsp;&nbsp; ".join(
f"<span style='color: rgb{color}'>&#9632;</span>&nbsp;{name}" for name, color, _dashed in SAR3D_MARKS.values())
_GROUND_COLOR = (115, 105, 88)
_SCENE_BOX_COLOR = "#898781"
@@ -47,7 +70,7 @@ SAR3D_VIEWS: dict[str, tuple[str, str, str, tuple[int, int] | None]] = {
"sar3d_oblique": ("3D Projection", "", "", None),
}
# Corners of the scene box indexed by bits (x, y, z); an edge joins corners one bit apart.
# Corners of a box indexed by bits (x, y, z); an edge joins corners one bit apart.
_BOX_EDGES = [(a, b) for a, b in itertools.combinations(range(8), 2) if bin(a ^ b).count("1") == 1]
# Axis names go on the edges meeting at the bottom corner nearest the camera (X max, Y min, Z min).
_AXIS_EDGES = {"X": (0, 1), "Y": (1, 3), "Z": (1, 5)}
@@ -67,6 +90,45 @@ def sar3d_status(collection: ResultCollection) -> tuple[int, str] | None:
return code, bytes(row[1:].astype(np.uint8)).decode("utf-8", errors="replace")
def bg_gate_gain(ratio: float, show: float, full: float, floor: float) -> float:
"""Scale of a frame by its peak over background (notebook bg_gate_gain).
Below ``show`` the frame is empty; at ``show`` its maximum lands on ``floor``, the
cloud threshold, so an object appears as a point; from ``full`` the scale is full.
"""
if ratio < show:
return 0.0
if full <= show:
return 1.0
return float(np.clip(floor + (1.0 - floor) * (ratio - show) / (full - show), floor, 1.0))
Projection = Callable[[ArrayLike], np.ndarray]
def _projection(axes: tuple[int, int] | None, basis: np.ndarray) -> Projection:
"""Scene points -> plot coordinates of a view: two scene axes, or the oblique image axes."""
if axes is None:
return lambda points: np.asarray(points, dtype=float) @ basis.T
return lambda points: np.asarray(points, dtype=float)[..., list(axes)]
def _box_corners(bounds: np.ndarray) -> np.ndarray:
"""Eight corners of the box [X0, X1, Y0, Y1, Z0, Z1], indexed by bits (x, y, z)."""
return np.array([[bounds[2*axis + ((index >> axis) & 1)] for axis in range(3)] for index in range(8)])
def _table(collection: ResultCollection, name: str, columns: int) -> np.ndarray:
"""Rows of a table payload; none when it is absent."""
payload = collection_payload_by_name(collection, name, kind=4)
if payload is None:
return np.empty((0, columns))
table = np.asarray(payload.table, dtype=float)
if table.ndim != 2 or table.shape[1] != columns:
raise ValueError(f"{name} must have {columns} columns")
return table
class Sar3dRenderer:
"""Draws the 3D SAR views."""
@@ -91,6 +153,9 @@ class Sar3dRenderer:
self.antennas: dict[str, tuple[pg.ScatterPlotItem, pg.ScatterPlotItem]] = {}
self.images: dict[str, pg.ImageItem] = {}
self.targets: dict[str, pg.PlotCurveItem] = {}
self.marks: dict[str, dict[int, pg.PlotCurveItem]] = {}
self.objects: dict[str, pg.ScatterPlotItem] = {}
self.object_labels: dict[str, list[pg.TextItem]] = {}
for index, (name, (title, x_label, y_label, axes)) in enumerate(SAR3D_VIEWS.items()):
plot = create_plot(title)
grid_layout.addWidget(plot, index // 2, index % 2)
@@ -116,6 +181,21 @@ class Sar3dRenderer:
plot.addItem(peak)
self.images[name] = image
self.targets[name] = peak
self.marks[name] = {}
for kind, (_name, color, dashed) in SAR3D_MARKS.items():
style = Qt.PenStyle.DashLine if dashed else Qt.PenStyle.SolidLine
# A dark outline keeps the box visible over pixels of its own colour.
curve = pg.PlotCurveItem(pen=pg.mkPen(color, width=2, style=style), connect="pairs",
shadowPen=pg.mkPen(PLOT_FOREGROUND, width=4, style=style))
curve.setZValue(3)
plot.addItem(curve)
self.marks[name][kind] = curve
objects = pg.ScatterPlotItem(symbol="d", size=11, brush=pg.mkBrush("#ffffff"),
pen=pg.mkPen("#111111", width=1.5))
objects.setZValue(5)
plot.addItem(objects)
self.objects[name] = objects
self.object_labels[name] = []
if x_label:
plot.setLabel("bottom", x_label, units="m")
plot.setLabel("left", y_label, units="m")
@@ -142,6 +222,11 @@ class Sar3dRenderer:
label = pg.TextItem(axis_name, color=PLOT_FOREGROUND, anchor=(0.5, 0.5))
oblique.addItem(label)
self.axis_labels[axis_name] = label
self.box_legend = QLabel(_BOX_LEGEND)
self.box_legend.setTextFormat(Qt.TextFormat.RichText)
self.box_legend.setWordWrap(True)
self.box_legend.hide()
grid_layout.addWidget(self.box_legend, 2, 0, 1, 2)
self.clear()
def clear(self, message: str = "SAR3D: waiting for measurements") -> None:
@@ -153,12 +238,45 @@ class Sar3dRenderer:
for markers in self.antennas.values():
for marker in markers:
marker.setData([], [])
self._draw_marks(np.empty((0, 7)), {})
self._draw_objects(np.empty((0, 4)), {})
self.box.setData([], [])
self.ground.setData([], [])
for label in self.axis_labels.values():
label.hide()
self.status_label.setText(message)
def _draw_marks(self, boxes: np.ndarray, projections: dict[str, Projection]) -> None:
"""Boxes around the CLEAN object groups, one curve per class and view."""
for view, curves in self.marks.items():
for kind, curve in curves.items():
rows = boxes[boxes[:, 0] == kind, 1:]
if not len(rows) or view not in projections:
curve.setData([], [])
continue
points = projections[view](np.concatenate([_box_corners(row)[np.ravel(_BOX_EDGES)] for row in rows]))
curve.setData(points[:, 0], points[:, 1])
def _draw_objects(self, objects: np.ndarray, projections: dict[str, Projection]) -> None:
"""Numbered CLEAN objects with their level in frame backgrounds."""
for name, markers in self.objects.items():
plot = self.plots[name]
for label in self.object_labels[name]:
plot.removeItem(label)
self.object_labels[name].clear()
if not len(objects) or name not in projections:
markers.setData([], [])
continue
points = projections[name](objects[:, :3])
markers.setData(points[:, 0], points[:, 1])
for number, ((x, y), level) in enumerate(zip(points, objects[:, 3]), start=1):
label = pg.TextItem(f"{number}: {level:.0f} bg", color=PLOT_FOREGROUND,
fill=pg.mkBrush(*OVERLAY_FILL), anchor=(0.5, 1.2))
label.setZValue(6)
label.setPos(float(x), float(y))
plot.addItem(label)
self.object_labels[name].append(label)
def draw(self, collection: ResultCollection, settings: GuiSar3dStateModel, geometry: GprModel) -> bool:
"""Draw four maximum projections of the volume; return success."""
status = sar3d_status(collection)
@@ -178,6 +296,22 @@ class Sar3dRenderer:
return False
peak = collection_payload_by_name(collection, "sar3d_peak", kind=4)
level, *peak_xyz = peak.table[0] if peak is not None else (None,)
objects = _table(collection, "sar3d_objects", 4)
marks = _table(collection, "sar3d_marks", 7)
# Frame relative to its background; CLEAN objects are each in their own scale already.
background = _table(collection, "sar3d_background", 3)
gain = 1.0
background_note = ""
if len(background):
ratio, appear, full = (float(value) for value in background[0])
background_note = f"peak {ratio:.1f} backgrounds"
if not len(objects):
gain = bg_gate_gain(ratio, appear, full, settings.cloud_threshold)
state = ("below the appearance level, frame empty" if gain <= 0.0
else "full scale" if gain >= 1.0 else f"scale ×{gain:.2f}")
background_note += f" (appears from {appear:g}, full scale from {full:g}): {state}"
# Objects replace the peak marker, and so does a frame below the appearance level.
show_peak = not len(objects) and gain > 0.0
antennas = selected_antenna_coordinates(geometry, settings.input_positions, settings.output_positions)
pitch = np.radians(settings.look_angle_deg)
@@ -186,48 +320,51 @@ class Sar3dRenderer:
coordinates[:, 1] = settings.bp_ground_depth_m + y*np.cos(pitch) - z*np.sin(pitch)
coordinates[:, 2] = y*np.sin(pitch) + z*np.cos(pitch)
width_payload = collection_payload_by_name(collection, "sar3d_target_width", kind=4)
widths = width_payload.table[0] if width_payload is not None else np.full(3, settings.voxel_m)
steps = np.array([settings.voxel_x_m, settings.voxel_y_m, settings.voxel_z_m])
widths = width_payload.table[0] if width_payload is not None else steps
zoom = settings.zoom_percent / 100.0
projections = {name: _projection(axes, basis.table) for name, (*_labels, axes) in SAR3D_VIEWS.items()}
for name, payload in payloads.items():
title, _x_label, _y_label, axes = SAR3D_VIEWS[name]
rect = image_rect(payload.image_x_axis, payload.image_y_axis)
image = self.images[name]
image.setImage(np.where(payload.image >= settings.cloud_threshold, payload.image, 0),
autoLevels=False, levels=(0, 1))
shown = payload.image * gain
image.setImage(np.where(shown >= settings.cloud_threshold, shown, 0), autoLevels=False, levels=(0, 1))
image.setRect(rect)
image.show()
plot = self.plots[name]
position = (basis.table @ np.asarray(peak_xyz) if axes is None else np.asarray(peak_xyz)[list(axes)]) if peak_xyz else None
position = projections[name](peak_xyz) if peak_xyz else None
if axes is not None:
for marker, coordinates, (symbol, brush, pen) in zip(self.antennas[name], antennas, ANTENNA_STYLES):
xx, yy = coordinates[:, axes[0]], coordinates[:, axes[1]]
marker.setData(x=xx, y=yy, symbol=symbol, size=13, brush=pg.mkBrush(brush), pen=pg.mkPen(pen))
if len(xx):
pad = settings.voxel_m
pad = steps.max()
rect = rect.united(QRectF(xx.min()-pad, yy.min()-pad, np.ptp(xx)+2*pad, np.ptp(yy)+2*pad))
if position is not None:
if position is not None and show_peak:
extent = np.abs(basis.table) @ widths if axes is None else widths[list(axes)]
half = .5 * max(extent)
self.targets[name].setData(position[0]+np.array([-half, half, half, -half, -half]),
position[1]+np.array([-half, -half, half, half, -half]))
if zoom > 1.0:
else:
self.targets[name].setData([], [])
if position is not None and zoom > 1.0:
center = position + (np.array([rect.center().x(), rect.center().y()])-position) / zoom**2
rect = QRectF(center[0]-rect.width()/(2*zoom), center[1]-rect.height()/(2*zoom),
rect.width()/zoom, rect.height()/zoom)
else:
self.targets[name].setData([], [])
plot.setRange(rect, padding=0.04)
plot.setTitle(title)
boxes = marks if settings.mark_enabled else np.empty((0, 7))
self._draw_marks(boxes, projections)
# The legend follows the settings rather than each frame, so the views keep their size.
self.box_legend.setVisible(settings.mark_enabled and settings.clean_enabled)
self._draw_objects(np.empty((0, 4)) if settings.mark_enabled else objects, projections)
# Scene box and ground on the oblique view, from the extents of the other views.
front, top = payloads["sar3d_front"], payloads["sar3d_top"]
bounds = [
(front.image_x_axis[0], front.image_x_axis[-1]),
(front.image_y_axis[0], front.image_y_axis[-1]),
(top.image_y_axis[0], top.image_y_axis[-1]),
]
corners = np.array([[bounds[axis][(index >> axis) & 1] for axis in range(3)] for index in range(8)])
projected = corners @ basis.table.T
bounds = np.array([front.image_x_axis[0], front.image_x_axis[-1], front.image_y_axis[0],
front.image_y_axis[-1], top.image_y_axis[0], top.image_y_axis[-1]])
projected = _box_corners(bounds) @ basis.table.T
box = projected[np.ravel(_BOX_EDGES)]
self.box.setData(box[:, 0], box[:, 1])
centre = projected.mean(axis=0)
@@ -235,12 +372,20 @@ class Sar3dRenderer:
middle = (projected[start] + projected[end]) / 2
self.axis_labels[axis_name].setPos(*(middle + 0.08 * (middle - centre))) # just outside
self.axis_labels[axis_name].show()
(x0, x1), _, (z0, z1) = bounds
x0, x1, _y0, _y1, z0, z1 = bounds
ground = (np.array([[x0, 0.0, z0], [x1, 0.0, z0], [x1, 0.0, z1], [x0, 0.0, z1], [x0, 0.0, z0]]) @ basis.table.T).T
self.ground.setData(ground[0], ground[1])
summary = f"SAR3D: ready — {status[1]}" if status else "SAR3D: ready"
notes = [f"SAR3D: ready — {status[1]}" if status else "SAR3D: ready"]
if background_note:
notes.append(background_note)
if len(boxes):
counts = {kind: int(np.sum(boxes[:, 0] == kind)) for kind in SAR3D_MARKS}
notes.append("boxes: " + ", ".join(f"{counts[kind]} {name}" for kind, (name, *_style)
in SAR3D_MARKS.items() if kind != 3 or counts[kind]))
if level is not None:
x, y, z = peak_xyz
summary += f" | max X {x:+.2f}, Y {y:+.2f}, Z {z:.2f} m | level {level:.3g}"
self.status_label.setText(f"{summary} | zoom {settings.zoom_percent}%")
notes.append(f"max X {x:+.2f}, Y {y:+.2f}, Z {z:.2f} m | level {level:.3g}")
notes.append(f"zoom {settings.zoom_percent}%")
self.status_label.setText(" | ".join(notes))
return True
@@ -214,7 +214,7 @@ def build_processing_group(config: RunConfigModel, gui: GuiStateModel) -> Proces
)
processing_mode_pages.addWidget(gpr_page)
# 3D SAR page. The labels are the names of the reference notebook
# Multi_antenna_demonstrate_depthnorm.ipynb; the shared gpr_* fields get their own
# Multi_depthnorm_clean.ipynb; the shared gpr_* fields get their own
# widgets here, like on the GPR page.
sar3d_input_positions_input = view.add("_sar3d_input_positions_input", QLineEdit())
sar3d_input_positions_input.setPlaceholderText("0,1,2")
@@ -226,11 +226,12 @@ def build_processing_group(config: RunConfigModel, gui: GuiStateModel) -> Proces
sar3d_min_depth_m.setToolTip("Radar depth gate start (m); the scene grid along Z follows the gate.")
sar3d_max_depth_m = view.add("_sar3d_max_depth_m", double_spin(0.1, 50.0, 2, 0.1))
sar3d_max_depth_m.setToolTip("Radar depth gate end (m); the scene grid along Z follows the gate.")
sar3d_voxel_m = view.add("_sar3d_voxel_m", double_spin(0.01, 0.1, 3, 0.005))
sar3d_voxel_m.setToolTip(
"Edge of the cubic voxel (m), the same along X, Y and Z. Smaller is finer and slower: "
"halving it makes 8 times more voxels."
)
sar3d_voxel_x_m = view.add("_sar3d_voxel_x_m", double_spin(0.01, 0.1, 3, 0.005))
sar3d_voxel_y_m = view.add("_sar3d_voxel_y_m", double_spin(0.01, 0.1, 3, 0.005))
sar3d_voxel_z_m = view.add("_sar3d_voxel_z_m", double_spin(0.01, 0.1, 3, 0.005))
for axis, step in zip("XYZ", (sar3d_voxel_x_m, sar3d_voxel_y_m, sar3d_voxel_z_m)):
step.setToolTip(f"Voxel step along {axis} (m). Smaller is finer and slower: halving all three "
"steps makes 8 times more voxels.")
sar3d_look_angle_deg = view.add("_sar3d_look_angle_deg", double_spin(-90.0, 90.0, 2, 0.5))
sar3d_look_angle_deg.setToolTip(
"Downward tilt of the radar (deg): motion correction along range and the ground scene axes."
@@ -253,6 +254,17 @@ def build_processing_group(config: RunConfigModel, gui: GuiStateModel) -> Proces
sar3d_pair_depth_equalize = view.add("_sar3d_pair_depth_equalize", QCheckBox("PAIR_DEPTH_EQUALIZE"))
sar3d_background_subtract_enabled = view.add("_sar3d_background_subtract_enabled", QCheckBox("Subtract mean of previous collections"))
sar3d_background_mean_count = view.add("_sar3d_background_mean_count", integer_spin(0, 10000))
sar3d_bg_gate_enabled = view.add("_sar3d_bg_gate_enabled", QCheckBox("BG_GATE_ENABLE"))
sar3d_bg_gate_enabled.setToolTip(
"Show the frame relative to its background, the median of the volume: below 6 backgrounds the "
"frame is empty, at 6 its maximum appears at SAR_CLOUD_THRESHOLD, from 8.5 the scale is full."
)
sar3d_clean_enabled = view.add("_sar3d_clean_enabled", QCheckBox("SAR_CLEAN_ENABLE"))
sar3d_clean_enabled.setToolTip(
"CLEAN: subtract the strongest responses one by one, so weak objects show beside strong ones; "
"each object is drawn in its own 0-1 scale. Runs on a one-frame aperture: always with "
"SAR_FRAME_RADIUS = 0, otherwise while no previous frame is available, as after Start."
)
sar3d_x_size_m = view.add("_sar3d_x_size_m", double_spin(0.0, 100.0, 2, 0.1))
sar3d_y_min_m = view.add("_sar3d_y_min_m", double_spin(-50.0, 50.0, 2, 0.1))
sar3d_y_max_m = view.add("_sar3d_y_max_m", double_spin(-50.0, 50.0, 2, 0.1))
@@ -261,6 +273,11 @@ def build_processing_group(config: RunConfigModel, gui: GuiStateModel) -> Proces
sar3d_zoom_percent = view.add("_sar3d_zoom_percent", QSlider(Qt.Orientation.Horizontal))
sar3d_zoom_percent.setRange(100, 1000)
sar3d_zoom_percent.setToolTip("100% shows the full scene; higher values zoom towards its brightest object.")
sar3d_mark_enabled = view.add("_sar3d_mark_enabled", QCheckBox("SAR_MARK_ENABLE"))
sar3d_mark_enabled.setToolTip(
"Box the CLEAN objects by height: red very low (like a mine), orange on the ground, blue above it, "
"dashed grey at the scene boundary (not yet decidable). Off: numbered objects instead."
)
sar3d_page = _build_processing_mode_page(
processing_mode_pages,
@@ -271,7 +288,9 @@ def build_processing_group(config: RunConfigModel, gui: GuiStateModel) -> Proces
("F_STOP MHz", sar3d_stop_freq_mhz),
("MIN_DEPTH", sar3d_min_depth_m),
("MAX_DEPTH", sar3d_max_depth_m),
("SAR_VOXEL_M", sar3d_voxel_m),
("SAR_VOXEL_M X", sar3d_voxel_x_m),
("SAR_VOXEL_M Y", sar3d_voxel_y_m),
("SAR_VOXEL_M Z", sar3d_voxel_z_m),
("LOOK_ANGLE_DEG", sar3d_look_angle_deg),
sar3d_ignore_socket_speed_enabled,
("SPEED_M_S", sar3d_speed_m_s),
@@ -283,14 +302,16 @@ def build_processing_group(config: RunConfigModel, gui: GuiStateModel) -> Proces
sar3d_pair_depth_equalize,
sar3d_background_subtract_enabled,
("Mean count", sar3d_background_mean_count),
sar3d_bg_gate_enabled,
sar3d_clean_enabled,
("SAR_X_SIZE_M (0 = auto)", sar3d_x_size_m),
("SAR_Y_RANGE_M min", sar3d_y_min_m),
("SAR_Y_RANGE_M max", sar3d_y_max_m),
("SAR_Z_SIZE_M (0 = auto)", sar3d_z_size_m),
("SAR_CLOUD_THRESHOLD", sar3d_cloud_threshold),
("Zoom (%)", sar3d_zoom_percent),
sar3d_mark_enabled,
],
split_index=8,
)
processing_mode_pages.addWidget(sar3d_page)
@@ -320,7 +341,7 @@ def build_processing_group(config: RunConfigModel, gui: GuiStateModel) -> Proces
trace_controls=(show_magnitude_checkbox, show_phase_checkbox, unwrap_phase_checkbox,
pass_through_combo_filter_input, pass_through_fixed_y_enabled,
pass_through_y_min_db, pass_through_y_max_db),
visual_controls=(gpr_render_mode, sar3d_cloud_threshold, sar3d_zoom_percent),
visual_controls=(gpr_render_mode, sar3d_cloud_threshold, sar3d_zoom_percent, sar3d_mark_enabled),
locator_controls=(gpr_max_detected_objects_to_draw, gpr_draw_top_m_objects,
gpr_visible_x_min_m, gpr_visible_x_max_m,
gpr_visible_z_min_m, gpr_visible_z_max_m),
+7 -2
View File
@@ -134,7 +134,7 @@ class GuiGprStateModel:
class GuiSar3dStateModel:
"""UI-only defaults for the 3D SAR (sar3d) live settings.
Values are the reference notebook's (Multi_antenna_demonstrate_depthnorm.ipynb), whose
Values are the reference notebook's (Multi_depthnorm_clean.ipynb), whose
names the GUI shows: INPUT_IDX, F_START, MIN_DEPTH, LOOK_ANGLE_DEG, ...
"""
@@ -148,7 +148,9 @@ class GuiSar3dStateModel:
ignore_socket_speed_enabled: bool = setting(False, '_sar3d_ignore_socket_speed_enabled', live='ignore_socket_speed', group='Motion')
look_angle_deg: float = setting(19.5, '_sar3d_look_angle_deg', live='gpr_look_angle_deg', group='Motion')
frame_radius: int = setting(0, '_sar3d_frame_radius', live='sar3d_frame_radius', group='3D SAR', minimum=0)
voxel_m: float = setting(0.02, '_sar3d_voxel_m', live='sar3d_voxel_m', group='3D SAR', minimum=0, exclusive_minimum=True)
voxel_x_m: float = setting(0.025, '_sar3d_voxel_x_m', live='sar3d_voxel_x_m', group='3D SAR', minimum=0, exclusive_minimum=True)
voxel_y_m: float = setting(0.02, '_sar3d_voxel_y_m', live='sar3d_voxel_y_m', group='3D SAR', minimum=0, exclusive_minimum=True)
voxel_z_m: float = setting(0.02, '_sar3d_voxel_z_m', live='sar3d_voxel_z_m', group='3D SAR', minimum=0, exclusive_minimum=True)
bp_ground_depth_m: float = setting(1.25, '_sar3d_bp_ground_depth_m', live='sar3d_bp_ground_depth_m', group='3D SAR')
comp_range_power: float = setting(0.3, '_sar3d_comp_range_power', live='sar3d_comp_range_power', group='3D SAR')
use_compensation: bool = setting(True, '_sar3d_use_compensation', live='sar3d_use_compensation', group='3D SAR')
@@ -156,12 +158,15 @@ class GuiSar3dStateModel:
pair_depth_equalize: bool = setting(True, '_sar3d_pair_depth_equalize', live='pair_depth_equalize', group='3D SAR')
background_subtract_enabled: bool = setting(True, '_sar3d_background_subtract_enabled', live='gpr_background_subtract_enabled', group='3D SAR')
background_mean_count: int = setting(10, '_sar3d_background_mean_count', live='gpr_background_mean_count', group='3D SAR', minimum=0)
bg_gate_enabled: bool = setting(True, '_sar3d_bg_gate_enabled', live='sar3d_bg_gate_enabled', group='3D SAR')
clean_enabled: bool = setting(True, '_sar3d_clean_enabled', live='sar3d_clean_enabled', group='3D SAR')
x_size_m: float = setting(0.0, '_sar3d_x_size_m', live='sar3d_x_size_m', group='Scene', minimum=0)
y_min_m: float = setting(-0.5, '_sar3d_y_min_m', live='sar3d_y_min_m', group='Scene')
y_max_m: float = setting(2.0, '_sar3d_y_max_m', live='sar3d_y_max_m', group='Scene')
z_size_m: float = setting(0.0, '_sar3d_z_size_m', live='sar3d_z_size_m', group='Scene', minimum=0)
cloud_threshold: float = setting(0.65, '_sar3d_cloud_threshold', web='sar3d_cloud_threshold', group='Display', minimum=0, maximum=1)
zoom_percent: int = setting(100, '_sar3d_zoom_percent', web='sar3d_zoom_percent', group='Display', minimum=100, maximum=1000)
mark_enabled: bool = setting(True, '_sar3d_mark_enabled', web='sar3d_mark_enabled', group='Display')
@dataclass(slots=True)
@@ -53,11 +53,13 @@ class ProcessingLiveConfig:
gpr_visible_z_min_m: float = 0.0
gpr_visible_z_max_m: float = 14.0
# 3D SAR (sar3d processor) parameters, named after the reference notebook
# Multi_antenna_demonstrate_depthnorm.ipynb (SAR_FRAME_RADIUS, BP_GROUND_DEPTH_M, ...).
# Multi_depthnorm_clean.ipynb (SAR_FRAME_RADIUS, BP_GROUND_DEPTH_M, ...).
# The band, depth gate, antenna selection, speed and look angle are the
# shared gpr_* fields.
sar3d_frame_radius: int = 0
sar3d_voxel_m: float = 0.02 # SAR_VOXEL_M, one edge for X, Y and Z
sar3d_voxel_x_m: float = 0.025 # SAR_VOXEL_M, the voxel steps along X, Y and Z
sar3d_voxel_y_m: float = 0.02
sar3d_voxel_z_m: float = 0.02
sar3d_bp_ground_depth_m: float = 1.25
sar3d_comp_range_power: float = 0.3
sar3d_use_compensation: bool = True
@@ -66,6 +68,8 @@ class ProcessingLiveConfig:
sar3d_y_min_m: float = -0.5
sar3d_y_max_m: float = 2.0
sar3d_z_size_m: float = 0.0
sar3d_bg_gate_enabled: bool = True # BG_GATE_ENABLE: peak-to-background ratio for the display
sar3d_clean_enabled: bool = True # SAR_CLEAN_ENABLE: CLEAN of a one-frame aperture into objects
# When true, the C++ data_processor ignores socket-supplied vlc updates
# and keeps using `gpr_speed_m_s` from this file.
ignore_socket_speed: bool = False
+3 -1
View File
@@ -82,7 +82,9 @@ class GuiProfileCodecTest(unittest.TestCase):
ignore_socket_speed_enabled=True,
look_angle_deg=6.5,
frame_radius=3,
voxel_m=0.035,
voxel_x_m=0.035,
voxel_y_m=0.03,
voxel_z_m=0.025,
bp_ground_depth_m=1.1,
comp_range_power=0.5,
use_compensation=False,
+1 -1
View File
@@ -382,7 +382,7 @@ class Sar3dRecoveryTest(_ProcessorTestCase):
self._live.gpr_speed_m_s = 0.0
self._live.gpr_start_freq_mhz = 1500.0
self._live.gpr_stop_freq_mhz = 6000.0
self._live.sar3d_voxel_m = 0.4
self._live.sar3d_voxel_x_m = self._live.sar3d_voxel_y_m = self._live.sar3d_voxel_z_m = 0.4
self._live.sar3d_frame_radius = 0
self._live.tx_geometry = [{"output_pos": 0, "x_m": -0.3, "y_m": 0.0, "z_m": 0.0}]
self._live.rx_geometry = [{"input_pos": 0, "x_m": 0.2, "y_m": 0.4, "z_m": 0.0}]
+98 -1
View File
@@ -2,17 +2,19 @@
from __future__ import annotations
import json
import os
import unittest
from unittest import mock
import numpy as np
import pyqtgraph as pg
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PyQt6.QtCore import QPointF, QRectF, Qt, QSignalBlocker # noqa: E402
from PyQt6.QtWidgets import QApplication, QCheckBox, QComboBox, QDoubleSpinBox, QSlider # noqa: E402
from python_app.gui.controllers.renderers.sar3d import SAR3D_STATES, SAR3D_VIEWS, Sar3dRenderer # noqa: E402
from python_app.gui.controllers.renderers.sar3d import SAR3D_MARKS, SAR3D_STATES, SAR3D_VIEWS, Sar3dRenderer # noqa: E402
from python_app.models.dataset_model import ComboKey, ResultCollection, ResultPayload, TraceData # noqa: E402
from python_app.models.gui_profile_schema import GuiSar3dStateModel # noqa: E402
from python_app.models.run_config_schema import GprModel, GprTxGeometryModel, GprRxGeometryModel # noqa: E402
@@ -29,6 +31,17 @@ def _collection(*payloads: ResultPayload) -> ResultCollection:
capture_start_ns=1200, capture_end_ns=1400)
def _points(item: pg.PlotCurveItem | pg.ScatterPlotItem) -> tuple[np.ndarray, np.ndarray]:
"""Drawn x and y of a curve or scatter item, empty when nothing is drawn."""
xx, yy = item.getData()
assert xx is not None and yy is not None
return np.asarray(xx), np.asarray(yy)
def _table(name: str, *rows: list[float]) -> ResultPayload:
return ResultPayload(processing_name=name, kind=4, table=np.array(rows, dtype=np.float32))
def _projections() -> ResultCollection:
x, y, z = np.array([-1, 0, 1]), np.array([-0.5, 0.5]), np.array([1, 2, 3])
axes = [(x, y), (z, y), (x, z), (x, y)]
@@ -129,6 +142,68 @@ class Sar3dPlotTest(unittest.TestCase):
peak = np.array([.5*(xx.min()+xx.max()), .5*(yy.min()+yy.max())])
self.assertLess(np.linalg.norm(zoomed.mean(axis=1)-peak), np.linalg.norm(ranges.mean(axis=1)-peak))
def test_bg_gate_scales_the_frame_and_hides_it_below_appearance(self) -> None:
self.settings.cloud_threshold = .5
for ratio, gain, state in ((9., 1., "full scale"), (7.5, .8, "scale ×0.80"),
(5., 0., "below the appearance level, frame empty")):
with self.subTest(ratio=ratio):
result = _projections()
result.collection_payloads.append(_table("sar3d_background", [ratio, 6, 8.5]))
self.assertTrue(self.renderer.draw(result, self.settings, self.geometry))
for name, image in self.renderer.images.items():
np.testing.assert_allclose(np.asarray(image.image), gain, err_msg=name)
self.assertEqual(len(_points(self.renderer.targets[name])[0]) > 0, gain > 0, name)
self.assertIn(f"peak {ratio:.1f} backgrounds (appears from 6, full scale from 8.5): {state}",
self.renderer.status_label.text())
def test_clean_objects_are_boxed_by_class_or_numbered(self) -> None:
result = _projections()
result.collection_payloads += [
_table("sar3d_background", [7.5, 6, 8.5]),
_table("sar3d_objects", [.5, -.2, 2., 45.], [-.5, .3, 1.5, 12.]),
_table("sar3d_marks", [0, .25, .75, -.3, .1, 1.75, 2.25], [2, -.75, -.25, .1, .5, 1.25, 1.75]),
]
self.assertTrue(self.renderer.draw(result, self.settings, self.geometry))
text = self.renderer.status_label.text()
self.assertIn("peak 7.5 backgrounds | boxes: 1 very low, 0 on the ground, 1 above the ground |", text)
self.assertNotIn("at the boundary", text)
for name, (_title, _x, _y, axes) in SAR3D_VIEWS.items():
# Objects are in their own scale: no background gain and no peak marker.
np.testing.assert_allclose(np.asarray(self.renderer.images[name].image), 1)
self.assertFalse(len(_points(self.renderer.targets[name])[0]))
self.assertFalse(len(_points(self.renderer.objects[name])[0]))
curves = self.renderer.marks[name]
self.assertFalse(len(_points(curves[1])[0]) or len(_points(curves[3])[0]))
plane = axes or (0, 1) # the test basis shows the oblique view along X and Y
for kind, low, high in ((0, (.25, -.3, 1.75), (.75, .1, 2.25)), (2, (-.75, .1, 1.25), (-.25, .5, 1.75))):
xx, yy = _points(curves[kind])
self.assertEqual(len(xx), 24)
np.testing.assert_allclose([xx.min(), yy.min()], np.asarray(low)[list(plane)])
np.testing.assert_allclose([xx.max(), yy.max()], np.asarray(high)[list(plane)])
# The legend lists every class under the views while boxes are on.
self.assertFalse(self.renderer.box_legend.isHidden())
self.assertTrue(all(name in self.renderer.box_legend.text() for name, *_style in SAR3D_MARKS.values()))
self.settings.mark_enabled = False
self.renderer.draw(result, self.settings, self.geometry)
self.assertTrue(self.renderer.box_legend.isHidden())
self.assertNotIn("boxes:", self.renderer.status_label.text())
for name, (_title, _x, _y, axes) in SAR3D_VIEWS.items():
self.assertTrue(all(not len(_points(curve)[0]) for curve in self.renderer.marks[name].values()))
xx, yy = _points(self.renderer.objects[name])
plane = list(axes or (0, 1))
np.testing.assert_allclose(np.column_stack([xx, yy]), np.array([[.5, -.2, 2.], [-.5, .3, 1.5]])[:, plane])
self.assertEqual([label.textItem.toPlainText() for label in self.renderer.object_labels[name]],
["1: 45 bg", "2: 12 bg"])
self.renderer.clear()
self.assertFalse(any(self.renderer.object_labels.values()))
def test_table_with_another_width_is_an_error(self) -> None:
result = _projections()
result.collection_payloads.append(_table("sar3d_objects", [.5, -.2, 2.]))
with self.assertRaisesRegex(ValueError, "sar3d_objects must have 4 columns"):
self.renderer.draw(result, self.settings, self.geometry)
def test_cloud_threshold_masks_weak_response_without_changing_levels(self) -> None:
result = _projections()
for payload in result.collection_payloads:
@@ -167,13 +242,35 @@ class Sar3dIntegrationTest(unittest.TestCase):
widget(self.view._view.processing, "_sar3d_cloud_threshold", QDoubleSpinBox).setValue(1.)
self.assertEqual(self.view._live.revision, revision)
self.assertIn("zoom 247%", self.renderer.status_label.text())
widget(self.view._view.processing, "_sar3d_mark_enabled", QCheckBox).setChecked(False)
self.assertEqual(self.view._live.revision, revision)
live = self.view._live.build().to_dict()
self.assertNotIn("sar3d_zoom_percent", live)
self.assertNotIn("sar3d_cloud_threshold", live)
self.assertNotIn("sar3d_mark_enabled", live)
schema = {entry["name"]: entry for entry in self.view._config.web_schema()}
self.assertEqual(schema["sar3d_zoom_percent"]["value"], 247)
self.assertEqual(schema["sar3d_zoom_percent"]["step"], 1)
self.assertEqual(schema["sar3d_cloud_threshold"]["value"], 1.)
self.assertIs(schema["sar3d_mark_enabled"]["value"], False)
def test_bg_gate_and_clean_publish_a_processing_revision(self) -> None:
self.view._pipeline.drain_results = mock.Mock()
live = self.view._live.build().to_dict()
self.assertIs(live["sar3d_bg_gate_enabled"], True)
self.assertIs(live["sar3d_clean_enabled"], True)
for name, key in (("_sar3d_bg_gate_enabled", "sar3d_bg_gate_enabled"),
("_sar3d_clean_enabled", "sar3d_clean_enabled")):
with self.subTest(key):
revision = self.view._live.revision
widget(self.view._view.processing, name, QCheckBox).setChecked(False)
self.assertNotEqual(self.view._live.revision, revision)
published = json.loads(self.view._live.writer.path.read_text(encoding="utf-8"))
self.assertIs(published[key], False)
self.assertEqual(published["settings_revision"], self.view._live.revision)
schema = {entry["name"]: entry for entry in self.view._config.web_schema()}
self.assertEqual(schema["sar3d_bg_gate_enabled"]["label"], "BG_GATE_ENABLE")
self.assertEqual(schema["sar3d_clean_enabled"]["label"], "SAR_CLEAN_ENABLE")
def test_inactive_sar_scene_is_validated_before_publishing_gpr_settings(self) -> None:
with QSignalBlocker(self.mode):
@@ -336,7 +336,6 @@
"ignore_socket_speed_enabled": false,
"look_angle_deg": 10.0,
"frame_radius": 0,
"voxel_m": 0.02,
"bp_ground_depth_m": 1.25,
"comp_range_power": 0.3,
"use_compensation": true,