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
@@ -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"]))