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:
@@ -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"]))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user