Rework radar system
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user