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
+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):