new GPR parameters and some UI fixes

This commit is contained in:
Ayzen
2026-04-07 12:55:26 +03:00
parent 4b78c2808d
commit 202993325d
27 changed files with 1494 additions and 168 deletions
@@ -359,6 +359,249 @@ class AppWindowPlotMixin:
self._sync_bscan_history_from_results()
return self._draw_bscan_heatmap_from_history()
def _configure_bscan_plot_axes(self) -> None:
"""Apply persistent B-scan plot axis labels and base view settings."""
plot = self._bscan_plot
plot_item = plot.getPlotItem()
plot_item.showAxis("left", show=True)
plot_item.showAxis("bottom", show=True)
plot.setLabel("bottom", "Sweep #")
plot.setLabel("left", "Range", units="m")
view_box = plot.getViewBox()
view_box.invertY(False)
view_box.enableAutoRange(x=False, y=False)
def _draw_bscan_heatmap_from_history(self) -> bool:
"""Render B-scan heatmap from currently cached history arrays."""
display_key = self._pick_bscan_display_key()
@@ -391,13 +634,7 @@ class AppWindowPlotMixin:
image_item.setLevels(self._bscan_levels(sweeps, axis_mode))
self._bscan_plot.clear()
view_box = self._bscan_plot.getViewBox()
view_box.invertY(True)
view_box.enableAutoRange(x=False, y=False)
self._bscan_plot.getPlotItem().showAxis("left", show=True)
self._bscan_plot.getPlotItem().showAxis("bottom", show=True)
self._bscan_plot.setLabel("bottom", "Sweep #")
self._bscan_plot.setLabel("left", "Depth", units="m")
self._configure_bscan_plot_axes()
self._bscan_plot.addItem(image_item)
self._bscan_plot.setXRange(x_min, x_max, padding=0.02)
self._bscan_plot.setYRange(depth_min, depth_max, padding=0.02)
@@ -538,6 +775,7 @@ class AppWindowPlotMixin:
"""Clear latest GPR plot surface."""
if not hasattr(self, "_gpr_plot"):
return
self._configure_gpr_plot_axes()
self._clear_gpr_point_labels()
self._clear_gpr_region_labels()
self._clear_gpr_region_masks()
@@ -557,23 +795,27 @@ class AppWindowPlotMixin:
self._gpr_region_centers_item.hide()
self._gpr_plot.setTitle(f"GPR {self._gpr_config_mode.currentText()}")
def _ensure_gpr_plot_items(self) -> None:
"""Create persistent GPR plot items once and reuse them on redraw."""
if self._gpr_image_item is not None:
return
def _configure_gpr_plot_axes(self) -> None:
"""Apply persistent GPR plot axis labels and base view settings."""
plot = self._gpr_plot
plot_item = plot.getPlotItem()
plot_item.showAxis("left", show=True)
plot_item.showAxis("bottom", show=True)
plot_item.setClipToView(True)
plot.setLabel("bottom", "X", units="m")
plot.setLabel("left", "Depth", units="m")
plot.setLabel("left", "Range", units="m")
view_box = plot.getViewBox()
view_box.invertY(False)
view_box.enableAutoRange(x=False, y=False)
def _ensure_gpr_plot_items(self) -> None:
"""Create persistent GPR plot items once and reuse them on redraw."""
if self._gpr_image_item is not None:
return
plot = self._gpr_plot
self._configure_gpr_plot_axes()
if self._gpr_lookup_table is None:
self._gpr_lookup_table = self._build_lut(["#081c15", "#1b4332", "#ffd166", "#f94144"])
@@ -694,6 +936,12 @@ class AppWindowPlotMixin:
return self._gpr_selected_geometry
def _draw_gpr_map(self, collection: ResultCollection) -> bool:
"""Draw latest collection-level GPR plot according to current render mode."""
if self._gpr_render_mode.currentText() == "objects_only":
return self._draw_gpr_objects_only(collection)
return self._draw_gpr_heatmap(collection)
def _draw_gpr_heatmap(self, collection: ResultCollection) -> bool:
"""Draw latest collection-level GPR accumulator and annotations."""
accumulator_payload = self._collection_payload_by_name(collection, "gpr_accumulator", kind=3)
if accumulator_payload is None:
@@ -729,36 +977,9 @@ class AppWindowPlotMixin:
self._gpr_image_item.show()
plot.setXRange(x_min, x_max, padding=0.02)
plot.setYRange(min(0.0, y_min), y_max, padding=0.02)
plot.setYRange(self._gpr_display_y_min(y_min, y_max), y_max, padding=0.02)
x_tx, x_rx = self._selected_gpr_geometry()
if x_tx.size > 0:
self._gpr_tx_item.setData(
x=x_tx,
y=np.zeros_like(x_tx),
symbol="t",
size=13,
brush=pg.mkBrush("#ff595e"),
pen=pg.mkPen("#ffca3a", width=1.0),
)
self._gpr_tx_item.show()
else:
self._gpr_tx_item.setData(x=[], y=[])
self._gpr_tx_item.hide()
if x_rx.size > 0:
self._gpr_rx_item.setData(
x=x_rx,
y=np.zeros_like(x_rx),
symbol="t1",
size=13,
brush=pg.mkBrush("#4cc9f0"),
pen=pg.mkPen("#e0fbfc", width=1.0),
)
self._gpr_rx_item.show()
else:
self._gpr_rx_item.setData(x=[], y=[])
self._gpr_rx_item.hide()
self._draw_gpr_geometry_markers()
points_payload = self._collection_payload_by_name(collection, "gpr_points", kind=4)
if points_payload is not None and np.asarray(points_payload.table).size > 0:
@@ -824,6 +1045,242 @@ class AppWindowPlotMixin:
plot.setUpdatesEnabled(True)
return True
@staticmethod
def _normalized_display_range(start: float, stop: float, *, minimum_span: float = 0.1) -> tuple[float, float]:
"""Return ordered display bounds with a non-zero span."""
lower = min(float(start), float(stop))
upper = max(float(start), float(stop))
if upper - lower >= minimum_span:
return lower, upper
center = 0.5 * (lower + upper)
half_span = 0.5 * minimum_span
return center - half_span, center + half_span
def _gpr_visible_object_bounds(self) -> tuple[float, float, float, float]:
"""Return normalized object-only visible X/Z bounds from GUI controls."""
x_min, x_max = self._normalized_display_range(
float(self._gpr_visible_x_min_m.value()),
float(self._gpr_visible_x_max_m.value()),
minimum_span=0.1,
)
z_min, z_max = self._normalized_display_range(
float(self._gpr_visible_z_min_m.value()),
float(self._gpr_visible_z_max_m.value()),
minimum_span=0.1,
)
return x_min, x_max, z_min, z_max
@staticmethod
def _gpr_display_y_min(z_min: float, z_max: float) -> float:
"""Return lower GPR display bound with a small negative margin for antenna markers."""
lower = min(0.0, float(z_min))
span = max(float(z_max) - float(z_min), 1e-6)
marker_margin = max(span * 0.03, 0.06)
return lower - marker_margin
def _draw_gpr_geometry_markers(self) -> None:
"""Render selected Tx/Rx geometry markers on current GPR plot."""
x_tx, x_rx = self._selected_gpr_geometry()
if x_tx.size > 0:
self._gpr_tx_item.setData(
x=x_tx,
y=np.zeros_like(x_tx),
symbol="t",
size=13,
brush=pg.mkBrush("#ff595e"),
pen=pg.mkPen("#ffca3a", width=1.0),
)
self._gpr_tx_item.show()
else:
self._gpr_tx_item.setData(x=[], y=[])
self._gpr_tx_item.hide()
if x_rx.size > 0:
self._gpr_rx_item.setData(
x=x_rx,
y=np.zeros_like(x_rx),
symbol="t1",
size=13,
brush=pg.mkBrush("#4cc9f0"),
pen=pg.mkPen("#e0fbfc", width=1.0),
)
self._gpr_rx_item.show()
else:
self._gpr_rx_item.setData(x=[], y=[])
self._gpr_rx_item.hide()
@staticmethod
def _format_gpr_object_label(x_m: float, z_m: float, pair_count: float) -> str:
"""Format object-only annotation text with pair count and coordinates."""
return f"{int(round(pair_count))} | x={x_m:.1f} | z={z_m:.1f}"
@staticmethod
def _expanded_scene_rect(rect: QRectF, *, padding_px: float = 4.0) -> QRectF:
"""Return scene rect padded to keep labels visually separated."""
return rect.adjusted(-padding_px, -padding_px, padding_px, padding_px)
@staticmethod
def _scene_rect_intersects_any(rect: QRectF, occupied_rects: list[QRectF]) -> bool:
"""Return whether candidate label rect intersects any already placed label."""
return any(rect.intersects(occupied_rect) for occupied_rect in occupied_rects)
@staticmethod
def _gpr_object_label_candidates(
x_m: float,
z_m: float,
*,
x_span: float,
z_span: float,
) -> list[tuple[float, float, tuple[float, float]]]:
"""Return candidate label placements around one object."""
x_offset = max(x_span * 0.015, 0.02)
z_offset = max(z_span * 0.02, 0.02)
return [
(x_m + x_offset, z_m - z_offset, (0.0, 1.0)),
(x_m + x_offset, z_m + z_offset, (0.0, 0.0)),
(x_m - x_offset, z_m - z_offset, (1.0, 1.0)),
(x_m - x_offset, z_m + z_offset, (1.0, 0.0)),
(x_m, z_m - (z_offset * 1.35), (0.5, 1.0)),
(x_m, z_m + (z_offset * 1.35), (0.5, 0.0)),
(x_m + (x_offset * 2.2), z_m - (z_offset * 1.5), (0.0, 1.0)),
(x_m + (x_offset * 2.2), z_m + (z_offset * 1.5), (0.0, 0.0)),
(x_m - (x_offset * 2.2), z_m - (z_offset * 1.5), (1.0, 1.0)),
(x_m - (x_offset * 2.2), z_m + (z_offset * 1.5), (1.0, 0.0)),
]
def _place_gpr_object_label(
self,
*,
label: pg.TextItem,
x_m: float,
z_m: float,
x_span: float,
z_span: float,
occupied_scene_rects: list[QRectF],
) -> None:
"""Place one object label using the first non-overlapping candidate position."""
last_rect: QRectF | None = None
for label_x, label_z, anchor in self._gpr_object_label_candidates(
x_m,
z_m,
x_span=x_span,
z_span=z_span,
):
label.setAnchor(anchor)
label.setPos(label_x, label_z)
candidate_rect = self._expanded_scene_rect(label.sceneBoundingRect())
last_rect = candidate_rect
if not self._scene_rect_intersects_any(candidate_rect, occupied_scene_rects):
occupied_scene_rects.append(candidate_rect)
return
if last_rect is not None:
occupied_scene_rects.append(last_rect)
def _gpr_object_rows(self, collection: ResultCollection) -> np.ndarray:
"""Return object rows as `[x_m, z_m, pair_count]` from current GPR result payload."""
points_payload = self._collection_payload_by_name(collection, "gpr_points", kind=4)
if points_payload is not None:
points = np.asarray(points_payload.table, dtype=np.float32)
if points.ndim == 2 and points.shape[1] >= 3:
return points[:, :3]
centers_payload = self._collection_payload_by_name(collection, "gpr_region_centers", kind=4)
if centers_payload is not None:
centers = np.asarray(centers_payload.table, dtype=np.float32)
if centers.ndim == 2 and centers.shape[1] >= 3:
return centers[:, :3]
return np.zeros((0, 3), dtype=np.float32)
def _filtered_gpr_object_rows(self, collection: ResultCollection) -> np.ndarray:
"""Return object rows filtered by minimum pair count and visible X/Z bounds."""
rows = self._gpr_object_rows(collection)
if rows.size == 0:
return rows
x_min, x_max, z_min, z_max = self._gpr_visible_object_bounds()
min_pair_count = float(self._gpr_min_visible_pair_count.value())
finite_mask = np.all(np.isfinite(rows[:, :3]), axis=1)
visible_mask = (
finite_mask
& (rows[:, 2] >= min_pair_count)
& (rows[:, 0] >= x_min)
& (rows[:, 0] <= x_max)
& (rows[:, 1] >= z_min)
& (rows[:, 1] <= z_max)
)
return rows[visible_mask]
def _draw_gpr_objects_only(self, collection: ResultCollection) -> bool:
"""Draw only detected GPR objects inside configured X/Z bounds."""
accumulator_payload = self._collection_payload_by_name(collection, "gpr_accumulator", kind=3)
object_rows = self._filtered_gpr_object_rows(collection)
if accumulator_payload is None and object_rows.size == 0:
self._clear_gpr_plot()
return False
x_min, x_max, z_min, z_max = self._gpr_visible_object_bounds()
plot = self._gpr_plot
plot.setUpdatesEnabled(False)
try:
self._ensure_gpr_plot_items()
plot.getViewBox().invertY(False)
self._clear_gpr_point_labels()
self._clear_gpr_region_labels()
self._clear_gpr_region_masks()
self._gpr_image_item.hide()
self._draw_gpr_geometry_markers()
self._gpr_region_centers_item.setData(x=[], y=[])
self._gpr_region_centers_item.hide()
if object_rows.size > 0:
self._gpr_points_item.setData(
x=object_rows[:, 0],
y=object_rows[:, 1],
symbol="o",
size=18,
brush=pg.mkBrush("#ff4d4f"),
pen=pg.mkPen("#ff4d4f", width=1.6),
)
self._gpr_points_item.show()
occupied_scene_rects: list[QRectF] = []
x_span = x_max - x_min
z_span = z_max - z_min
for x_value, z_value, pair_count in object_rows:
label = pg.TextItem(
text=self._format_gpr_object_label(
float(x_value),
float(z_value),
float(pair_count),
),
color="#ffd6d9",
anchor=(0.0, 1.0),
)
label.setZValue(40)
plot.addItem(label)
self._place_gpr_object_label(
label=label,
x_m=float(x_value),
z_m=float(z_value),
x_span=x_span,
z_span=z_span,
occupied_scene_rects=occupied_scene_rects,
)
self._gpr_point_labels.append(label)
else:
self._gpr_points_item.setData(x=[], y=[])
self._gpr_points_item.hide()
plot.setXRange(x_min, x_max, padding=0.0)
plot.setYRange(self._gpr_display_y_min(z_min, z_max), z_max, padding=0.0)
plot.setTitle(f"GPR {self._gpr_config_mode.currentText()} Objects Only")
finally:
plot.setUpdatesEnabled(True)
return True
def _result_collection_has_trace(self, collection: ResultCollection) -> bool:
"""Return `True` when collection contains at least one trace payload."""
if collection.collection_payloads: