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