Rework radar system

This commit is contained in:
Ayzen
2026-10-06 12:32:26 +03:00
parent 552cdc688a
commit 5bb86b7165
299 changed files with 13537 additions and 27763 deletions
@@ -0,0 +1 @@
"""Independent renderers for processed radar results."""
@@ -0,0 +1,162 @@
"""B-scan rendering and cache helpers."""
from __future__ import annotations
from collections import deque
from PyQt6.QtCore import QRectF
import numpy as np
import pyqtgraph as pg
from python_app.models.collection_identity import collection_key
from python_app.models.dataset_model import ResultCollection
from python_app.models.gui_profile_schema import GuiBscanStateModel
from python_app.gui.controllers.renderers.common import build_lut, create_plot
def _result_tail(
*,
result_history: list[ResultCollection],
history_limit: int,
) -> list[ResultCollection]:
"""Choose by acquisition order, keeping the newest result of each exact sweep."""
unique_reversed_tail: list[ResultCollection] = []
seen_keys: set[tuple[int, int]] = set()
for collection in reversed(result_history[-history_limit:]):
key = collection_key(collection)
if key in seen_keys:
continue
seen_keys.add(key)
unique_reversed_tail.append(collection)
unique_reversed_tail.reverse()
return unique_reversed_tail
def apply_mean_ascan_subtraction(
history: deque[np.ndarray],
*,
enabled: bool,
) -> np.ndarray:
"""Subtract mean A-scan across sweeps when enabled."""
sweeps = np.vstack(history).astype(np.float32, copy=False)
if not enabled:
return sweeps
mean_trace = np.mean(sweeps, axis=0, dtype=np.float32)
return sweeps - mean_trace
def rebuild_bscan_history_from_results(
result_history: list[ResultCollection],
history_limit: int,
) -> tuple[dict[tuple[int, int], deque[np.ndarray]], dict[tuple[int, int], np.ndarray]]:
"""Group compatible range profiles; a changed depth axis starts a new image."""
history_by_combo: dict[tuple[int, int], deque[np.ndarray]] = {}
depth_axis_by_combo: dict[tuple[int, int], np.ndarray] = {}
result_tail = _result_tail(
result_history=result_history,
history_limit=history_limit,
)
for collection in result_tail:
for block in collection.blocks:
key = (block.combo.input, block.combo.output)
for payload in block.payloads:
if payload.kind != 1 or payload.processing_name != "bscan":
continue
if payload.frequency_hz.size == 0 or payload.trace.size == 0:
continue
if payload.frequency_hz.size != payload.trace.size:
continue
depth_axis = np.asarray(payload.frequency_hz, dtype=np.float32)
amplitudes = np.asarray(np.real(payload.trace), dtype=np.float32)
history = history_by_combo.get(key)
stored_axis = depth_axis_by_combo.get(key)
if (
history is None
or stored_axis is None
or stored_axis.shape != depth_axis.shape
or not np.allclose(stored_axis, depth_axis, rtol=1e-4, atol=1e-6)
):
history = deque(maxlen=history_limit)
history_by_combo[key] = history
depth_axis_by_combo[key] = depth_axis.copy()
history.append(amplitudes.copy())
return history_by_combo, depth_axis_by_combo
def bscan_lookup_table(axis_mode: str) -> np.ndarray:
"""Build B-scan colormap table for selected axis mode."""
if axis_mode == "abs":
return build_lut(["#440154", "#31688e", "#35b779", "#fde725"])
return build_lut(["#2166ac", "#67a9cf", "#f7f7f7", "#ef8a62", "#b2182b"])
def bscan_levels(sweeps: np.ndarray, axis_mode: str) -> tuple[float, float]:
"""Compute image levels for B-scan data based on axis mode."""
min_value = float(np.min(sweeps))
max_value = float(np.max(sweeps))
if axis_mode == "abs":
if max_value <= min_value:
return min_value, min_value + 1e-6
return min_value, max_value
max_abs = max(abs(min_value), abs(max_value), 1e-6)
return -max_abs, max_abs
class BscanRenderer:
"""Own the heatmap and its per-combination arrays; input history stays external."""
def __init__(self) -> None:
self.widget = create_plot()
self.widget.setLabel("bottom", "Sweep #")
self.widget.setLabel("left", "Range", units="m")
item = self.widget.getPlotItem()
assert item is not None
item.getViewBox().invertY(False)
item.getViewBox().enableAutoRange(x=False, y=False)
self.image = pg.ImageItem(axisOrder="row-major")
item.addItem(self.image)
self.history_by_combo: dict[tuple[int, int], deque[np.ndarray]] = {}
self.depth_axis_by_combo: dict[tuple[int, int], np.ndarray] = {}
self.signature: tuple[object, ...] | None = None
def sync(self, results: list[ResultCollection], window: int) -> bool:
# Completion time distinguishes recomputed payloads for the same acquisition.
signature = (window, tuple((collection_key(c), c.monotonic_ns, c.settings_revision) for c in results[-window:]))
if signature == self.signature:
return False
self.history_by_combo, self.depth_axis_by_combo = rebuild_bscan_history_from_results(results, window)
self.signature = signature
return True
def draw(self, key: tuple[int, int] | None, settings: GuiBscanStateModel) -> bool:
history = self.history_by_combo.get(key) if key is not None else None
depth = self.depth_axis_by_combo.get(key) if key is not None else None
if not history or depth is None:
self.image.clear()
return False
sweeps = apply_mean_ascan_subtraction(history, enabled=settings.subtract_mean_ascan)
depth_min, depth_max = float(np.min(depth)), float(np.max(depth))
count = sweeps.shape[0]
self.image.setImage(sweeps.T, autoLevels=False)
self.image.setRect(QRectF(.5, depth_min, float(count), max(depth_max - depth_min, 1e-6)))
self.image.setLookupTable(bscan_lookup_table(settings.axis))
self.image.setLevels(bscan_levels(sweeps, settings.axis))
item = self.widget.getPlotItem()
assert item is not None
item.getViewBox().setRange(xRange=(.5, .5 + count), yRange=(depth_min, depth_max), padding=.02)
assert key is not None
self.widget.setTitle(f"B-scan S21 in{key[0]}/out{key[1]} | sweeps={count}")
return True
def clear(self) -> None:
self.history_by_combo.clear()
self.depth_axis_by_combo.clear()
self.signature = None
self.image.clear()
@@ -0,0 +1,23 @@
"""Plot construction and color tables shared by radar views."""
import numpy as np
import pyqtgraph as pg
def create_plot(title: str = "") -> pg.PlotWidget:
plot = pg.PlotWidget(background="#0f141c", enableMenu=False, title=title)
plot.showGrid(x=True, y=True, alpha=0.2)
return plot
def build_lut(stops: list[str], *, size: int = 256) -> np.ndarray:
"""Interpolate hex color stops into 8-bit RGB LUT array."""
stop_positions = np.linspace(0.0, 1.0, num=len(stops), dtype=np.float32)
sample_positions = np.linspace(0.0, 1.0, num=size, dtype=np.float32)
stop_colors = np.asarray([pg.mkColor(value).getRgb()[:3] for value in stops], dtype=np.float32)
lut = np.empty((size, 3), dtype=np.uint8)
for channel in range(3):
lut[:, channel] = np.interp(sample_positions, stop_positions, stop_colors[:, channel]).astype(np.uint8)
return lut
+292
View File
@@ -0,0 +1,292 @@
"""GPR heatmaps and object overlays with renderer-owned graphics and geometry."""
from __future__ import annotations
from PyQt6.QtCore import QRectF
import numpy as np
import pyqtgraph as pg
from python_app.gui.controllers.renderers.common import build_lut, create_plot
from python_app.gui.result_payloads import (
collection_payload_by_name, collection_payloads_by_prefix,
filter_object_rows, gpr_object_rows,
)
from python_app.models.run_config_validation import parse_position_csv
from python_app.models.dataset_model import ResultCollection, ResultPayload
from python_app.models.gui_profile_schema import GuiGprStateModel, GuiLegacyGprStateModel
from python_app.models.run_config_schema import GprModel
GprSettings = GuiGprStateModel | GuiLegacyGprStateModel
def normalized_display_range(start: float, stop: float, *, minimum_span: float = 0.1) -> tuple[float, float]:
"""Order display bounds and expand an empty interval around its center."""
lower, upper = sorted((float(start), float(stop)))
if upper - lower >= minimum_span:
return lower, upper
center = 0.5 * (lower + upper)
return center - 0.5 * minimum_span, center + 0.5 * minimum_span
def display_y_min(z_min: float, z_max: float) -> float:
"""Leave room for antenna markers when the surface is visible."""
if z_min > 0.0:
return z_min
return z_min - max(max(z_max - z_min, 1e-6) * 0.03, 0.06)
def object_label_candidates(
x_m: float, z_m: float, *, x_span: float, z_span: float,
) -> list[tuple[float, float, tuple[float, float]]]:
"""Candidate label placements, ordered by proximity to the object."""
dx, dz = max(x_span * 0.015, 0.02), max(z_span * 0.02, 0.02)
return [
(x_m + dx, z_m - dz, (0.0, 1.0)),
(x_m + dx, z_m + dz, (0.0, 0.0)),
(x_m - dx, z_m - dz, (1.0, 1.0)),
(x_m - dx, z_m + dz, (1.0, 0.0)),
(x_m, z_m - dz * 1.35, (0.5, 1.0)),
(x_m, z_m + dz * 1.35, (0.5, 0.0)),
(x_m + dx * 2.2, z_m - dz * 1.5, (0.0, 1.0)),
(x_m + dx * 2.2, z_m + dz * 1.5, (0.0, 0.0)),
(x_m - dx * 2.2, z_m - dz * 1.5, (1.0, 1.0)),
(x_m - dx * 2.2, z_m + dz * 1.5, (1.0, 0.0)),
]
def _title(settings: GprSettings, *, objects_only: bool = False) -> str:
title = f"Legacy GPR {settings.mode}" if isinstance(settings, GuiLegacyGprStateModel) else "GPR coherent BP"
return f"{title} Objects Only" if objects_only else title
class GprRenderer:
"""Draw one collection from explicit display settings and antenna geometry."""
def __init__(self) -> None:
self.widget = create_plot("GPR coherent BP")
self.widget.setBackground("#0f141c")
plot = self.widget.getPlotItem()
assert plot is not None
self._plot = plot
self._view = plot.getViewBox()
self._plot.showGrid(x=True, y=True, alpha=0.2)
self._configure_axes()
self._lookup_table = build_lut(["#081c15", "#1b4332", "#ffd166", "#f94144"])
self._image = pg.ImageItem(axisOrder="row-major")
self._tx = pg.ScatterPlotItem()
self._rx = pg.ScatterPlotItem()
self._points = pg.ScatterPlotItem()
self._region_centers = pg.ScatterPlotItem()
self._markers = (self._tx, self._rx, self._points, self._region_centers)
for item, z_value in zip((self._image, *self._markers), (0, 20, 20, 30, 30)):
item.setZValue(z_value)
item.hide()
self._plot.addItem(item)
self._labels: list[pg.TextItem] = []
self._masks: list[pg.ImageItem] = []
self._contours: list[pg.IsocurveItem] = []
self._geometry_signature: tuple[object, ...] | None = None
self._selected_geometry = (np.zeros(0, dtype=np.float32), np.zeros(0, dtype=np.float32))
def _configure_axes(self) -> None:
plot = self._plot
plot.showAxis("left", show=True)
plot.showAxis("bottom", show=True)
plot.setClipToView(True)
plot.setLabel("bottom", "X", units="m")
plot.setLabel("left", "Range", units="m")
plot.setMenuEnabled(False)
plot.hideButtons()
view = self._view
view.invertY(False)
view.enableAutoRange(x=False, y=False)
view.setMouseEnabled(x=False, y=False)
view.setMenuEnabled(False)
def _clear_annotations(self) -> None:
for item in (*self._labels, *self._masks):
self._plot.removeItem(item)
self._labels.clear()
self._masks.clear()
self._contours.clear()
def clear(self, settings: GprSettings) -> None:
"""Clear displayed data while retaining reusable graphics items."""
self._configure_axes()
self._clear_annotations()
self._image.hide()
for marker in self._markers:
marker.setData(x=[], y=[])
marker.hide()
self._plot.setTitle(_title(settings))
def _geometry(self, settings: GprSettings, geometry: GprModel) -> tuple[np.ndarray, np.ndarray]:
inputs = tuple(parse_position_csv(settings.input_positions))
outputs = tuple(parse_position_csv(settings.output_positions))
signature = (
tuple((entry.output_pos, entry.x_m, entry.y_m, entry.z_m) for entry in geometry.tx_geometry),
tuple((entry.input_pos, entry.x_m, entry.y_m, entry.z_m) for entry in geometry.rx_geometry),
inputs, outputs,
)
if signature != self._geometry_signature:
self._selected_geometry = (
np.asarray([
entry.x_m for entry in sorted(geometry.tx_geometry, key=lambda entry: entry.output_pos)
if not outputs or entry.output_pos in outputs
], dtype=np.float32),
np.asarray([
entry.x_m for entry in sorted(geometry.rx_geometry, key=lambda entry: entry.input_pos)
if not inputs or entry.input_pos in inputs
], dtype=np.float32),
)
self._geometry_signature = signature
return self._selected_geometry
def _draw_geometry(self, settings: GprSettings, geometry: GprModel) -> None:
tx, rx = self._geometry(settings, geometry)
for marker, xs, symbol, brush, pen in (
(self._tx, tx, "t", "#ff595e", "#ffca3a"),
(self._rx, rx, "t1", "#4cc9f0", "#e0fbfc"),
):
marker.setData(
x=xs, y=np.zeros_like(xs), symbol=symbol, size=13,
brush=pg.mkBrush(brush), pen=pg.mkPen(pen, width=1.0),
)
marker.setVisible(xs.size > 0)
def _label(self, text: str, x: float, y: float, color: str) -> pg.TextItem:
label = pg.TextItem(text=text, color=color, anchor=(0.0, 1.0))
label.setZValue(40)
label.setPos(x, y)
self._plot.addItem(label)
self._labels.append(label)
return label
def _draw_regions(self, collection: ResultCollection, rect: QRectF) -> None:
centers_payload = collection_payload_by_name(collection, "gpr_region_centers", kind=4)
if centers_payload is not None and np.asarray(centers_payload.table).size > 0:
centers = np.asarray(centers_payload.table, dtype=np.float32)
self._region_centers.setData(
x=centers[:, 0], y=centers[:, 1], symbol="o", size=10,
brush=pg.mkBrush("#80ed99"), pen=pg.mkPen("#081c15", width=1.1),
)
self._region_centers.show()
for row in centers:
self._label(f"{float(row[2]):.0f}", float(row[0]), float(row[1]), "#d8f3dc")
for payload in collection_payloads_by_prefix(collection, "gpr_region_mask_", kind=3):
mask = np.asarray(payload.image, dtype=np.float32)
if mask.ndim != 2 or mask.size == 0:
continue
mask_image = pg.ImageItem(axisOrder="row-major")
mask_image.setZValue(5)
mask_image.setImage(mask, autoLevels=False)
mask_image.setRect(rect)
mask_image.setOpacity(0.0)
self._plot.addItem(mask_image)
contour = pg.IsocurveItem(data=mask)
contour.setLevel(0.5)
contour.setPen("#4cc9f0", width=1.3)
contour.setParentItem(mask_image)
self._masks.append(mask_image)
self._contours.append(contour)
@staticmethod
def _place_object_label(
label: pg.TextItem, x: float, z: float, x_span: float, z_span: float, occupied: list[QRectF],
) -> None:
for label_x, label_z, anchor in object_label_candidates(x, z, x_span=x_span, z_span=z_span):
label.setAnchor(anchor)
label.setPos(label_x, label_z)
rect = label.sceneBoundingRect().adjusted(-4.0, -4.0, 4.0, 4.0)
if not any(rect.intersects(other) for other in occupied):
occupied.append(rect)
return
occupied.append(label.sceneBoundingRect().adjusted(-4.0, -4.0, 4.0, 4.0))
def _draw_points(
self, rows: np.ndarray, settings: GprSettings, objects_only: bool, x_span: float, z_span: float,
) -> None:
if rows.size == 0:
return
self._points.setData(
x=rows[:, 0], y=rows[:, 1], symbol="o" if objects_only else "d",
size=18 if objects_only else 11,
brush=pg.mkBrush("#ff4d4f" if objects_only else "#ffffff"),
pen=pg.mkPen("#ff4d4f" if objects_only else "#111111", width=1.6 if objects_only else 1.1),
)
self._points.show()
occupied: list[QRectF] = []
for x_value, z_value, score in rows:
x, z = float(x_value), float(z_value)
text = f"{float(score):.0f}" if isinstance(settings, GuiLegacyGprStateModel) else f"{float(score):.2f}"
if objects_only:
text += f" | x={x:.1f} | z={z:.1f}"
label = self._label(text, x, z, "#ffd6d9" if objects_only else "#ffffff")
if objects_only:
self._place_object_label(label, x, z, x_span, z_span, occupied)
def draw(self, collection: ResultCollection, settings: GprSettings, geometry: GprModel) -> bool:
"""Render a heatmap or object-only view, returning whether data is present."""
objects_only = settings.render_mode == "objects_only"
accumulator = collection_payload_by_name(collection, "gpr_accumulator", kind=3)
x_min, x_max = normalized_display_range(settings.visible_x_min_m, settings.visible_x_max_m)
z_min, z_max = normalized_display_range(settings.visible_z_min_m, settings.visible_z_max_m)
rows = gpr_object_rows(collection)
# Coherent BP rows are finalized in C++; only legacy GPR uses GUI filtering.
if isinstance(settings, GuiLegacyGprStateModel):
rows = filter_object_rows(
rows, min_score=float(settings.min_visible_pair_count),
x_bounds=(x_min, x_max), z_bounds=(z_min, z_max),
)
if objects_only:
if accumulator is None and rows.size == 0:
self.clear(settings)
return False
elif accumulator is None or not self._valid_accumulator(accumulator):
self.clear(settings)
return False
self.widget.setUpdatesEnabled(False)
try:
self._clear_annotations()
self._image.hide()
for marker in (self._points, self._region_centers):
marker.setData(x=[], y=[])
marker.hide()
self._view.invertY(False)
self._draw_geometry(settings, geometry)
rect: QRectF | None = None
if not objects_only and accumulator is not None:
image = np.asarray(accumulator.image, dtype=np.float32)
xs = np.asarray(accumulator.image_x_axis, dtype=np.float32)
ys = np.asarray(accumulator.image_y_axis, dtype=np.float32)
rect = QRectF(
float(xs[0]), float(ys[0]),
max(float(xs[-1]) - float(xs[0]), 1e-6),
max(float(ys[-1]) - float(ys[0]), 1e-6),
)
self._image.setImage(image, autoLevels=False)
self._image.setRect(rect)
self._image.setLookupTable(self._lookup_table)
self._image.setLevels((float(np.min(image)), float(np.max(image) + 1e-6)))
self._image.show()
self._view.setXRange(x_min, x_max, padding=0.0)
self._view.setYRange(display_y_min(z_min, z_max), z_max, padding=0.0)
points = collection_payload_by_name(collection, "gpr_points", kind=4)
if points is None or np.asarray(points.table).size == 0:
rows = np.zeros((0, 3), dtype=np.float32)
self._draw_points(rows, settings, objects_only, x_max - x_min, z_max - z_min)
if rect is not None:
self._draw_regions(collection, rect)
if objects_only:
self._view.setXRange(x_min, x_max, padding=0.0)
self._view.setYRange(display_y_min(z_min, z_max), z_max, padding=0.0)
self._plot.setTitle(_title(settings, objects_only=objects_only))
finally:
self.widget.setUpdatesEnabled(True)
return True
@staticmethod
def _valid_accumulator(payload: ResultPayload) -> bool:
image = np.asarray(payload.image)
return image.ndim == 2 and image.size > 0 and np.size(payload.image_x_axis) > 0 and np.size(payload.image_y_axis) > 0
@@ -0,0 +1,218 @@
"""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 that maximum before normalization.
- ``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
import itertools
import numpy as np
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 build_lut, create_plot
from python_app.gui.result_payloads import collection_payload_by_name
from python_app.models.dataset_model import ResultCollection, ResultPayload
# Colours start at this fraction of the normalization reference; weaker voxels stay transparent,
# like those under the notebook's point-cloud threshold (SAR_CLOUD_THRESHOLD).
DISPLAY_THRESHOLD = 0.70
# Status codes published by the sar3d processor in its "sar3d_status" table.
SAR3D_STATES = {0: "ready", 1: "no signal", 2: "error", 3: "warning"}
# 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 the scene 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 _image_rect(x_axis: np.ndarray, y_axis: np.ndarray) -> QRectF:
"""Rectangle covering the pixels centred on the axis samples."""
dx = (x_axis[-1] - x_axis[0]) / max(len(x_axis) - 1, 1)
dy = (y_axis[-1] - y_axis[0]) / max(len(y_axis) - 1, 1)
return QRectF(x_axis[0] - dx / 2, y_axis[0] - dy / 2, x_axis[-1] - x_axis[0] + dx, y_axis[-1] - y_axis[0] + dy)
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)
self.plots: dict[str, pg.PlotWidget] = {}
for index, (name, (title, *_rest)) in enumerate(SAR3D_VIEWS.items()):
plot = create_plot(title)
grid_layout.addWidget(plot, index // 2, index % 2)
self.plots[name] = plot
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 DISPLAY_THRESHOLD
self.images: dict[str, pg.ImageItem] = {}
self.peaks: dict[str, pg.ScatterPlotItem] = {}
for name, (_title, x_label, y_label, _axes) in SAR3D_VIEWS.items():
plot = self.plots[name]
item = plot.getPlotItem()
assert item is not None
view = item.getViewBox()
view.setMouseEnabled(x=False, y=False)
view.setMenuEnabled(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.ScatterPlotItem(symbol="star", size=18, pen=pg.mkPen("w", width=1.5), brush=None)
peak.setZValue(3)
plot.addItem(peak)
self.images[name] = image
self.peaks[name] = peak
if x_label:
plot.showGrid(x=True, y=True, alpha=0.2)
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("w", width=1, style=Qt.PenStyle.DashLine))
ground.setZValue(2)
plot.addItem(ground)
oblique = self.plots["sar3d_oblique"]
oblique.hideAxis("bottom")
oblique.hideAxis("left")
oblique.setAspectLocked(True)
# The notebook scene's ground, under the image so it shows through weak voxels.
self.ground = pg.PlotCurveItem(
pen=pg.mkPen(115, 105, 88), brush=pg.mkBrush(115, 105, 88, 110), fillLevel="enclosed"
)
self.ground.setZValue(-1)
oblique.addItem(self.ground)
self.box = pg.PlotCurveItem(pen=pg.mkPen(190, 190, 190, 170), 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=(190, 190, 190), anchor=(0.5, 0.5))
oblique.addItem(label)
self.axis_labels[axis_name] = label
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.peaks[name].setData([], [])
self.plots[name].setTitle(title)
self.box.setData([], [])
self.ground.setData([], [])
for label in self.axis_labels.values():
label.hide()
self.status_label.setText(message)
def draw(self, collection: ResultCollection) -> 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,)
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(payload.image, autoLevels=False, levels=(DISPLAY_THRESHOLD, 1.0))
image.setRect(rect)
image.show()
plot = self.plots[name]
plot.setRange(rect, padding=0.0)
if peak_xyz:
position = basis.table @ np.asarray(peak_xyz) if axes is None else [peak_xyz[axis] for axis in axes]
self.peaks[name].setData([position[0]], [position[1]])
else:
self.peaks[name].setData([], [])
plot.setTitle(title)
# 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
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), _, (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])
if level is not None:
x, y, z = peak_xyz
self.plots["sar3d_oblique"].setTitle(
f"3D projection | max X {x:+.2f}, Y {y:+.2f}, Z {z:.2f} m | level {level:.3g}"
)
self.status_label.setText(f"SAR3D: ready — {status[1]}" if status else "SAR3D: ready")
return True
@@ -0,0 +1,186 @@
"""Magnitude and phase rendering with owned plots, curves and legends."""
from __future__ import annotations
from typing import Protocol
import numpy as np
import pyqtgraph as pg
from PyQt6.QtCore import Qt
from PyQt6.QtWidgets import QVBoxLayout, QWidget
from python_app.gui.controllers.renderers.common import create_plot
from python_app.models.dataset_model import ComboKey, ResultCollection, TraceData
from python_app.models.gui_profile_schema import GuiPassThroughStateModel
from python_app.models.run_config_validation import parse_combos_from_text
class WarningLogger(Protocol):
def __call__(self, text: str, *, details: str | None = None,
once_key: str | None = None) -> None: ...
CurveKey = tuple[ComboKey, int, str]
Series = tuple[CurveKey, np.ndarray, np.ndarray, str]
_PALETTE = ("#4cc9f0", "#f72585", "#b8f2e6", "#ffd166",
"#90be6d", "#ff595e", "#6a4c93", "#1982c4")
class _TracePanel:
"""One persistent curve surface and its combo legend."""
def __init__(self, label: str, units: str) -> None:
self.plot = create_plot("Pass-Through S21")
item = self.plot.getPlotItem()
assert item is not None
self.item = item
self.view = item.getViewBox()
self.item.showGrid(x=True, y=True, alpha=0.2)
self.item.setLabel("left", label, units=units)
self.item.setLabel("bottom", "Frequency", units="Hz")
self.item.setDownsampling(mode="peak")
self.item.setClipToView(True)
self.curves: dict[CurveKey, pg.PlotCurveItem] = {}
self.legend: pg.LegendItem | None = None
self._legend_sources: dict[ComboKey, pg.PlotCurveItem] = {}
def clear(self) -> None:
self.item.clear()
self.curves.clear()
self.sync_legend({})
def update_curve(self, key: CurveKey, x: np.ndarray, y: np.ndarray,
color: str, *, width: float, dashed: bool = False) -> pg.PlotCurveItem:
pen = pg.mkPen(color, width=width,
style=Qt.PenStyle.DashLine if dashed else Qt.PenStyle.SolidLine)
curve = self.curves.get(key)
if curve is None:
curve = pg.PlotCurveItem(pen=pen, antialias=False)
self.curves[key] = curve
self.item.addItem(curve)
else:
curve.setPen(pen)
curve.setData(x, y)
return curve
def retain_curves(self, active_keys: set[CurveKey]) -> None:
for key in self.curves.keys() - active_keys:
self.item.removeItem(self.curves.pop(key))
def sync_legend(self, sources: dict[ComboKey, pg.PlotCurveItem]) -> None:
if self._legend_sources == sources:
return
if self.legend is None and sources:
self.legend = self.item.addLegend(offset=(8, 8))
if self.legend is not None:
self.legend.clear()
for combo in sorted(sources, key=lambda key: (key.input, key.output)):
self.legend.addItem(sources[combo], f"in{combo.input}/out{combo.output}")
self.legend.setVisible(bool(sources))
self._legend_sources = sources
class TraceRenderer:
"""Render result traces or a captured trace using explicit display settings."""
def __init__(self, log_warning: WarningLogger, max_points: int = 800) -> None:
self._log_warning = log_warning
self.max_points = max_points
self.widget = QWidget()
layout = QVBoxLayout(self.widget)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(6)
self.magnitude = _TracePanel("Magnitude", "dB")
self.phase = _TracePanel("Phase", "deg")
self.magnitude.item.showAxis("bottom", show=False)
for panel in (self.magnitude, self.phase):
layout.addWidget(panel.plot, stretch=1)
def clear(self) -> None:
self.magnitude.clear()
self.phase.clear()
def draw(self, collection: ResultCollection, settings: GuiPassThroughStateModel) -> bool:
combo_filter = self._combo_filter(settings.combo_filter)
colors: dict[ComboKey, str] = {}
series: list[Series] = []
for block in collection.blocks:
if combo_filter is not None and block.combo not in combo_filter:
continue
color = colors.setdefault(block.combo, _PALETTE[len(colors) % len(_PALETTE)])
for index, payload in enumerate(block.payloads):
if payload.kind != 1 or not payload.trace.size or payload.frequency_hz.size != payload.trace.size:
continue
series.append(((block.combo, index, payload.processing_name),
payload.frequency_hz, payload.trace, color))
return self._render(series, settings, "Pass-Through S21")
def draw_trace(self, trace: TraceData, title: str, settings: GuiPassThroughStateModel) -> None:
self.clear()
self._render([((trace.combo, 0, "__single_trace__"), trace.frequency_hz,
trace.s21, "#ffd166")], settings, title, single=True)
def _combo_filter(self, text: str) -> set[ComboKey] | None:
text = text.strip()
if not text:
return None
try:
return {ComboKey(combo.input, combo.output) for combo in parse_combos_from_text(text)}
except Exception as exc: # noqa: BLE001
self._log_warning("Invalid pass-through switch-combo filter.",
details=f"{exc}\nExpected format: input:output,input:output",
once_key=f"pass_through_combo_filter_invalid_{text}")
return None
def _render(self, series: list[Series], settings: GuiPassThroughStateModel,
title: str, *, single: bool = False) -> bool:
mag, phase = self.magnitude, self.phase
mag.plot.setVisible(settings.show_magnitude)
phase.plot.setVisible(settings.show_phase)
if not settings.show_magnitude and not settings.show_phase:
self.clear()
return False
mag.item.showAxis("bottom", show=not settings.show_phase)
active: set[CurveKey] = set()
mag_sources: dict[ComboKey, pg.PlotCurveItem] = {}
phase_sources: dict[ComboKey, pg.PlotCurveItem] = {}
x_min, x_max = np.inf, -np.inf
for key, frequency, samples, color in series:
if not samples.size or frequency.size != samples.size:
continue
active.add(key)
x_min, x_max = min(x_min, float(np.min(frequency))), max(x_max, float(np.max(frequency)))
step = max(1, int(np.ceil(samples.size / self.max_points))) if self.max_points > 0 else 1
if settings.show_magnitude:
magnitude = 20.0 * np.log10(np.maximum(np.abs(samples[::step]), 1e-12))
curve = mag.update_curve(key, frequency[::step], magnitude, color,
width=1.8 if single else 1.4)
mag_sources.setdefault(key[0], curve)
if settings.show_phase:
angles = np.angle(samples)
if settings.unwrap_phase:
angles = np.unwrap(angles)
curve = phase.update_curve(key, frequency[::step], np.degrees(angles[::step]),
"#80ed99" if single else color,
width=1.4 if single else 1.2, dashed=single)
phase_sources.setdefault(key[0], curve)
for panel, visible, sources in ((mag, settings.show_magnitude, mag_sources),
(phase, settings.show_phase, phase_sources)):
panel.retain_curves(active if visible else set())
panel.sync_legend(sources if not single else {})
if visible:
panel.item.setTitle(title)
panel.view.invertY(False)
if active and np.isfinite(x_min) and np.isfinite(x_max):
panel.view.setXRange(x_min, x_max, padding=0.02)
if settings.show_magnitude:
mag.view.enableAutoRange(y=not settings.fixed_y_enabled)
if settings.fixed_y_enabled:
mag.view.setYRange(min(settings.y_min_db, settings.y_max_db),
max(settings.y_min_db, settings.y_max_db), padding=0.0)
if settings.show_phase:
phase.view.enableAutoRange(y=settings.unwrap_phase)
if not settings.unwrap_phase:
phase.view.setYRange(-180.0, 180.0, padding=0.02)
return bool(active)