Refactor radar system

This commit is contained in:
Ayzen
2026-10-05 16:46:28 +03:00
parent 515049d2ed
commit 8f097dfa2e
179 changed files with 8212 additions and 6676 deletions
@@ -198,7 +198,7 @@ class AppWindowBscanPlotMixin:
def _draw_bscan_heatmap(self, _collection: ResultCollection) -> bool:
"""Draw B-scan image rebuilt from processed result history."""
self._disable_phase_axis()
self._trace_phase_plot.clear()
self._sync_bscan_history_from_results()
return self._draw_bscan_heatmap_from_history()
@@ -256,8 +256,8 @@ class AppWindowBscanPlotMixin:
image_item.setRect(QRectF(x_min, depth_min, sweep_width, depth_span))
axis_mode = self._bscan_axis.currentText()
image_item.setLookupTable(self._bscan_lookup_table(axis_mode))
image_item.setLevels(self._bscan_levels(sweeps, axis_mode))
image_item.setLookupTable(bscan_lookup_table(axis_mode))
image_item.setLevels(bscan_levels(sweeps, axis_mode))
self._bscan_plot.clear()
self._configure_bscan_plot_axes()
@@ -463,53 +463,8 @@ class AppWindowBscanPlotMixin:
except ValueError:
return None
def _bscan_lookup_table(self, axis_mode: str) -> np.ndarray:
"""Return lookup table for current B-scan axis mode."""
return bscan_lookup_table(axis_mode)
@staticmethod
def _build_lut(stops: list[str], *, size: int = 256) -> np.ndarray:
"""Backward-compatible wrapper around LUT builder."""
return build_lut(stops, size=size)
@staticmethod
def _bscan_levels(sweeps: np.ndarray, axis_mode: str) -> tuple[float, float]:
"""Return display levels for B-scan image."""
return bscan_levels(sweeps, axis_mode)
def _clear_bscan_plot_history(self) -> None:
"""Drop cached B-scan history and invalidate cache signature."""
self._bscan_history_by_combo.clear()
self._bscan_depth_axis_by_combo.clear()
self._bscan_render_signature = None
def _ensure_phase_view_box(self) -> pg.ViewBox:
"""Create or return secondary right-axis ViewBox for phase curves."""
plot_item = self._bscan_plot.getPlotItem()
phase_view_box = self._phase_viewbox
if phase_view_box is None:
phase_view_box = pg.ViewBox()
self._phase_viewbox = phase_view_box
plot_item.scene().addItem(phase_view_box)
plot_item.getAxis("right").linkToView(phase_view_box)
phase_view_box.setXLink(plot_item.vb)
plot_item.vb.sigResized.connect(self._update_phase_view_box_geometry)
self._update_phase_view_box_geometry()
return phase_view_box
def _update_phase_view_box_geometry(self) -> None:
"""Keep right-axis ViewBox geometry in sync with main plot ViewBox."""
phase_view_box = self._phase_viewbox
if phase_view_box is None:
return
plot_item = self._bscan_plot.getPlotItem()
phase_view_box.setGeometry(plot_item.vb.sceneBoundingRect())
phase_view_box.linkedViewChanged(plot_item.vb, phase_view_box.XAxis)
def _clear_phase_overlay(self) -> None:
"""Remove all phase curves from secondary ViewBox."""
self._trace_phase_plot.clear()
def _disable_phase_axis(self) -> None:
"""Hide right axis and clear phase overlay when phase is not rendered."""
self._clear_phase_overlay()
@@ -6,6 +6,7 @@ from PyQt6.QtCore import QRectF
import numpy as np
import pyqtgraph as pg
from python_app.gui.controllers.app_window_plot.bscan_plot_mixin import build_lut
from python_app.models.dataset_model import ResultCollection
from python_app.orchestration.gpr_locator import (
collection_payload_by_name as gpr_collection_payload_by_name,
@@ -95,7 +96,7 @@ class AppWindowGprPlotMixin:
self._configure_gpr_plot_axes()
if self._gpr_lookup_table is None:
self._gpr_lookup_table = self._build_lut(["#081c15", "#1b4332", "#ffd166", "#f94144"])
self._gpr_lookup_table = build_lut(["#081c15", "#1b4332", "#ffd166", "#f94144"])
self._gpr_image_item = pg.ImageItem(axisOrder="row-major")
self._gpr_image_item.setZValue(0)
@@ -179,29 +180,29 @@ class AppWindowGprPlotMixin:
output_positions_text = self._gpr_output_positions_input.text()
requested_inputs = tuple(self._parse_csv_int_list(input_positions_text))
requested_outputs = tuple(self._parse_csv_int_list(output_positions_text))
tx_entries = self._gpr_tx_geometry_input.records()
rx_entries = self._gpr_rx_geometry_input.records()
signature = (
self._gpr_tx_geometry_input.toPlainText(),
self._gpr_rx_geometry_input.toPlainText(),
tuple(tuple(entry.values()) for entry in tx_entries),
tuple(tuple(entry.values()) for entry in rx_entries),
requested_inputs,
requested_outputs,
)
if signature == self._gpr_geometry_signature and self._gpr_selected_geometry is not None:
return self._gpr_selected_geometry
tx_entries = self._parse_gpr_tx_geometry_text(signature[0])
rx_entries = self._parse_gpr_rx_geometry_text(signature[1])
requested_input_set = set(requested_inputs)
requested_output_set = set(requested_outputs)
rx_entries = sorted(rx_entries, key=lambda entry: int(entry.input_pos))
tx_entries = sorted(tx_entries, key=lambda entry: int(entry.output_pos))
rx_entries = sorted(rx_entries, key=lambda entry: entry["input_pos"])
tx_entries = sorted(tx_entries, key=lambda entry: entry["output_pos"])
if requested_input_set:
rx_entries = [entry for entry in rx_entries if int(entry.input_pos) in requested_input_set]
rx_entries = [entry for entry in rx_entries if entry["input_pos"] in requested_input_set]
if requested_output_set:
tx_entries = [entry for entry in tx_entries if int(entry.output_pos) in requested_output_set]
tx_entries = [entry for entry in tx_entries if entry["output_pos"] in requested_output_set]
x_tx = np.asarray([float(entry.x_m) for entry in tx_entries], dtype=np.float32)
x_rx = np.asarray([float(entry.x_m) for entry in rx_entries], dtype=np.float32)
x_tx = np.asarray([entry["x_m"] for entry in tx_entries], dtype=np.float32)
x_rx = np.asarray([entry["x_m"] for entry in rx_entries], dtype=np.float32)
self._gpr_geometry_signature = signature
self._gpr_selected_geometry = (x_tx, x_rx)
return self._gpr_selected_geometry
@@ -1,14 +1,17 @@
"""3D SAR views of the volume published by the sar3d processor.
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:
the ground away from the radar. Payloads share normalization by the maximum
away from the Y boundaries:
- ``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.
- ``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.
"""
from __future__ import annotations
@@ -22,7 +25,7 @@ from PyQt6.QtCore import QRectF, Qt
from python_app.gui.controllers.app_window_plot.bscan_plot_mixin import build_lut
from python_app.models.dataset_model import ResultCollection
# Colours start at this fraction of the maximum; weaker voxels stay transparent,
# Colours start at this fraction of the normalization reference; weaker voxels stay transparent,
# like those under the threshold of the notebook's point cloud.
DISPLAY_THRESHOLD = 0.75
@@ -31,7 +34,7 @@ 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),
"sar3d_oblique": ("3D projection", "", "", None),
}
# Corners of the scene box indexed by bits (x, y, z); an edge joins corners one bit apart.
@@ -40,6 +43,22 @@ _BOX_EDGES = [(a, b) for a, b in itertools.combinations(range(8), 2) if bin(a ^
_AXIS_EDGES = {"X": (0, 1), "Y": (1, 3), "Z": (1, 5)}
def sar3d_status(collection: ResultCollection) -> tuple[int, str] | None:
"""Decode processor status without letting malformed telemetry break a Qt slot."""
payload = next((item for item in collection.collection_payloads
if item.processing_name == "sar3d_status" and item.kind == 4), None)
if payload is None:
return None
table = payload.table
if table.ndim != 2 or table.shape[0] != 1 or table.shape[1] == 0:
return 2, "Invalid SAR3D status payload"
row = table[0]
if (not np.isfinite(row).all() or not np.equal(row, np.floor(row)).all()
or row[0] not in (0, 1, 2, 3) or np.any((row[1:] < 0) | (row[1:] > 255))):
return 2, "Invalid SAR3D status payload"
return int(row[0]), bytes(row[1:].astype(np.uint8)).decode("utf-8", errors="replace")
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)
@@ -97,7 +116,7 @@ class AppWindowSar3dPlotMixin:
oblique.addItem(label)
self._sar3d_axis_labels[axis_name] = label
def _clear_sar3d_plot(self) -> None:
def _clear_sar3d_plot(self, message: str = "SAR3D: waiting for measurements") -> None:
"""Clear all four 3D SAR views."""
for name, (title, *_rest) in SAR3D_VIEWS.items():
self._sar3d_images[name].hide()
@@ -105,13 +124,21 @@ class AppWindowSar3dPlotMixin:
self._sar3d_plots[name].setTitle(title)
self._sar3d_box.setData([], [])
self._sar3d_ground.setData([], [])
for label in self._sar3d_axis_labels.values():
label.hide()
self._sar3d_status_label.setText(message)
def _draw_sar3d_projections(self, collection: ResultCollection) -> bool:
"""Draw the 3D SAR views of a result collection; return success."""
"""Draw four maximum projections of the volume; return success."""
status = sar3d_status(collection)
if status is not None and status[0] in (1, 2, 3):
state = {1: "no signal", 2: "error", 3: "warning"}[status[0]]
self._clear_sar3d_plot(f"SAR3D: {state} — {status[1]}")
return False
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()
self._clear_sar3d_plot("SAR3D: waiting for projection data")
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,)
@@ -147,12 +174,14 @@ class AppWindowSar3dPlotMixin:
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
self._sar3d_axis_labels[axis_name].show()
(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}"
f"3D projection | max X {x:+.2f}, Y {y:+.2f}, Z {z:.2f} m | level {level:.3g}"
)
self._sar3d_status_label.setText(f"SAR3D: ready — {status[1]}" if status else "SAR3D: ready")
return True