fix graph and white background added

This commit is contained in:
Ayzen
2026-10-15 14:46:06 +03:00
parent 5102ed751c
commit 9a9b8ce5c7
35 changed files with 730 additions and 463 deletions
+3 -3
View File
@@ -116,14 +116,14 @@ class BscanReplay:
finally:
self.active = False
# Tap and results rings drop independently: only these replayed keys belong
# in the refreshed image; unrelated runtime results may still use old settings.
# Tap and results rings drop independently: only these replayed keys belong in the
# refreshed image. One image never mixes settings, so unreturned sweeps are left out.
self.history.replay_results[:] = [replayed[key] for key in keys if key in replayed]
received = sum(key in replayed for key in keys[:sent])
if received < sent:
self._warning(
f"B-scan replay recovered {received} of {sent} re-sent sweeps; "
"some columns may still show their captured settings.",
"the image leaves the others out.",
once_key=f"bscan_replay_incomplete_{sent}_{received}",
)
self._debug(f"B-scan replay finished: sent={sent}, recovered={received}.")
@@ -120,27 +120,15 @@ class LiveSettingsController:
def _redraw(self) -> None:
try:
mode = self._mode.currentText()
pipeline, plots, history = self._pipeline, self._pipeline.plots, self._pipeline.history
if mode == "bscan":
if pipeline.can_reprocess_history():
pipeline.schedule_replay()
else:
pipeline.drain_results(timeout_s=0.25, poll_s=0.01)
plots.draw_bscan()
mode, pipeline = self._mode.currentText(), self._pipeline
if mode == "bscan" and pipeline.can_reprocess_history():
pipeline.schedule_replay()
elif mode == "bscan":
pipeline.drain_results(timeout_s=0.25, poll_s=0.01)
elif mode in _GEOMETRY_MODES:
latest = pipeline.drain_results(timeout_s=0.8, poll_s=0.02)
collection = latest if latest is not None else history.results[-1] if history.results else None
if mode == "sar3d":
plots.sar3d.clear()
if collection is not None:
plots.draw(collection)
elif collection is None or not plots.draw(collection):
plots.clear_gpr()
elif history.results:
plots.draw(history.results[-1])
else:
plots.trace.clear()
# Only the published revision is drawn: wait for the newest sweep recomputed with it.
pipeline.drain_results(timeout_s=0.8, poll_s=0.02, revision=pipeline.plots.required_revision)
pipeline.plots.redraw()
except Exception as exc:
self._log.show_exception("Failed to render processing settings", exc)
@@ -171,7 +159,7 @@ class LiveSettingsController:
def locator_changed(self, *_args: object) -> None:
if self._mode.currentText() == "gpr" and self._publish_changes():
self.visual_changed()
self._redraw()
def set_mode_page(self, mode: str) -> None:
pages = self._processing.pages
@@ -80,6 +80,9 @@ class PipelineController:
self._raw_reader: ShmRingReader | None = None
self._pre_reader: ShmRingReader | None = None
self._result_reader: ShmRingReader | None = None
# Results read anywhere (polling, Stop/pause drains, snapshots) await display;
# the next poll shows the newest acquisition, not merely the last one read.
self._display_pending = False
self.active_config: RunConfigModel | None = None
self.active_config_path: Path | None = None
self.stop_generation = 0
@@ -203,6 +206,7 @@ class PipelineController:
raise error
if not operation.valid:
raise InterruptedError("Pipeline operation cancelled")
self._display_pending = False # the pausing operation owns the display
return operation
except BaseException:
operation.finish(resume=False)
@@ -478,11 +482,14 @@ class PipelineController:
finally:
if self.single_capture_active:
self._check_single_capture_deadline()
elif latest[2] is not None:
self._restart_count = 0
started = time.monotonic_ns()
self.plots.draw(latest[2])
self.metrics.record("rendering", time.monotonic_ns() - started)
elif self._display_pending:
if latest[2] is not None:
self._restart_count = 0 # results flow again
self._display_pending = False
if self.history.results:
started = time.monotonic_ns()
self.plots.draw(self.history.results[-1])
self.metrics.record("rendering", time.monotonic_ns() - started)
except Exception as exc:
self._report_ui_error("Result display", exc)
@@ -634,6 +641,7 @@ class PipelineController:
self.metrics.record("processing", collection.processing_duration_ns)
if self.history.record_result(collection):
self.recording.record("results", collection)
self._display_pending = True
latest = collection
return latest
@@ -662,17 +670,20 @@ class PipelineController:
stable = 0 if self.drain_once() else stable + 1
self.pump_events(poll_s)
def drain_results(self, *, timeout_s: float, poll_s: float) -> ResultCollection | None:
deadline, stable = time.monotonic() + timeout_s, 0
latest_seen = None
while self._result_reader is not None and time.monotonic() < deadline and (latest_seen is None or stable < 2):
latest = self.read_results()
if latest is not None:
latest_seen, stable = latest, 0
elif latest_seen is not None:
def drain_results(self, *, timeout_s: float, poll_s: float, revision: int | None = None) -> None:
"""Read results until the timeout; stop two quiet polls after results arrive or,
given a revision, as soon as the newest result carries it."""
deadline, seen, stable = time.monotonic() + timeout_s, False, 0
while self._result_reader is not None and time.monotonic() < deadline:
if self.read_results() is not None:
seen, stable = True, 0
elif seen:
stable += 1
if revision is None and stable >= 2:
return
if revision is not None and self.history.results and self.history.results[-1].settings_revision == revision:
return
self.pump_events(poll_s)
return latest_seen
def drain_snapshot(self) -> None:
initial_readers = self.readers
+45 -26
View File
@@ -2,6 +2,7 @@
from collections.abc import Callable
import pyqtgraph as pg
from PyQt6.QtCore import QSignalBlocker
from PyQt6.QtWidgets import QComboBox, QPushButton, QStackedWidget, QWidget
@@ -14,8 +15,12 @@ from python_app.models.dataset_model import ResultCollection, TraceData
from python_app.models.gui_profile_schema import GuiProcessingStateModel
from python_app.models.run_config_schema import GprModel
Renderer = BscanRenderer | GprRenderer | Sar3dRenderer | TraceRenderer
class PlotController:
"""Own the plot views: renderers draw and clear only through it, and it counts image changes."""
def __init__(
self, history: HistoryController, settings: Callable[[], GuiProcessingStateModel],
geometry: Callable[[], GprModel], running: Callable[[], bool],
@@ -36,9 +41,28 @@ class PlotController:
for renderer in (self.bscan, self.gpr, self.sar3d, self.trace):
self.stack.addWidget(renderer.widget)
self.stack.setCurrentWidget(self.trace.widget)
# Counts changes of the shown image (drawing, page, size, view range, a legend click
# hiding a curve) for observers such as the web stream. QGraphicsScene.changed must stay
# unconnected: it switches Qt to a legacy update path that leaves stale pixels behind.
self.revision = 0
self.stack.currentChanged.connect(self._changed)
for plot in self.stack.findChildren(pg.PlotWidget):
item = plot.getPlotItem()
assert item is not None
view = item.getViewBox()
view.sigResized.connect(self._changed)
view.sigRangeChanged.connect(self._changed)
for panel in (self.trace.magnitude, self.trace.phase):
panel.legend.sigSampleClicked.connect(self._changed)
self._combo: QComboBox | None = None
self._next: QPushButton | None = None
def _changed(self, *_args: object) -> None:
self.revision += 1
def _renderer(self, mode: str) -> Renderer:
return {"bscan": self.bscan, "gpr": self.gpr, "sar3d": self.sar3d}.get(mode, self.trace)
def bind_display_controls(self, combo: QComboBox, next_button: QPushButton) -> None:
self._combo, self._next = combo, next_button
combo.currentIndexChanged.connect(self.on_display_combo_changed)
@@ -55,8 +79,7 @@ class PlotController:
return self.sar3d.grid if current is self.sar3d.widget else current
def set_mode(self, mode: str) -> None:
renderer = {"bscan": self.bscan, "gpr": self.gpr, "sar3d": self.sar3d}.get(mode, self.trace)
self.stack.setCurrentWidget(renderer.widget)
self.stack.setCurrentWidget(self._renderer(mode).widget)
self.sync_display_combos()
def select_combo(self, input_pos: int, output_pos: int) -> None:
@@ -96,6 +119,7 @@ class PlotController:
self.sync_display_combos()
def draw_bscan(self) -> bool:
self._changed()
self.sync_bscan()
window, retained = self.window_scans(), len(self.history.preprocessed)
if retained < window and self.bscan.history_by_combo:
@@ -119,6 +143,7 @@ class PlotController:
return False
if mode == "bscan":
return self.draw_bscan()
self._changed()
if mode == "gpr":
return self.gpr.draw(collection, settings.gpr, self._geometry())
if mode == "sar3d":
@@ -126,42 +151,36 @@ class PlotController:
return self.trace.draw(collection, settings.pass_through)
def redraw(self, *_args: object) -> None:
"""Show the newest result with the current settings."""
mode = self._settings().selected_mode
if mode == "bscan":
self.draw_bscan()
elif mode == "sar3d":
self.sar3d.clear()
if self.history.results:
self.draw(self.history.results[-1])
elif not self.history.results or not self.draw(self.history.results[-1]):
self.clear_current()
return
# Cleared first: a result of another settings revision is not drawn and must not
# leave the previous image, while a renderer's own failure message is kept.
self._changed()
self._renderer(mode).clear()
if self.history.results:
self.draw(self.history.results[-1])
def draw_trace(self, trace: TraceData, title: str) -> None:
self._changed()
self.trace.draw_trace(trace, title, self._settings().pass_through)
def clear_trace(self) -> None:
self._changed()
self.trace.clear()
def clear_bscan(self) -> None:
self._changed()
self.bscan.clear()
self.sync_display_combos()
def clear_gpr(self) -> None:
self.gpr.clear()
def clear_current(self) -> None:
mode = self._settings().selected_mode
if mode == "bscan":
self.clear_bscan()
elif mode == "gpr":
self.clear_gpr()
elif mode == "sar3d":
self.sar3d.clear()
else:
self.trace.clear()
def clear(self) -> None:
self.clear_bscan()
self.clear_gpr()
self.sar3d.clear()
self.trace.clear()
self._changed()
for renderer in (self.bscan, self.gpr, self.sar3d, self.trace):
renderer.clear()
self.sync_display_combos()
@staticmethod
def has_output(collection: ResultCollection) -> bool:
@@ -324,7 +324,7 @@ class PreprocessController:
dialog.clear_capture_log()
dialog.reset_preview()
dialog.set_status(f"{preprocess_asset_display_name(kind)} sequence started")
self._plots.trace.clear()
self._plots.clear_trace()
self._update_capture_state()
logger.info("%s sequence started: set=%s, combos=%d, radar_variants=%d, median_sweeps=%d",
kind, name, session.state().total_count, len(keys), settings.median_sweep_count)
@@ -417,7 +417,7 @@ class PreprocessController:
trace = session.last_captured_trace()
if trace is None:
dialog.reset_preview()
self._plots.trace.clear()
self._plots.clear_trace()
else:
dialog.draw_last_trace(trace, title=f"{display} last trace")
self._plots.draw_trace(trace, f"{display} last trace")
+14 -9
View File
@@ -4,14 +4,13 @@ from __future__ import annotations
from collections import deque
from PyQt6.QtCore import QRectF
import numpy as np
import pyqtgraph as pg
from python_app.models.collection_identity import collection_key
from python_app.models.dataset_model import ResultCollection
from python_app.models.gui_profile_schema import GuiBscanStateModel
from python_app.gui.controllers.renderers.common import build_lut, create_plot
from python_app.gui.controllers.renderers.common import build_lut, create_plot, image_rect
def _result_tail(
@@ -109,16 +108,18 @@ def bscan_levels(sweeps: np.ndarray, axis_mode: str) -> tuple[float, float]:
return -max_abs, max_abs
_TITLE = "B-scan S21"
class BscanRenderer:
"""Own the heatmap and its per-combination arrays; input history stays external."""
def __init__(self) -> None:
self.widget = create_plot()
self.widget = create_plot(_TITLE)
self.widget.setLabel("bottom", "Sweep #")
self.widget.setLabel("left", "Range", units="m")
item = self.widget.getPlotItem()
assert item is not None
item.getViewBox().invertY(False)
item.getViewBox().enableAutoRange(x=False, y=False)
self.image = pg.ImageItem(axisOrder="row-major")
item.addItem(self.image)
@@ -139,24 +140,28 @@ class BscanRenderer:
history = self.history_by_combo.get(key) if key is not None else None
depth = self.depth_axis_by_combo.get(key) if key is not None else None
if not history or depth is None:
self.image.clear()
self._show_empty()
return False
sweeps = apply_mean_ascan_subtraction(history, enabled=settings.subtract_mean_ascan)
depth_min, depth_max = float(np.min(depth)), float(np.max(depth))
count = sweeps.shape[0]
rect = image_rect(np.arange(1, count + 1), depth)
self.image.setImage(sweeps.T, autoLevels=False)
self.image.setRect(QRectF(.5, depth_min, float(count), max(depth_max - depth_min, 1e-6)))
self.image.setRect(rect)
self.image.setLookupTable(bscan_lookup_table(settings.axis))
self.image.setLevels(bscan_levels(sweeps, settings.axis))
item = self.widget.getPlotItem()
assert item is not None
item.getViewBox().setRange(xRange=(.5, .5 + count), yRange=(depth_min, depth_max), padding=.02)
item.getViewBox().setRange(rect, padding=.02)
assert key is not None
self.widget.setTitle(f"B-scan S21 in{key[0]}/out{key[1]} | sweeps={count}")
self.widget.setTitle(f"{_TITLE} in{key[0]}/out{key[1]} | sweeps={count}")
return True
def clear(self) -> None:
self.history_by_combo.clear()
self.depth_axis_by_combo.clear()
self.signature = None
self._show_empty()
def _show_empty(self) -> None:
self.image.clear()
self.widget.setTitle(_TITLE)
+27 -5
View File
@@ -1,15 +1,25 @@
"""Plot construction and color tables shared by radar views."""
"""Plot construction, colors and color tables shared by radar views."""
import numpy as np
import pyqtgraph as pg
from PyQt6.QtCore import QRectF
from python_app.gui.theme import PLOT_FOREGROUND
from python_app.models.run_config_schema import GprModel
from python_app.models.run_config_validation import parse_position_csv
from python_app.gui.theme import PLOT_BACKGROUND
# Series identity colors in fixed order, checked for the white plot surface: lightness band,
# chroma floor and neighbour separation under normal and colorblind vision. Aqua, yellow and
# pink stay under 3:1 contrast with white, so lines are 2 px wide and the legend names pairs.
SERIES_COLORS = ("#2a78d6", "#eb6834", "#1baf7a", "#eda100", "#e87ba4", "#008300", "#6250d6", "#e34948")
# A captured trace shown on its own (main view, capture preview, PNG export).
CAPTURED_TRACE_PEN = pg.mkPen(SERIES_COLORS[0], width=2)
# Behind legends and labels, so their text stays legible over curves and heatmaps.
OVERLAY_FILL = (255, 255, 255, 200)
def create_plot(title: str = "") -> pg.PlotWidget:
plot = pg.PlotWidget(background=PLOT_BACKGROUND, enableMenu=False, title=title)
plot = pg.PlotWidget(enableMenu=False, title=title)
plot.showGrid(x=True, y=True, alpha=0.2)
return plot
@@ -26,8 +36,20 @@ def build_lut(stops: list[str], *, size: int = 256) -> np.ndarray:
return lut
# Same Tx/Rx markers on ground BP and the three planar SAR views.
ANTENNA_STYLES = (("t", "#ff595e", "#ffca3a"), ("t1", "#4cc9f0", "#e0fbfc"))
def image_rect(x_axis: np.ndarray, y_axis: np.ndarray) -> QRectF:
"""Rectangle covering image pixels centred on the axis samples; a lone sample spans one unit."""
(x, width), (y, height) = (_pixel_span(axis) for axis in (x_axis, y_axis))
return QRectF(x, y, width, height)
def _pixel_span(axis: np.ndarray) -> tuple[float, float]:
first, last = float(axis[0]), float(axis[-1])
pitch = (last - first) / (len(axis) - 1) if len(axis) > 1 else 1.0
return first - pitch / 2, last - first + pitch
# Same Tx/Rx markers (symbol, fill, outline) on ground BP and the three planar SAR views.
ANTENNA_STYLES = (("t", SERIES_COLORS[7], PLOT_FOREGROUND), ("t1", SERIES_COLORS[0], PLOT_FOREGROUND))
def selected_antenna_coordinates(geometry: GprModel, inputs: str, outputs: str) -> tuple[np.ndarray, np.ndarray]:
+33 -61
View File
@@ -6,10 +6,13 @@ from PyQt6.QtCore import QRectF
import numpy as np
import pyqtgraph as pg
from python_app.gui.controllers.renderers.common import ANTENNA_STYLES, build_lut, create_plot, selected_antenna_coordinates
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
@@ -59,13 +62,16 @@ class GprRenderer:
"""Draw one collection from explicit display settings and antenna geometry."""
def __init__(self) -> None:
self.widget = create_plot("GPR coherent BP — ground plane")
self.widget = create_plot(_title())
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()
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()
@@ -80,21 +86,6 @@ class GprRenderer:
self._geometry_signature: tuple[object, ...] | None = None
self._selected_geometry = (np.empty((0, 3)), np.empty((0, 3)))
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._plot.removeItem(item)
@@ -102,11 +93,9 @@ class GprRenderer:
def clear(self) -> 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())
@@ -128,8 +117,16 @@ class GprRenderer:
brush=pg.mkBrush(brush), pen=pg.mkPen(pen, width=1.0))
marker.setVisible(len(coordinates) > 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))
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)
@@ -153,6 +150,7 @@ class GprRenderer:
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",
@@ -167,7 +165,7 @@ class GprRenderer:
text = 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")
label = self._label(text, x, z)
if objects_only:
self._place_object_label(label, x, z, x_span, z_span, occupied)
@@ -175,8 +173,6 @@ class GprRenderer:
"""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)
if objects_only:
if accumulator is None and rows.size == 0:
@@ -186,42 +182,18 @@ class GprRenderer:
self.clear()
return False
self.widget.setUpdatesEnabled(False)
try:
self._clear_annotations()
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()
for marker in (self._points,):
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, objects_only, x_max - x_min, z_max - z_min)
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(objects_only=objects_only))
finally:
self.widget.setUpdatesEnabled(True)
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
+20 -25
View File
@@ -24,8 +24,11 @@ import pyqtgraph as pg
from PyQt6.QtCore import QRectF, Qt
from PyQt6.QtWidgets import QGridLayout, QLabel, QVBoxLayout, QWidget
from python_app.gui.controllers.renderers.common import ANTENNA_STYLES, build_lut, create_plot, selected_antenna_coordinates
from python_app.gui.controllers.renderers.common import (
ANTENNA_STYLES, build_lut, create_plot, image_rect, selected_antenna_coordinates,
)
from python_app.gui.result_payloads import collection_payload_by_name
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 GuiSar3dStateModel
from python_app.models.run_config_schema import GprModel
@@ -33,6 +36,9 @@ from python_app.models.run_config_schema import GprModel
# Status codes published by the sar3d processor in its "sar3d_status" table.
SAR3D_STATES = {0: "ready", 1: "no signal", 2: "error", 3: "warning"}
_GROUND_COLOR = (115, 105, 88)
_SCENE_BOX_COLOR = "#898781"
# payload -> (title, x label, y label, scene axes (0 = X, 1 = Y, 2 = Z) along the plot axes)
SAR3D_VIEWS: dict[str, tuple[str, str, str, tuple[int, int] | None]] = {
"sar3d_front": ("Front (X-Y)", "X", "Y", (0, 1)),
@@ -61,13 +67,6 @@ def sar3d_status(collection: ResultCollection) -> tuple[int, str] | None:
return code, 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)
dy = (y_axis[-1] - y_axis[0]) / max(len(y_axis) - 1, 1)
return QRectF(x_axis[0] - dx / 2, y_axis[0] - dy / 2, x_axis[-1] - x_axis[0] + dx, y_axis[-1] - y_axis[0] + dy)
class Sar3dRenderer:
"""Draws the 3D SAR views."""
@@ -85,21 +84,19 @@ class Sar3dRenderer:
grid_layout.setContentsMargins(0, 0, 0, 0)
grid_layout.setSpacing(6)
layout.addWidget(self.grid, stretch=1)
self.plots: dict[str, pg.PlotWidget] = {}
for index, (name, (title, *_rest)) in enumerate(SAR3D_VIEWS.items()):
plot = create_plot(title)
grid_layout.addWidget(plot, index // 2, index % 2)
self.plots[name] = plot
lut = build_lut(["#081c15", "#1b4332", "#ffd166", "#f94144"]) # the GPR palette
lut = np.column_stack([lut, np.full(len(lut), 255, dtype=np.uint8)])
lut[0, 3] = 0 # below SAR_CLOUD_THRESHOLD
self.plots: dict[str, pg.PlotWidget] = {}
self.antennas: dict[str, tuple[pg.ScatterPlotItem, pg.ScatterPlotItem]] = {}
self.images: dict[str, pg.ImageItem] = {}
self.targets: dict[str, pg.PlotCurveItem] = {}
for name, (_title, x_label, y_label, _axes) in SAR3D_VIEWS.items():
plot = self.plots[name]
for index, (name, (title, x_label, y_label, axes)) in enumerate(SAR3D_VIEWS.items()):
plot = create_plot(title)
grid_layout.addWidget(plot, index // 2, index % 2)
self.plots[name] = plot
plot.setAspectLocked(True)
if _axes is not None:
if axes is not None:
markers = (pg.ScatterPlotItem(), pg.ScatterPlotItem())
for marker in markers:
marker.setZValue(4)
@@ -109,42 +106,40 @@ class Sar3dRenderer:
assert item is not None
view = item.getViewBox()
view.setMouseEnabled(x=False, y=False)
view.setMenuEnabled(False)
view.enableAutoRange(x=False, y=False)
item.hideButtons()
image = pg.ImageItem(axisOrder="row-major", lut=lut)
plot.addItem(image)
# Hollow, so the response at the maximum stays visible.
peak = pg.PlotCurveItem(pen=pg.mkPen("w", width=1.5))
peak = pg.PlotCurveItem(pen=pg.mkPen(PLOT_FOREGROUND, width=1.5))
peak.setZValue(3)
plot.addItem(peak)
self.images[name] = image
self.targets[name] = peak
if x_label:
plot.showGrid(x=True, y=True, alpha=0.2)
plot.setLabel("bottom", x_label, units="m")
plot.setLabel("left", y_label, units="m")
if y_label == "Y":
ground = pg.InfiniteLine(pos=0.0, angle=0, pen=pg.mkPen("w", width=1, style=Qt.PenStyle.DashLine))
ground = pg.InfiniteLine(pos=0.0, angle=0,
pen=pg.mkPen(_GROUND_COLOR, width=1, style=Qt.PenStyle.DashLine))
ground.setZValue(2)
plot.addItem(ground)
oblique = self.plots["sar3d_oblique"]
oblique.hideAxis("bottom")
oblique.hideAxis("left")
oblique.setAspectLocked(True)
# The notebook scene's ground, under the image so it shows through weak voxels.
self.ground = pg.PlotCurveItem(
pen=pg.mkPen(115, 105, 88), brush=pg.mkBrush(115, 105, 88, 110), fillLevel="enclosed"
pen=pg.mkPen(_GROUND_COLOR), brush=pg.mkBrush(*_GROUND_COLOR, 110), fillLevel="enclosed"
)
self.ground.setZValue(-1)
oblique.addItem(self.ground)
self.box = pg.PlotCurveItem(pen=pg.mkPen(190, 190, 190, 170), connect="pairs")
self.box = pg.PlotCurveItem(pen=pg.mkPen(_SCENE_BOX_COLOR), connect="pairs")
self.box.setZValue(2)
oblique.addItem(self.box)
self.axis_labels: dict[str, pg.TextItem] = {}
for axis_name in _AXIS_EDGES:
label = pg.TextItem(axis_name, color=(190, 190, 190), anchor=(0.5, 0.5))
label = pg.TextItem(axis_name, color=PLOT_FOREGROUND, anchor=(0.5, 0.5))
oblique.addItem(label)
self.axis_labels[axis_name] = label
self.clear()
@@ -195,7 +190,7 @@ class Sar3dRenderer:
zoom = settings.zoom_percent / 100.0
for name, payload in payloads.items():
title, _x_label, _y_label, axes = SAR3D_VIEWS[name]
rect = _image_rect(payload.image_x_axis, payload.image_y_axis)
rect = image_rect(payload.image_x_axis, payload.image_y_axis)
image = self.images[name]
image.setImage(np.where(payload.image >= settings.cloud_threshold, payload.image, 0),
autoLevels=False, levels=(0, 1))
+32 -46
View File
@@ -6,10 +6,10 @@ from typing import Protocol
import numpy as np
import pyqtgraph as pg
from PyQt6.QtCore import Qt
from PyQt6.QtGui import QPen
from PyQt6.QtWidgets import QVBoxLayout, QWidget
from python_app.gui.controllers.renderers.common import create_plot
from python_app.gui.controllers.renderers.common import CAPTURED_TRACE_PEN, OVERLAY_FILL, SERIES_COLORS, create_plot
from python_app.models.dataset_model import ComboKey, ResultCollection, TraceData
from python_app.models.gui_profile_schema import GuiPassThroughStateModel
from python_app.models.run_config_validation import parse_combos_from_text
@@ -21,9 +21,7 @@ class WarningLogger(Protocol):
CurveKey = tuple[ComboKey, int, str]
Series = tuple[CurveKey, np.ndarray, np.ndarray, str]
_PALETTE = ("#4cc9f0", "#f72585", "#b8f2e6", "#ffd166",
"#90be6d", "#ff595e", "#6a4c93", "#1982c4")
Series = tuple[CurveKey, np.ndarray, np.ndarray, QPen]
class _TracePanel:
@@ -35,57 +33,49 @@ class _TracePanel:
assert item is not None
self.item = item
self.view = item.getViewBox()
self.item.showGrid(x=True, y=True, alpha=0.2)
self.item.setLabel("left", label, units=units)
self.item.setLabel("bottom", "Frequency", units="Hz")
self.item.setDownsampling(mode="peak")
self.item.setClipToView(True)
self.curves: dict[CurveKey, pg.PlotCurveItem] = {}
self.legend: pg.LegendItem | None = None
self._legend_sources: dict[ComboKey, pg.PlotCurveItem] = {}
# Sweeps denser than the screen are drawn as per-bin min/max, so narrow peaks
# and notches stay visible; ordinary sweeps keep every point.
self.item.setDownsampling(auto=True, mode="peak")
self.curves: dict[CurveKey, pg.PlotDataItem] = {}
self.legend = self.item.addLegend(offset=(8, 8), brush=pg.mkBrush(*OVERLAY_FILL), colCount=2)
self.legend.hide()
self._legend_sources: dict[ComboKey, pg.PlotDataItem] = {}
def clear(self) -> None:
self.item.clear()
self.curves.clear()
self.sync_legend({})
def update_curve(self, key: CurveKey, x: np.ndarray, y: np.ndarray,
color: str, *, width: float, dashed: bool = False) -> pg.PlotCurveItem:
pen = pg.mkPen(color, width=width,
style=Qt.PenStyle.DashLine if dashed else Qt.PenStyle.SolidLine)
def update_curve(self, key: CurveKey, x: np.ndarray, y: np.ndarray, pen: QPen) -> pg.PlotDataItem:
curve = self.curves.get(key)
if curve is None:
curve = pg.PlotCurveItem(pen=pen, antialias=False)
curve = pg.PlotDataItem(antialias=False)
self.curves[key] = curve
self.item.addItem(curve)
else:
curve.setPen(pen)
curve.setData(x, y)
curve.setData(x, y, pen=pen) # one update; a separate setPen would redraw the old data first
return curve
def retain_curves(self, active_keys: set[CurveKey]) -> None:
for key in self.curves.keys() - active_keys:
self.item.removeItem(self.curves.pop(key))
def sync_legend(self, sources: dict[ComboKey, pg.PlotCurveItem]) -> None:
def sync_legend(self, sources: dict[ComboKey, pg.PlotDataItem]) -> None:
if self._legend_sources == sources:
return
if self.legend is None and sources:
self.legend = self.item.addLegend(offset=(8, 8))
if self.legend is not None:
self.legend.clear()
for combo in sorted(sources, key=lambda key: (key.input, key.output)):
self.legend.addItem(sources[combo], f"in{combo.input}/out{combo.output}")
self.legend.setVisible(bool(sources))
self.legend.clear()
for combo in sorted(sources, key=lambda key: (key.input, key.output)):
self.legend.addItem(sources[combo], f"in{combo.input}/out{combo.output}")
self.legend.setVisible(bool(sources))
self._legend_sources = sources
class TraceRenderer:
"""Render result traces or a captured trace using explicit display settings."""
def __init__(self, log_warning: WarningLogger, max_points: int = 800) -> None:
def __init__(self, log_warning: WarningLogger) -> None:
self._log_warning = log_warning
self.max_points = max_points
self.widget = QWidget()
layout = QVBoxLayout(self.widget)
layout.setContentsMargins(0, 0, 0, 0)
@@ -102,23 +92,24 @@ class TraceRenderer:
def draw(self, collection: ResultCollection, settings: GuiPassThroughStateModel) -> bool:
combo_filter = self._combo_filter(settings.combo_filter)
colors: dict[ComboKey, str] = {}
series: list[Series] = []
for block in collection.blocks:
# The color follows the pair's place in the run, so filtering never repaints shown pairs.
for position, block in enumerate(collection.blocks):
if combo_filter is not None and block.combo not in combo_filter:
continue
color = colors.setdefault(block.combo, _PALETTE[len(colors) % len(_PALETTE)])
pen = pg.mkPen(SERIES_COLORS[position % len(SERIES_COLORS)], width=2)
for index, payload in enumerate(block.payloads):
if payload.kind != 1 or not payload.trace.size or payload.frequency_hz.size != payload.trace.size:
# Another mode's result (a B-scan range profile) is not S21 over frequency.
if payload.processing_name != "pass_through" or payload.kind != 1:
continue
series.append(((block.combo, index, payload.processing_name),
payload.frequency_hz, payload.trace, color))
payload.frequency_hz, payload.trace, pen))
return self._render(series, settings, "Pass-Through S21")
def draw_trace(self, trace: TraceData, title: str, settings: GuiPassThroughStateModel) -> None:
self.clear()
self._render([((trace.combo, 0, "__single_trace__"), trace.frequency_hz,
trace.s21, "#ffd166")], settings, title, single=True)
trace.s21, CAPTURED_TRACE_PEN)], settings, title, single=True)
def _combo_filter(self, text: str) -> set[ComboKey] | None:
text = text.strip()
@@ -142,27 +133,23 @@ class TraceRenderer:
return False
mag.item.showAxis("bottom", show=not settings.show_phase)
active: set[CurveKey] = set()
mag_sources: dict[ComboKey, pg.PlotCurveItem] = {}
phase_sources: dict[ComboKey, pg.PlotCurveItem] = {}
mag_sources: dict[ComboKey, pg.PlotDataItem] = {}
phase_sources: dict[ComboKey, pg.PlotDataItem] = {}
x_min, x_max = np.inf, -np.inf
for key, frequency, samples, color in series:
for key, frequency, samples, pen in series:
if not samples.size or frequency.size != samples.size:
continue
active.add(key)
x_min, x_max = min(x_min, float(np.min(frequency))), max(x_max, float(np.max(frequency)))
step = max(1, int(np.ceil(samples.size / self.max_points))) if self.max_points > 0 else 1
if settings.show_magnitude:
magnitude = 20.0 * np.log10(np.maximum(np.abs(samples[::step]), 1e-12))
curve = mag.update_curve(key, frequency[::step], magnitude, color,
width=1.8 if single else 1.4)
magnitude = 20.0 * np.log10(np.maximum(np.abs(samples), 1e-12))
curve = mag.update_curve(key, frequency, magnitude, pen)
mag_sources.setdefault(key[0], curve)
if settings.show_phase:
angles = np.angle(samples)
if settings.unwrap_phase:
angles = np.unwrap(angles)
curve = phase.update_curve(key, frequency[::step], np.degrees(angles[::step]),
"#80ed99" if single else color,
width=1.4 if single else 1.2, dashed=single)
curve = phase.update_curve(key, frequency, np.degrees(angles), pen)
phase_sources.setdefault(key[0], curve)
for panel, visible, sources in ((mag, settings.show_magnitude, mag_sources),
@@ -171,7 +158,6 @@ class TraceRenderer:
panel.sync_legend(sources if not single else {})
if visible:
panel.item.setTitle(title)
panel.view.invertY(False)
if active and np.isfinite(x_min) and np.isfinite(x_max):
panel.view.setXRange(x_min, x_max, padding=0.02)
if settings.show_magnitude:
@@ -109,6 +109,8 @@ class SnapshotController:
return
self.pipeline.history_changed()
self.pipeline.plots.redraw()
# Removing the newest sweep brings an older one, maybe never replayed, into a stopped B-scan.
self.pipeline.schedule_replay()
self._log(
f"{action}: dropped raw={dropped_raw}, "
f"preprocessed={dropped_pre}, results={dropped_results}"
+9 -24
View File
@@ -12,7 +12,6 @@ import threading
import time
from typing import TYPE_CHECKING
import pyqtgraph as pg
from PyQt6.QtCore import QBuffer, QIODevice, QObject, pyqtSignal
from PyQt6.QtWidgets import QLineEdit, QSpinBox, QWidget
@@ -100,20 +99,13 @@ class WebBridge(QObject):
self._frame: dict | None = None
self._pending_lock = threading.Lock()
self._pending_calls: set[_WebActionCall] = set()
self._closed = self._streaming = self._grabbing = False
self._dirty = True
self._closed = self._streaming = False
self._grabbed_revision: int | None = None # plot revision shown by the last grab; None forces one
self._last_png: str | None = None
self._last_grab_s = 0.0
self._frame_seq = self._snapshot_errors = 0
self._action_requested.connect(self._run_action)
self._streaming_changed.connect(self._set_streaming)
plots = pipeline.plots
plots.stack.currentChanged.connect(self._mark_dirty)
for plot in plots.stack.findChildren(pg.PlotWidget):
scene = plot.scene()
assert scene is not None
scene.changed.connect(self._mark_dirty)
plot.sigDeviceRangeChanged.connect(self._mark_dirty)
def start_server(self) -> None:
if self._closed or self._server is not None:
@@ -295,26 +287,19 @@ class WebBridge(QObject):
if not self._closed:
self._streaming = enabled
if enabled:
self._dirty = True
def _mark_dirty(self, *_args: object) -> None:
if not self._closed and not self._grabbing:
self._dirty = True
self._grabbed_revision = None
def _grab_frame_if_due(self) -> dict | None:
if self._closed or not self._streaming or not self._dirty:
plots = self._pipeline.plots
if self._closed or not self._streaming or plots.revision == self._grabbed_revision:
return None
now = time.monotonic()
if now - self._last_grab_s < _GRAB_INTERVAL_S:
return None
self._last_grab_s, self._dirty, self._grabbing = now, False, True
try:
png = self._grab_png()
except Exception:
self._dirty = True
raise
finally:
self._grabbing = False
self._last_grab_s = now
png = self._grab_png()
# Rendering applies pending resizes and lazy auto-ranges; this image already shows them.
self._grabbed_revision = plots.revision
if png is None or png == self._last_png:
return None
self._last_png = png