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:
@@ -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}'>■</span> {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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}]
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user