Files

203 lines
8.9 KiB
Python

"""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 (
ANTENNA_STYLES, OVERLAY_FILL, build_lut, create_plot, image_rect, selected_antenna_coordinates,
)
from python_app.gui.result_payloads import (
collection_payload_by_name, gpr_object_rows,
)
from python_app.gui.theme import PLOT_FOREGROUND
from python_app.models.dataset_model import ResultCollection, ResultPayload
from python_app.models.gui_profile_schema import GuiGprStateModel
from python_app.models.run_config_schema import GprModel
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(*, objects_only: bool = False) -> str:
title = "GPR coherent BP — ground plane"
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(_title())
plot = self.widget.getPlotItem()
assert plot is not None
self._plot = plot
self._view = plot.getViewBox()
plot.setLabel("bottom", "X", units="m")
plot.setLabel("left", "Range", units="m")
plot.hideButtons()
self._view.enableAutoRange(x=False, y=False)
self._view.setMouseEnabled(x=False, y=False)
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._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._geometry_signature: tuple[object, ...] | None = None
self._selected_geometry = (np.empty((0, 3)), np.empty((0, 3)))
def _clear_annotations(self) -> None:
for item in self._labels:
self._plot.removeItem(item)
self._labels.clear()
def clear(self) -> None:
"""Clear displayed data while retaining reusable graphics items."""
self._clear_annotations()
self._image.hide()
for marker in self._markers:
marker.hide()
self._plot.setTitle(_title())
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),
settings.input_positions, settings.output_positions,
)
if signature != self._geometry_signature:
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: GuiGprStateModel, geometry: GprModel) -> None:
tx, rx = self._geometry(settings, geometry)
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 _draw_image(self, accumulator: ResultPayload) -> None:
image = np.asarray(accumulator.image, dtype=np.float32)
self._image.setImage(image, autoLevels=False)
self._image.setRect(image_rect(accumulator.image_x_axis, accumulator.image_y_axis))
self._image.setLookupTable(self._lookup_table)
self._image.setLevels((float(np.min(image)), float(np.max(image) + 1e-6)))
self._image.show()
def _label(self, text: str, x: float, y: float) -> pg.TextItem:
label = pg.TextItem(text=text, color=PLOT_FOREGROUND, fill=pg.mkBrush(*OVERLAY_FILL), anchor=(0.0, 1.0))
label.setZValue(40)
label.setPos(x, y)
self._plot.addItem(label)
self._labels.append(label)
return label
@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, objects_only: bool, x_span: float, z_span: float,
) -> None:
if rows.size == 0:
self._points.hide()
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):.2f}"
if objects_only:
text += f" | x={x:.1f} | z={z:.1f}"
label = self._label(text, x, z)
if objects_only:
self._place_object_label(label, x, z, x_span, z_span, occupied)
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)
rows = gpr_object_rows(collection)
if objects_only:
if accumulator is None and rows.size == 0:
self.clear()
return False
elif accumulator is None or not self._valid_accumulator(accumulator):
self.clear()
return False
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)
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._clear_annotations()
self._draw_geometry(settings, geometry)
if objects_only or accumulator is None:
self._image.hide()
else:
self._draw_image(accumulator)
self._draw_points(rows, objects_only, x_max - x_min, z_max - z_min)
self._plot.setTitle(_title(objects_only=objects_only))
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