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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user