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