Update GPR and SAR3D processing

This commit is contained in:
Ayzen
2026-10-08 16:24:01 +03:00
parent 390015ec39
commit 3575ca68f5
67 changed files with 1187 additions and 2581 deletions
@@ -3,6 +3,8 @@
import numpy as np
import pyqtgraph as pg
from python_app.models.run_config_schema import GprModel
from python_app.models.run_config_validation import parse_position_csv
from python_app.gui.theme import PLOT_BACKGROUND
@@ -22,3 +24,16 @@ def build_lut(stops: list[str], *, size: int = 256) -> np.ndarray:
for channel in range(3):
lut[:, channel] = np.interp(sample_positions, stop_positions, stop_colors[:, channel]).astype(np.uint8)
return lut
# Same Tx/Rx markers on ground BP and the three planar SAR views.
ANTENNA_STYLES = (("t", "#ff595e", "#ffca3a"), ("t1", "#4cc9f0", "#e0fbfc"))
def selected_antenna_coordinates(geometry: GprModel, inputs: str, outputs: str) -> tuple[np.ndarray, np.ndarray]:
selected_inputs, selected_outputs = parse_position_csv(inputs), parse_position_csv(outputs)
tx = [[row.x_m, row.y_m, row.z_m] for row in geometry.tx_geometry
if not selected_outputs or row.output_pos in selected_outputs]
rx = [[row.x_m, row.y_m, row.z_m] for row in geometry.rx_geometry
if not selected_inputs or row.input_pos in selected_inputs]
return np.asarray(tx, dtype=float).reshape(-1, 3), np.asarray(rx, dtype=float).reshape(-1, 3)
+28 -89
View File
@@ -6,18 +6,14 @@ 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.controllers.renderers.common import ANTENNA_STYLES, build_lut, create_plot, selected_antenna_coordinates
from python_app.gui.result_payloads import (
collection_payload_by_name, collection_payloads_by_prefix,
filter_object_rows, gpr_object_rows,
collection_payload_by_name, 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.gui_profile_schema import GuiGprStateModel
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."""
@@ -54,8 +50,8 @@ def object_label_candidates(
]
def _title(settings: GprSettings, *, objects_only: bool = False) -> str:
title = f"Legacy GPR {settings.mode}" if isinstance(settings, GuiLegacyGprStateModel) else "GPR coherent BP"
def _title(*, objects_only: bool = False) -> str:
title = "GPR coherent BP — ground plane"
return f"{title} Objects Only" if objects_only else title
@@ -63,7 +59,7 @@ 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 = create_plot("GPR coherent BP — ground plane")
plot = self.widget.getPlotItem()
assert plot is not None
self._plot = plot
@@ -75,17 +71,14 @@ class GprRenderer:
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)):
self._markers = (self._tx, self._rx, self._points)
for item, z_value in zip((self._image, *self._markers), (0, 20, 20, 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))
self._selected_geometry = (np.empty((0, 3)), np.empty((0, 3)))
def _configure_axes(self) -> None:
plot = self._plot
@@ -103,13 +96,11 @@ class GprRenderer:
view.setMenuEnabled(False)
def _clear_annotations(self) -> None:
for item in (*self._labels, *self._masks):
for item in self._labels:
self._plot.removeItem(item)
self._labels.clear()
self._masks.clear()
self._contours.clear()
def clear(self, settings: GprSettings) -> None:
def clear(self) -> None:
"""Clear displayed data while retaining reusable graphics items."""
self._configure_axes()
self._clear_annotations()
@@ -117,41 +108,25 @@ class GprRenderer:
for marker in self._markers:
marker.setData(x=[], y=[])
marker.hide()
self._plot.setTitle(_title(settings))
self._plot.setTitle(_title())
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))
def _geometry(self, settings: GuiGprStateModel, geometry: GprModel) -> tuple[np.ndarray, np.ndarray]:
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,
settings.input_positions, settings.output_positions,
)
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._selected_geometry = selected_antenna_coordinates(geometry, settings.input_positions, settings.output_positions)
self._geometry_signature = signature
return self._selected_geometry
def _draw_geometry(self, settings: GprSettings, geometry: GprModel) -> None:
def _draw_geometry(self, settings: GuiGprStateModel, 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)
for marker, coordinates, (symbol, brush, pen) in zip((self._tx, self._rx), (tx, rx), ANTENNA_STYLES):
marker.setData(x=coordinates[:, 0], y=coordinates[:, 2], symbol=symbol, size=13,
brush=pg.mkBrush(brush), pen=pg.mkPen(pen, width=1.0))
marker.setVisible(len(coordinates) > 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))
@@ -161,34 +136,6 @@ class GprRenderer:
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],
@@ -203,7 +150,7 @@ class GprRenderer:
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,
self, rows: np.ndarray, objects_only: bool, x_span: float, z_span: float,
) -> None:
if rows.size == 0:
return
@@ -217,39 +164,33 @@ class GprRenderer:
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}"
text = 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:
def draw(self, collection: ResultCollection, settings: GuiGprStateModel, 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)
self.clear()
return False
elif accumulator is None or not self._valid_accumulator(accumulator):
self.clear(settings)
self.clear()
return False
self.widget.setUpdatesEnabled(False)
try:
self._clear_annotations()
self._image.hide()
for marker in (self._points, self._region_centers):
for marker in (self._points,):
marker.setData(x=[], y=[])
marker.hide()
self._view.invertY(False)
@@ -274,13 +215,11 @@ class GprRenderer:
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)
self._draw_points(rows, objects_only, x_max - x_min, z_max - z_min)
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))
self._plot.setTitle(_title(objects_only=objects_only))
finally:
self.widget.setUpdatesEnabled(True)
return True
+52 -18
View File
@@ -9,6 +9,7 @@ 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_status``: [code, UTF-8 bytes...] with ready CUDA (0), no signal (1),
processing error (2), or CUDA unavailable (3), including the explanation.
@@ -23,13 +24,11 @@ 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.controllers.renderers.common import ANTENNA_STYLES, build_lut, create_plot, selected_antenna_coordinates
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
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"}
@@ -93,11 +92,19 @@ class Sar3dRenderer:
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
lut[0, 3] = 0 # below SAR_CLOUD_THRESHOLD
self.antennas: dict[str, tuple[pg.ScatterPlotItem, pg.ScatterPlotItem]] = {}
self.images: dict[str, pg.ImageItem] = {}
self.peaks: dict[str, pg.ScatterPlotItem] = {}
self.targets: dict[str, pg.PlotCurveItem] = {}
for name, (_title, x_label, y_label, _axes) in SAR3D_VIEWS.items():
plot = self.plots[name]
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()
@@ -108,11 +115,11 @@ class Sar3dRenderer:
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 = pg.PlotCurveItem(pen=pg.mkPen("w", width=1.5))
peak.setZValue(3)
plot.addItem(peak)
self.images[name] = image
self.peaks[name] = peak
self.targets[name] = peak
if x_label:
plot.showGrid(x=True, y=True, alpha=0.2)
plot.setLabel("bottom", x_label, units="m")
@@ -146,15 +153,18 @@ class Sar3dRenderer:
"""Clear all four 3D SAR views."""
for name, (title, *_rest) in SAR3D_VIEWS.items():
self.images[name].hide()
self.peaks[name].setData([], [])
self.targets[name].setData([], [])
self.plots[name].setTitle(title)
for markers in self.antennas.values():
for marker in markers:
marker.setData([], [])
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:
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:
@@ -174,20 +184,44 @@ class Sar3dRenderer:
peak = collection_payload_by_name(collection, "sar3d_peak", kind=4)
level, *peak_xyz = peak.table[0] if peak is not None else (None,)
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)
widths = width_payload.table[0] if width_payload is not None else np.full(3, settings.voxel_m)
zoom = settings.zoom_percent / 100.0
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.setImage(np.where(payload.image >= settings.cloud_threshold, payload.image, 0),
autoLevels=False, levels=(0, 1))
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]])
position = (basis.table @ np.asarray(peak_xyz) if axes is None else np.asarray(peak_xyz)[list(axes)]) 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
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:
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.peaks[name].setData([], [])
self.targets[name].setData([], [])
plot.setRange(rect, padding=0.04)
plot.setTitle(title)
# Scene box and ground on the oblique view, from the extents of the other views.
@@ -213,5 +247,5 @@ class Sar3dRenderer:
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(summary)
self.status_label.setText(f"{summary} | zoom {settings.zoom_percent}%")
return True