"""Independent 3D SAR views of the volume published by the sar3d processor. Scene axes: X across, Y height above the ground (Y = 0 is the ground), Z along the ground away from the radar. Payloads share normalization by the maximum away from the Y boundaries: - ``sar3d_front``, ``sar3d_side``, ``sar3d_top``: maximum projections onto X-Y (looking ahead from the radar), Z-Y (from the side) and X-Z (from above); - ``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_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, 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 from python_app.models.dataset_model import ResultCollection, ResultPayload from python_app.models.gui_profile_schema import GuiSar3dStateModel 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"■ {name}" for name, color, _dashed in SAR3D_MARKS.values()) _GROUND_COLOR = (115, 105, 88) _SCENE_BOX_COLOR = "#898781" # payload -> (title, x label, y label, scene axes (0 = X, 1 = Y, 2 = Z) along the plot axes) SAR3D_VIEWS: dict[str, tuple[str, str, str, tuple[int, int] | None]] = { "sar3d_front": ("Front (X-Y)", "X", "Y", (0, 1)), "sar3d_side": ("Side (Z-Y)", "Z", "Y", (2, 1)), "sar3d_top": ("Top (X-Z)", "X", "Z", (0, 2)), "sar3d_oblique": ("3D Projection", "", "", None), } # 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)} def sar3d_status(collection: ResultCollection) -> tuple[int, str] | None: """Status code (a SAR3D_STATES key) and message, one UTF-8 byte per table column.""" payload = collection_payload_by_name(collection, "sar3d_status", kind=4) if payload is None: return None if payload.table.ndim != 2 or not payload.table.size or not np.isfinite(payload.table).all(): return 2, "Invalid SAR3D status table" row = payload.table[0] code = int(row[0]) if code not in SAR3D_STATES or code != row[0]: return 2, f"Unknown SAR3D status code: {row[0]}" 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.""" def __init__(self) -> None: self.widget = QWidget() layout = QVBoxLayout(self.widget) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(6) self.status_label = QLabel() self.status_label.setTextFormat(Qt.TextFormat.PlainText) self.status_label.setWordWrap(True) layout.addWidget(self.status_label) self.grid = QWidget() grid_layout = QGridLayout(self.grid) grid_layout.setContentsMargins(0, 0, 0, 0) grid_layout.setSpacing(6) layout.addWidget(self.grid, stretch=1) lut = build_lut(["#081c15", "#1b4332", "#ffd166", "#f94144"]) # the GPR palette lut = np.column_stack([lut, np.full(len(lut), 255, dtype=np.uint8)]) lut[0, 3] = 0 # below SAR_CLOUD_THRESHOLD self.plots: dict[str, pg.PlotWidget] = {} 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) self.plots[name] = plot plot.setAspectLocked(True) if axes is not None: markers = (pg.ScatterPlotItem(), pg.ScatterPlotItem()) for marker in markers: marker.setZValue(4) plot.addItem(marker) self.antennas[name] = markers item = plot.getPlotItem() assert item is not None view = item.getViewBox() view.setMouseEnabled(x=False, y=False) view.enableAutoRange(x=False, y=False) item.hideButtons() image = pg.ImageItem(axisOrder="row-major", lut=lut) plot.addItem(image) # Hollow, so the response at the maximum stays visible. peak = pg.PlotCurveItem(pen=pg.mkPen(PLOT_FOREGROUND, width=1.5)) peak.setZValue(3) 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") if y_label == "Y": ground = pg.InfiniteLine(pos=0.0, angle=0, pen=pg.mkPen(_GROUND_COLOR, width=1, style=Qt.PenStyle.DashLine)) ground.setZValue(2) plot.addItem(ground) oblique = self.plots["sar3d_oblique"] oblique.hideAxis("bottom") oblique.hideAxis("left") # The notebook scene's ground, under the image so it shows through weak voxels. self.ground = pg.PlotCurveItem( pen=pg.mkPen(_GROUND_COLOR), brush=pg.mkBrush(*_GROUND_COLOR, 110), fillLevel="enclosed" ) self.ground.setZValue(-1) oblique.addItem(self.ground) self.box = pg.PlotCurveItem(pen=pg.mkPen(_SCENE_BOX_COLOR), connect="pairs") self.box.setZValue(2) oblique.addItem(self.box) self.axis_labels: dict[str, pg.TextItem] = {} for axis_name in _AXIS_EDGES: 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: """Clear all four 3D SAR views.""" for name, (title, *_rest) in SAR3D_VIEWS.items(): self.images[name].hide() self.targets[name].setData([], []) self.plots[name].setTitle(title) 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) if status is not None and status[0] != 0: self.clear(f"SAR3D: {SAR3D_STATES[status[0]]} — {status[1]}") return False payloads: dict[str, ResultPayload] = {} for name in SAR3D_VIEWS: payload = collection_payload_by_name(collection, name, kind=3) if payload is None or not payload.image.size: self.clear("SAR3D: waiting for projection data") return False payloads[name] = payload basis = collection_payload_by_name(collection, "sar3d_oblique_basis", kind=4) if basis is None: self.clear("SAR3D: waiting for projection data") 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) for coordinates in antennas: y, z = coordinates[:, 1].copy(), coordinates[:, 2].copy() 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) 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] 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 = 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 = 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 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])) 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 = 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) for axis_name, (start, end) in _AXIS_EDGES.items(): 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, _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]) 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 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