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
+170 -25
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: " + "   ".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:
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([], [])
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)
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