fix graph and white background added
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user