sar3d: GPU port of the notebook with four views

The sar3d processor is rewritten as a checked port of Multi_antenna_3D_clean_v1.ipynb:
NVRTC kernels, timing from the trace stamps, notebook parameter names and one cubic
SAR_VOXEL_M. It adds front, side, top and 3D views, drops gpr_cpp and the parity tools,
and the GUI speed now applies only while the socket speed is ignored.
This commit is contained in:
Ayzen
2026-10-02 16:05:22 +03:00
parent 61ef8720e1
commit 45861f4047
50 changed files with 1737 additions and 8144 deletions
@@ -1,162 +1,158 @@
"""3D SAR projection rendering helpers.
"""3D SAR views of the volume published by the sar3d processor.
The sar3d processor publishes two normalized max-projections of the imaged
volume as collection-level ImageF32 payloads, matching the legacy render
conventions (X across, Z range/"вдаль", Y up):
Scene axes: X across, Y height above the ground (Y = 0 is the ground), Z along
the ground away from the radar. Payloads, the volume normalized to a peak of 1:
- ``sar3d_proj_xz`` — top view: X (m) horizontal, Z/range (m) vertical
(max over the height Y), like the classic GPR heatmap;
- ``sar3d_proj_bottom`` — switchable bottom view: height Y (m) vertical, the
horizontal axis is the ground direction rotated by ``sar3d_bottom_view_deg``
around Y (0° = front view X, 90° = side view Z, 30°/60° in between);
plus ``sar3d_peak`` (ScalarF32), the volume peak before normalization. Both
projections are drawn on their own PlotWidgets stacked in one plot-stack page
(top view on top), mirroring how the pass-through page stacks its two plots.
- ``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 volume maximum before normalization.
"""
from __future__ import annotations
from PyQt6.QtCore import QRectF
import itertools
import numpy as np
import pyqtgraph as pg
from PyQt6.QtCore import QRectF, Qt
from python_app.gui.runtime.sar3d_views import sar3d_bottom_view_deg_from_label
from python_app.gui.controllers.app_window_plot.bscan_plot_mixin import build_lut
from python_app.models.dataset_model import ResultCollection
# Fraction of the projection maximum below which the heatmap is not highlighted
# (same as cloud-threshold=0.75 in the VNA_AlGo 3D SAR animation): the color
# scale is stretched over [frac, 1], everything below falls into the darkest
# LUT color.
HEATMAP_DISPLAY_THRESHOLD_FRAC = 0.75
# Colours start at this fraction of the maximum; weaker voxels stay transparent,
# like those under the threshold of the notebook's point cloud.
DISPLAY_THRESHOLD = 0.75
# 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", "", "", 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 _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 AppWindowSar3dPlotMixin:
"""Renders 3D SAR volume projections published by the sar3d processor."""
"""Draws the 3D SAR views."""
def _sar3d_bottom_view_labels(self) -> tuple[str, str]:
"""Return (plot title, horizontal axis label) for the active bottom view.
def _init_sar3d_views(self) -> None:
"""Create the persistent items of the four 3D SAR plots."""
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._sar3d_images = {}
self._sar3d_peaks = {}
for name, (_title, x_label, y_label, _axes) in SAR3D_VIEWS.items():
plot = self._sar3d_plots[name]
self._disable_gpr_plot_interaction(plot, plot.getViewBox())
plot.getViewBox().enableAutoRange(x=False, y=False)
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._sar3d_images[name] = image
self._sar3d_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)
The plot surfaces are created before the processing section builds the
bottom-view combo, so fall back to the front view until it exists.
"""
combo = getattr(self, "_sar3d_bottom_view", None)
degrees = sar3d_bottom_view_deg_from_label(combo.currentText()) if combo is not None else 0.0
if degrees <= 1e-9:
return "3D SAR front view (X-Y)", "X"
if degrees >= 90.0 - 1e-9:
return "3D SAR side view (Z-Y)", "Range Z"
return f"3D SAR view @ {degrees:g}\N{DEGREE SIGN}", f"u({degrees:g}\N{DEGREE SIGN})"
def _configure_sar3d_plot_axes(self) -> None:
"""Apply persistent axis labels and view settings to both projection plots."""
_bottom_title, bottom_x_label = self._sar3d_bottom_view_labels()
for plot, x_label, y_label in (
(self._sar3d_xz_plot, "X", "Range Z"),
(self._sar3d_xy_plot, bottom_x_label, "Height Y"),
):
plot_item = plot.getPlotItem()
plot_item.showAxis("left", show=True)
plot_item.showAxis("bottom", show=True)
plot_item.setClipToView(True)
plot.setLabel("bottom", x_label, units="m")
plot.setLabel("left", y_label, units="m")
view_box = plot.getViewBox()
view_box.invertY(False)
view_box.enableAutoRange(x=False, y=False)
self._disable_gpr_plot_interaction(plot, view_box)
def _ensure_sar3d_plot_items(self) -> None:
"""Create persistent projection image items once and reuse them on redraw."""
if self._sar3d_xz_image_item is not None:
return
self._configure_sar3d_plot_axes()
if self._sar3d_lookup_table is None:
# Same palette as the GPR accumulator so both imagers read alike.
self._sar3d_lookup_table = self._build_lut(["#081c15", "#1b4332", "#ffd166", "#f94144"])
self._sar3d_xz_image_item = pg.ImageItem(axisOrder="row-major")
self._sar3d_xz_image_item.setZValue(0)
self._sar3d_xz_image_item.hide()
self._sar3d_xz_plot.addItem(self._sar3d_xz_image_item)
self._sar3d_xy_image_item = pg.ImageItem(axisOrder="row-major")
self._sar3d_xy_image_item.setZValue(0)
self._sar3d_xy_image_item.hide()
self._sar3d_xy_plot.addItem(self._sar3d_xy_image_item)
oblique = self._sar3d_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._sar3d_ground = pg.PlotCurveItem(
pen=pg.mkPen(115, 105, 88), brush=pg.mkBrush(115, 105, 88, 110), fillLevel="enclosed"
)
self._sar3d_ground.setZValue(-1)
oblique.addItem(self._sar3d_ground)
self._sar3d_box = pg.PlotCurveItem(pen=pg.mkPen(190, 190, 190, 170), connect="pairs")
self._sar3d_box.setZValue(2)
oblique.addItem(self._sar3d_box)
self._sar3d_axis_labels = {}
for axis_name in _AXIS_EDGES:
label = pg.TextItem(axis_name, color=(190, 190, 190), anchor=(0.5, 0.5))
oblique.addItem(label)
self._sar3d_axis_labels[axis_name] = label
def _clear_sar3d_plot(self) -> None:
"""Clear both 3D SAR projection surfaces."""
if not hasattr(self, "_sar3d_xz_plot"):
return
self._configure_sar3d_plot_axes()
if self._sar3d_xz_image_item is not None:
self._sar3d_xz_image_item.hide()
if self._sar3d_xy_image_item is not None:
self._sar3d_xy_image_item.hide()
bottom_title, _bottom_x_label = self._sar3d_bottom_view_labels()
self._sar3d_xz_plot.setTitle("3D SAR top view (X-Z)")
self._sar3d_xy_plot.setTitle(bottom_title)
def _draw_sar3d_projection(self, plot: pg.PlotWidget, image_item: pg.ImageItem, payload) -> bool:
"""Draw one normalized projection payload onto its plot; return success."""
image = np.asarray(payload.image, dtype=np.float32)
x_axis = np.asarray(payload.image_x_axis, dtype=np.float32)
y_axis = np.asarray(payload.image_y_axis, dtype=np.float32)
if image.ndim != 2 or image.size == 0 or x_axis.size == 0 or y_axis.size == 0:
image_item.hide()
return False
x_min = float(x_axis[0])
x_max = float(x_axis[-1])
y_min = float(y_axis[0])
y_max = float(y_axis[-1])
rect = QRectF(x_min, y_min, max(x_max - x_min, 1e-6), max(y_max - y_min, 1e-6))
image_item.setImage(image, autoLevels=False)
image_item.setRect(rect)
image_item.setLookupTable(self._sar3d_lookup_table)
# Projections are normalized to [0, 1] by the processor; stretch the
# colors over [threshold, 1] like the 3D animation cloud cutoff.
image_item.setLevels((HEATMAP_DISPLAY_THRESHOLD_FRAC, 1.0))
image_item.show()
plot.setXRange(x_min, x_max, padding=0.0)
plot.setYRange(y_min, y_max, padding=0.0)
return True
"""Clear all four 3D SAR views."""
for name, (title, *_rest) in SAR3D_VIEWS.items():
self._sar3d_images[name].hide()
self._sar3d_peaks[name].setData([], [])
self._sar3d_plots[name].setTitle(title)
self._sar3d_box.setData([], [])
self._sar3d_ground.setData([], [])
def _draw_sar3d_projections(self, collection: ResultCollection) -> bool:
"""Draw the latest collection-level 3D SAR top and bottom projections."""
xz_payload = self._collection_payload_by_name(collection, "sar3d_proj_xz", kind=3)
bottom_payload = self._collection_payload_by_name(collection, "sar3d_proj_bottom", kind=3)
if xz_payload is None and bottom_payload is None:
"""Draw the 3D SAR views of a result collection; return success."""
payloads = {name: self._collection_payload_by_name(collection, name, kind=3) for name in SAR3D_VIEWS}
basis = self._collection_payload_by_name(collection, "sar3d_oblique_basis", kind=4)
if basis is None or any(payload is None or payload.image.size == 0 for payload in payloads.values()):
self._clear_sar3d_plot()
return False
peak = self._collection_payload_by_name(collection, "sar3d_peak", kind=4)
level, *peak_xyz = peak.table[0] if peak is not None else (None,)
for plot in (self._sar3d_xz_plot, self._sar3d_xy_plot):
plot.setUpdatesEnabled(False)
try:
self._ensure_sar3d_plot_items()
# Axis labels track the bottom-view selector (payload axes change with it).
self._configure_sar3d_plot_axes()
xz_drawn = xz_payload is not None and self._draw_sar3d_projection(
self._sar3d_xz_plot, self._sar3d_xz_image_item, xz_payload
)
bottom_drawn = bottom_payload is not None and self._draw_sar3d_projection(
self._sar3d_xy_plot, self._sar3d_xy_image_item, bottom_payload
)
if not xz_drawn and not bottom_drawn:
self._clear_sar3d_plot()
return False
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._sar3d_images[name]
image.setImage(payload.image, autoLevels=False, levels=(DISPLAY_THRESHOLD, 1.0))
image.setRect(rect)
image.show()
plot = self._sar3d_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._sar3d_peaks[name].setData([position[0]], [position[1]])
else:
self._sar3d_peaks[name].setData([], [])
plot.setTitle(title)
peak_payload = self._collection_payload_by_name(collection, "sar3d_peak", kind=2)
peak_suffix = ""
if peak_payload is not None:
peak_suffix = f" | peak={float(peak_payload.scalar_value):.3g}"
bottom_title, _bottom_x_label = self._sar3d_bottom_view_labels()
self._sar3d_xz_plot.setTitle(f"3D SAR top view (X-Z){peak_suffix}")
self._sar3d_xy_plot.setTitle(bottom_title)
finally:
for plot in (self._sar3d_xz_plot, self._sar3d_xy_plot):
plot.setUpdatesEnabled(True)
# 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._sar3d_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._sar3d_axis_labels[axis_name].setPos(*(middle + 0.08 * (middle - centre))) # just outside
(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._sar3d_ground.setData(ground[0], ground[1])
if level is not None:
x, y, z = peak_xyz
self._sar3d_plots["sar3d_oblique"].setTitle(
f"3D | max X {x:+.2f}, Y {y:+.2f}, Z {z:.2f} m | level {level:.3g}"
)
return True