web UI added and refactoring done
This commit is contained in:
@@ -3,10 +3,125 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt6.QtCore import QSignalBlocker
|
||||
from PyQt6.QtWidgets import QCheckBox, QComboBox, QDoubleSpinBox, QLineEdit, QSpinBox
|
||||
|
||||
from python_app.gui.runtime.constraints import validate_processing_mode_constraints
|
||||
from python_app.orchestration.live_processing_config import ProcessingLiveConfig
|
||||
|
||||
_GPR_MODES = ("gpr", "legacy_gpr")
|
||||
|
||||
|
||||
def _is_legacy_gpr(window) -> bool:
|
||||
return window._processing_mode.currentText() == "legacy_gpr"
|
||||
|
||||
|
||||
def _attr(name: str):
|
||||
"""Widget getter for a field backed by a single fixed widget."""
|
||||
return lambda window: getattr(window, name)
|
||||
|
||||
|
||||
def _dual(gpr_attr: str, legacy_attr: str):
|
||||
"""Widget getter for a gpr_* field with separate gpr / legacy_gpr widgets."""
|
||||
return lambda window: getattr(window, legacy_attr if _is_legacy_gpr(window) else gpr_attr)
|
||||
|
||||
|
||||
# The ONE authoritative field <-> widget map (inverse of `_live_processing_config`),
|
||||
# plus each field's display group and the processor mode(s) it applies to. Everything
|
||||
# else the web form needs — input type, combobox options, numeric ranges, the current
|
||||
# value, the enabled state — is read live FROM these widgets by `_build_web_settings_schema`,
|
||||
# so the web hardcodes none of it and always mirrors the desktop. `modes=None` => all modes.
|
||||
# Entries: (field, group, modes, widget_getter).
|
||||
_WEB_LIVE_SCHEMA = [
|
||||
("processor_mode", "Processor", None, _attr("_processing_mode")),
|
||||
("pass_through_fixed_y_enabled", "Pass-through", ("pass_through",), _attr("_pass_through_fixed_y_enabled")),
|
||||
("pass_through_y_min_db", "Pass-through", ("pass_through",), _attr("_pass_through_y_min_db")),
|
||||
("pass_through_y_max_db", "Pass-through", ("pass_through",), _attr("_pass_through_y_max_db")),
|
||||
("bscan_axis", "B-scan", ("bscan",), _attr("_bscan_axis")),
|
||||
("bscan_cut_m", "B-scan", ("bscan",), _attr("_bscan_cut_m")),
|
||||
("bscan_max_depth_m", "B-scan", ("bscan",), _attr("_bscan_max_depth_m")),
|
||||
("bscan_gain", "B-scan", ("bscan",), _attr("_bscan_gain")),
|
||||
("bscan_start_freq_mhz", "B-scan", ("bscan",), _attr("_bscan_start_freq_mhz")),
|
||||
("bscan_stop_freq_mhz", "B-scan", ("bscan",), _attr("_bscan_stop_freq_mhz")),
|
||||
("legacy_gpr_mode", "Mode", ("legacy_gpr",), _attr("_legacy_gpr_config_mode")),
|
||||
("gpr_input_positions", "Geometry & depth", _GPR_MODES, _dual("_gpr_input_positions_input", "_legacy_gpr_input_positions_input")),
|
||||
("gpr_output_positions", "Geometry & depth", _GPR_MODES, _dual("_gpr_output_positions_input", "_legacy_gpr_output_positions_input")),
|
||||
("gpr_min_depth_m", "Geometry & depth", _GPR_MODES, _dual("_gpr_min_depth_m", "_legacy_gpr_min_depth_m")),
|
||||
("gpr_max_depth_m", "Geometry & depth", _GPR_MODES, _dual("_gpr_max_depth_m", "_legacy_gpr_max_depth_m")),
|
||||
("gpr_start_freq_mhz", "Geometry & depth", _GPR_MODES, _dual("_gpr_start_freq_mhz", "_legacy_gpr_start_freq_mhz")),
|
||||
("gpr_stop_freq_mhz", "Geometry & depth", _GPR_MODES, _dual("_gpr_stop_freq_mhz", "_legacy_gpr_stop_freq_mhz")),
|
||||
("gpr_imaging_plane_y_m", "Geometry & depth", ("gpr",), _attr("_gpr_imaging_plane_y_m")),
|
||||
("gpr_range_comp_power", "Imaging", ("gpr",), _attr("_gpr_range_comp_power")),
|
||||
("gpr_angle_comp_power", "Imaging", ("gpr",), _attr("_gpr_angle_comp_power")),
|
||||
("gpr_score_mode", "Imaging", ("gpr",), _attr("_gpr_score_mode")),
|
||||
("gpr_background_subtract_enabled", "Imaging", _GPR_MODES, _dual("_gpr_background_subtract_enabled", "_legacy_gpr_background_subtract_enabled")),
|
||||
("gpr_background_mean_count", "Imaging", _GPR_MODES, _dual("_gpr_background_mean_count", "_legacy_gpr_background_mean_count")),
|
||||
("gpr_remove_sidelobe_objects_enabled", "Imaging", ("gpr",), _attr("_gpr_remove_sidelobe_objects_enabled")),
|
||||
("gpr_min_visible_score", "Detection", ("gpr",), _attr("_gpr_min_visible_score")),
|
||||
("gpr_max_detected_objects_to_draw", "Detection", ("gpr",), _attr("_gpr_max_detected_objects_to_draw")),
|
||||
("gpr_draw_top_m_objects", "Detection", ("gpr",), _attr("_gpr_draw_top_m_objects")),
|
||||
("gpr_comp_power", "Detection", ("legacy_gpr",), _attr("_legacy_gpr_comp_power")),
|
||||
("gpr_snr_thresh", "Detection", ("legacy_gpr",), _attr("_legacy_gpr_snr_thresh")),
|
||||
("gpr_snr_comp_max", "Detection", ("legacy_gpr",), _attr("_legacy_gpr_snr_comp_max")),
|
||||
("legacy_gpr_min_visible_pair_count", "Detection", ("legacy_gpr",), _attr("_legacy_gpr_min_visible_pair_count")),
|
||||
("gpr_look_angle_deg", "Motion", ("legacy_gpr",), _attr("_legacy_gpr_look_angle_deg")),
|
||||
("gpr_apply_freq_phase_correction", "Motion", ("legacy_gpr",), _attr("_legacy_gpr_apply_freq_phase_correction")),
|
||||
("gpr_reference_mode", "Motion", ("legacy_gpr",), _attr("_legacy_gpr_reference_mode")),
|
||||
("ignore_socket_speed", "Motion", ("legacy_gpr",), _attr("_legacy_gpr_ignore_socket_speed_enabled")),
|
||||
("gpr_speed_m_s", "Motion", ("legacy_gpr",), _attr("_legacy_gpr_speed_m_s")),
|
||||
]
|
||||
_WEB_LIVE_GETTERS = {field: getter for field, _group, _modes, getter in _WEB_LIVE_SCHEMA}
|
||||
|
||||
|
||||
def _web_field_schema(field: str, group: str, widget) -> dict | None:
|
||||
"""Describe one widget for the web form (type, options/range, value, enabled)."""
|
||||
base = {"name": field, "group": group, "enabled": bool(widget.isEnabled())}
|
||||
if isinstance(widget, QComboBox):
|
||||
return {**base, "kind": "select", "value": widget.currentText(),
|
||||
"options": [widget.itemText(i) for i in range(widget.count())]}
|
||||
if isinstance(widget, QCheckBox):
|
||||
return {**base, "kind": "bool", "value": bool(widget.isChecked())}
|
||||
if isinstance(widget, QSpinBox):
|
||||
return {**base, "kind": "int", "value": int(widget.value()),
|
||||
"min": int(widget.minimum()), "max": int(widget.maximum()), "step": int(widget.singleStep())}
|
||||
if isinstance(widget, QDoubleSpinBox):
|
||||
return {**base, "kind": "float", "value": float(widget.value()),
|
||||
"min": float(widget.minimum()), "max": float(widget.maximum()),
|
||||
"step": float(widget.singleStep()), "decimals": int(widget.decimals())}
|
||||
if isinstance(widget, QLineEdit):
|
||||
return {**base, "kind": "text", "value": widget.text()}
|
||||
return None
|
||||
|
||||
|
||||
def _build_web_settings_schema(window) -> list[dict]:
|
||||
"""Build the settings schema for the active mode straight from the Qt widgets."""
|
||||
mode = window._processing_mode.currentText()
|
||||
schema: list[dict] = []
|
||||
for field, group, modes, getter in _WEB_LIVE_SCHEMA:
|
||||
if modes is not None and mode not in modes:
|
||||
continue
|
||||
entry = _web_field_schema(field, group, getter(window))
|
||||
if entry is not None:
|
||||
schema.append(entry)
|
||||
return schema
|
||||
|
||||
|
||||
def _set_web_live_field(window, field: str, value) -> None:
|
||||
"""Write one web value into the desktop widget that feeds ``field``."""
|
||||
getter = _WEB_LIVE_GETTERS.get(field)
|
||||
if getter is None:
|
||||
return
|
||||
widget = getter(window)
|
||||
if isinstance(widget, QComboBox):
|
||||
window._set_combo_current_text(widget, str(value))
|
||||
elif isinstance(widget, QCheckBox):
|
||||
widget.setChecked(bool(value))
|
||||
elif isinstance(widget, QSpinBox):
|
||||
widget.setValue(int(float(value)))
|
||||
elif isinstance(widget, QDoubleSpinBox):
|
||||
widget.setValue(float(value))
|
||||
elif isinstance(widget, QLineEdit):
|
||||
widget.setText(",".join(str(int(v)) for v in value) if isinstance(value, list) else str(value))
|
||||
|
||||
|
||||
class AppWindowLiveProcessingMixin:
|
||||
"""Handle live processing updates, redraws, and locator republishing."""
|
||||
@@ -55,7 +170,8 @@ class AppWindowLiveProcessingMixin:
|
||||
|
||||
y_min_db = float(self._pass_through_y_min_db.value())
|
||||
y_max_db = float(self._pass_through_y_max_db.value())
|
||||
return ProcessingLiveConfig(
|
||||
visible_x_min, visible_x_max, visible_z_min, visible_z_max = self._gpr_visible_bounds()
|
||||
config = ProcessingLiveConfig(
|
||||
processor_mode=mode,
|
||||
pass_through_channel="s21",
|
||||
pass_through_fixed_y_enabled=bool(self._pass_through_fixed_y_enabled.isChecked()),
|
||||
@@ -95,11 +211,16 @@ class AppWindowLiveProcessingMixin:
|
||||
gpr_imaging_plane_y_m=float(self._gpr_imaging_plane_y_m.value()),
|
||||
gpr_min_visible_score=float(self._gpr_min_visible_score.value()),
|
||||
legacy_gpr_min_visible_pair_count=float(self._legacy_gpr_min_visible_pair_count.value()),
|
||||
gpr_visible_x_min_m=visible_x_min,
|
||||
gpr_visible_x_max_m=visible_x_max,
|
||||
gpr_visible_z_min_m=visible_z_min,
|
||||
gpr_visible_z_max_m=visible_z_max,
|
||||
ignore_socket_speed=bool(self._legacy_gpr_ignore_socket_speed_enabled.isChecked()),
|
||||
reprocess_current_result=bool(reprocess_current_result),
|
||||
history_command_seq=int(self._history_command_seq),
|
||||
history_command=str(history_command),
|
||||
)
|
||||
return config
|
||||
|
||||
def _write_live_processing_config(
|
||||
self,
|
||||
@@ -118,8 +239,50 @@ class AppWindowLiveProcessingMixin:
|
||||
)
|
||||
)
|
||||
|
||||
def _apply_web_live_settings(self, fields: dict) -> None:
|
||||
"""Apply web-requested live settings through the SAME path as a desktop edit.
|
||||
|
||||
Writes the values into the desktop widgets, then runs the single
|
||||
``_on_processing_live_settings_changed`` handler — one writer, one redraw —
|
||||
so the browser and the desktop never desync and ``processing_live.json`` has
|
||||
exactly one author. The per-widget change signals are neutralized by a
|
||||
suppression flag so the final handler runs once instead of dozens of times;
|
||||
a history command keeps its own server-managed sequence-bump path.
|
||||
"""
|
||||
try:
|
||||
history_command = str(fields.get("history_command", "none"))
|
||||
settings = {
|
||||
name: value
|
||||
for name, value in fields.items()
|
||||
if name not in {"history_command", "history_command_seq"}
|
||||
}
|
||||
self._suppress_live_settings_handler = True
|
||||
try:
|
||||
# processor_mode first: dual-sourced gpr_* fields route to the gpr or
|
||||
# legacy_gpr widget based on the active mode.
|
||||
if "processor_mode" in settings:
|
||||
_set_web_live_field(self, "processor_mode", settings.pop("processor_mode"))
|
||||
for name, value in settings.items():
|
||||
_set_web_live_field(self, name, value)
|
||||
finally:
|
||||
self._suppress_live_settings_handler = False
|
||||
|
||||
if history_command in {"clear_all", "remove_last"}:
|
||||
self._write_live_processing_config(history_command=history_command, bump_history_seq=True)
|
||||
self._on_processing_live_settings_changed()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._show_exception("Failed to apply web live settings", exc)
|
||||
|
||||
def _web_settings_schema(self) -> list[dict]:
|
||||
"""Return the live-settings schema for the active mode (read from widgets)."""
|
||||
return _build_web_settings_schema(self)
|
||||
|
||||
def _on_processing_live_settings_changed(self, *_args) -> None:
|
||||
"""Handle live-processing setting changes and trigger redraw when needed."""
|
||||
# Web-apply sets many widgets at once; their individual change signals are
|
||||
# suppressed so this writer+redraw runs exactly once at the end.
|
||||
if getattr(self, "_suppress_live_settings_handler", False):
|
||||
return
|
||||
try:
|
||||
current_mode = self._processing_mode.currentText()
|
||||
if self._is_gpr_processing_mode(current_mode):
|
||||
|
||||
@@ -22,6 +22,7 @@ from python_app.orchestration.preprocess_assets import (
|
||||
preprocess_asset_model,
|
||||
runtime_preprocess_asset_keys,
|
||||
)
|
||||
from python_app.orchestration.restart_policy import RestartPolicy
|
||||
from python_app.orchestration.shm_reader import ShmRingReader
|
||||
|
||||
|
||||
@@ -36,6 +37,15 @@ class AppWindowPipelineMixin:
|
||||
# a wedged reader cannot stay silently broken forever.
|
||||
_READER_ERROR_RECONNECT_AT = 40
|
||||
_READER_ERROR_STOP_AT = 400
|
||||
# If a pipeline child exits unexpectedly while the run should be live, relaunch
|
||||
# the whole pipeline from the last-written runtime config. It retries FOREVER with
|
||||
# capped back-off (this is an unattended appliance — it must keep trying to come
|
||||
# back, never permanently stop); the failure streak resets once a healthy poll sees
|
||||
# data. The "if it breaks it comes back up" contract holds in BOTH GUI and headless.
|
||||
_RESTART_POLICY = RestartPolicy(min_interval_s=3.0, max_interval_s=60.0)
|
||||
# Safety backstop so a wedged/dropping processor cannot leave a single capture
|
||||
# polling forever with no completion and no error. Generous, not a tight deadline.
|
||||
_SINGLE_CAPTURE_TIMEOUT_S = 300.0
|
||||
|
||||
def _processor_requires_restart(self, run_signature: tuple[object, ...]) -> bool:
|
||||
"""Return whether alive `data_processor` was started with different stable run settings."""
|
||||
@@ -148,6 +158,12 @@ class AppWindowPipelineMixin:
|
||||
self._drop_pending_ring_payloads(include_results=True)
|
||||
self._last_reader_error_signature = None
|
||||
self._reader_error_repeat_count = 0
|
||||
# Record what to relaunch if a child later dies unexpectedly. Only a
|
||||
# continuous run auto-restarts; a single capture is bounded by its deadline.
|
||||
self._active_run_config = config
|
||||
self._active_run_config_path = config_path
|
||||
self._pipeline_should_run = not single_capture
|
||||
self._pipeline_restart_count = 0
|
||||
if single_capture:
|
||||
self._single_capture_start_ns = time.monotonic_ns()
|
||||
|
||||
@@ -246,6 +262,7 @@ class AppWindowPipelineMixin:
|
||||
|
||||
def _stop_run(self) -> None:
|
||||
"""Stop acquisition-side processes and close readers as needed."""
|
||||
self._pipeline_should_run = False # an explicit stop disables crash auto-restart
|
||||
was_running = self._supervisor.is_running()
|
||||
if was_running:
|
||||
self._supervisor.stop_orchestrator()
|
||||
@@ -271,6 +288,7 @@ class AppWindowPipelineMixin:
|
||||
|
||||
def _stop_all_processes(self) -> None:
|
||||
"""Stop all managed pipeline processes and close all readers."""
|
||||
self._pipeline_should_run = False # an explicit stop disables crash auto-restart
|
||||
was_running = self._supervisor.is_running() or self._supervisor.is_processor_running()
|
||||
self._supervisor.stop_all()
|
||||
self._drain_rings_until_quiet(timeout_s=0.25, poll_s=0.02)
|
||||
@@ -299,12 +317,8 @@ class AppWindowPipelineMixin:
|
||||
|
||||
def _poll_rings(self) -> None:
|
||||
"""Poll readers, ingest history, and trigger rendering."""
|
||||
for report in self._supervisor.collect_exit_reports():
|
||||
if report.level == "INFO":
|
||||
self._log(report.format())
|
||||
continue
|
||||
self._status_label.setText("Status: error")
|
||||
self._log_error(report.format())
|
||||
self._web_update_snapshot() # guarded internally; never raises
|
||||
self._handle_process_exit_reports()
|
||||
|
||||
try:
|
||||
if self._raw_reader is not None:
|
||||
@@ -318,9 +332,13 @@ class AppWindowPipelineMixin:
|
||||
if self._single_capture_active:
|
||||
if self._finish_single_capture_if_ready():
|
||||
return
|
||||
self._check_single_capture_deadline()
|
||||
return
|
||||
|
||||
if result_latest is not None:
|
||||
# Genuine data flowed: the pipeline is healthy, so reset the
|
||||
# crash-storm budget (it only caps consecutive crash-restarts).
|
||||
self._pipeline_restart_count = 0
|
||||
render_started_ns = time.monotonic_ns()
|
||||
self._draw_preferred_collection(result_latest=result_latest)
|
||||
self._pipeline_metrics.record(
|
||||
@@ -331,6 +349,88 @@ class AppWindowPipelineMixin:
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._handle_reader_poll_error(exc)
|
||||
|
||||
def _handle_process_exit_reports(self) -> None:
|
||||
"""Log child exits and auto-restart the pipeline on an unexpected death.
|
||||
|
||||
Runs first in the poll tick and is fully guarded: it must never raise, or
|
||||
it would abort the Qt slot. An unexpected (non-clean) exit while the run is
|
||||
meant to be live triggers a bounded relaunch — in both GUI and headless.
|
||||
"""
|
||||
unexpected = False
|
||||
try:
|
||||
for report in self._supervisor.collect_exit_reports():
|
||||
if report.level == "INFO":
|
||||
self._log(report.format())
|
||||
continue
|
||||
self._status_label.setText("Status: error")
|
||||
self._log_error(report.format())
|
||||
unexpected = True
|
||||
except Exception as exc: # noqa: BLE001 - the poll tick must survive this
|
||||
self._log_exception("Failed to collect process exit reports", exc, level="ERROR")
|
||||
return
|
||||
|
||||
if unexpected and getattr(self, "_pipeline_should_run", False):
|
||||
self._recover_pipeline_after_crash()
|
||||
|
||||
def _recover_pipeline_after_crash(self) -> None:
|
||||
"""Relaunch the pipeline after an unexpected child exit (GUI and headless).
|
||||
|
||||
Re-spawns from the already-written runtime config — no widgets, no dialogs,
|
||||
no re-validation — so it is safe to call from the poll tick. Retries forever
|
||||
with capped back-off (never gives up); a healthy poll resets the failure streak.
|
||||
"""
|
||||
now = time.monotonic()
|
||||
consecutive_failures = getattr(self, "_pipeline_restart_count", 0)
|
||||
if not self._RESTART_POLICY.should_restart_now(
|
||||
now_s=now,
|
||||
last_restart_s=getattr(self, "_last_pipeline_restart_s", 0.0),
|
||||
consecutive_failures=consecutive_failures,
|
||||
):
|
||||
return # still inside the current back-off window; let it settle
|
||||
self._last_pipeline_restart_s = now
|
||||
self._pipeline_restart_count = consecutive_failures + 1
|
||||
|
||||
config = getattr(self, "_active_run_config", None)
|
||||
config_path = getattr(self, "_active_run_config_path", None)
|
||||
if config is None or config_path is None:
|
||||
self._pipeline_should_run = False
|
||||
self._log_error("Cannot auto-restart pipeline: no active run configuration recorded.")
|
||||
return
|
||||
|
||||
self._log_error(
|
||||
"Pipeline process exited unexpectedly; restarting "
|
||||
f"(attempt {self._pipeline_restart_count}, next back-off "
|
||||
f"{self._RESTART_POLICY.backoff_for(self._pipeline_restart_count):.0f}s)."
|
||||
)
|
||||
try:
|
||||
# Note: do NOT drain rings here — draining pumps the Qt event loop, which
|
||||
# would re-enter _poll_rings mid-restart (and reset the crash-storm count).
|
||||
self._supervisor.stop_all()
|
||||
self._close_readers(keep_results=False)
|
||||
self._supervisor.start(config_path, allow_clean_orchestrator_exit=False)
|
||||
self._raw_reader = ShmRingReader(config.rings.raw_tap.name)
|
||||
self._pre_reader = ShmRingReader(config.rings.preprocessed_tap.name)
|
||||
self._result_reader = ShmRingReader(config.rings.results.name)
|
||||
self._processor_run_signature = self._build_processor_run_signature(config)
|
||||
self._drop_pending_ring_payloads(include_results=True)
|
||||
self._status_label.setText("Status: running")
|
||||
self._log("Pipeline auto-restarted after crash.")
|
||||
except Exception as exc: # noqa: BLE001 - retry on the next crash signal
|
||||
self._log_exception("Pipeline auto-restart failed; will retry", exc, level="ERROR")
|
||||
|
||||
def _check_single_capture_deadline(self) -> None:
|
||||
"""Fail a single capture that never completes so it cannot hang forever."""
|
||||
start = self._single_capture_start_ns
|
||||
if start is None:
|
||||
return
|
||||
if time.monotonic_ns() - start <= int(self._SINGLE_CAPTURE_TIMEOUT_S * 1e9):
|
||||
return
|
||||
self._log_error(
|
||||
f"Single capture timed out after {self._SINGLE_CAPTURE_TIMEOUT_S:.0f}s "
|
||||
"with no result; stopping."
|
||||
)
|
||||
self._stop_run()
|
||||
|
||||
def _handle_reader_poll_error(self, exc: Exception) -> None:
|
||||
"""Surface a reader-poll failure without spamming the log.
|
||||
|
||||
|
||||
@@ -8,8 +8,10 @@ import pyqtgraph as pg
|
||||
|
||||
from python_app.models.dataset_model import ResultCollection
|
||||
from python_app.orchestration.gpr_locator import (
|
||||
apply_object_draw_limits as gpr_apply_object_draw_limits,
|
||||
collection_payload_by_name as gpr_collection_payload_by_name,
|
||||
collection_payloads_by_prefix as gpr_collection_payloads_by_prefix,
|
||||
filter_object_rows as gpr_filter_object_rows,
|
||||
gpr_object_rows as extract_gpr_object_rows,
|
||||
)
|
||||
|
||||
@@ -252,13 +254,12 @@ class AppWindowGprPlotMixin:
|
||||
|
||||
self._draw_gpr_geometry_markers()
|
||||
|
||||
# Both gpr and legacy_gpr filter heatmap object markers by their own
|
||||
# threshold + visible X/Z window (legacy has the same controls), so the
|
||||
# markers match the objects-only view instead of showing raw detections.
|
||||
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:
|
||||
points = (
|
||||
self._filtered_gpr_object_rows(collection)
|
||||
if self._processing_mode.currentText() == "gpr"
|
||||
else np.asarray(points_payload.table, dtype=np.float32)
|
||||
)
|
||||
points = self._filtered_gpr_object_rows(collection)
|
||||
else:
|
||||
points = np.zeros((0, 3), dtype=np.float32)
|
||||
|
||||
@@ -388,13 +389,7 @@ class AppWindowGprPlotMixin:
|
||||
@staticmethod
|
||||
def _apply_object_draw_limits(rows: np.ndarray, limits: tuple[int, int] | None) -> np.ndarray:
|
||||
"""Apply object count/top-M drawing rules to already-filtered rows."""
|
||||
if limits is None or rows.size == 0:
|
||||
return rows
|
||||
|
||||
max_detected_objects, draw_top_objects = limits
|
||||
if rows.shape[0] > int(max_detected_objects):
|
||||
return np.zeros((0, rows.shape[1]), dtype=rows.dtype)
|
||||
return rows[: max(0, int(draw_top_objects))]
|
||||
return gpr_apply_object_draw_limits(rows, limits)
|
||||
|
||||
@staticmethod
|
||||
def _gpr_display_y_min(z_min: float, z_max: float) -> float:
|
||||
@@ -517,17 +512,13 @@ class AppWindowGprPlotMixin:
|
||||
return rows
|
||||
|
||||
x_min, x_max, z_min, z_max = self._gpr_visible_object_bounds()
|
||||
min_score = self._gpr_locator_threshold()
|
||||
finite_mask = np.all(np.isfinite(rows[:, :3]), axis=1)
|
||||
visible_mask = (
|
||||
finite_mask
|
||||
& (rows[:, 2] >= min_score)
|
||||
& (rows[:, 0] >= x_min)
|
||||
& (rows[:, 0] <= x_max)
|
||||
& (rows[:, 1] >= z_min)
|
||||
& (rows[:, 1] <= z_max)
|
||||
return gpr_filter_object_rows(
|
||||
rows,
|
||||
min_score=self._gpr_locator_threshold(),
|
||||
x_bounds=(x_min, x_max),
|
||||
z_bounds=(z_min, z_max),
|
||||
draw_limits=self._gpr_draw_limits(),
|
||||
)
|
||||
return self._apply_object_draw_limits(rows[visible_mask], self._gpr_draw_limits())
|
||||
|
||||
def _draw_gpr_objects_only(self, collection: ResultCollection) -> bool:
|
||||
"""Draw only detected GPR objects inside configured X/Z bounds."""
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
"""Embed the browser UI inside the AppWindow without duplicating any rendering.
|
||||
|
||||
The desktop already owns the hardware and draws every plot with pyqtgraph, so the
|
||||
web view simply streams a snapshot of the *current Qt plot widget* — the browser
|
||||
shows exactly what the desktop shows, for every processing mode, with zero
|
||||
re-implemented rendering. Controls cross back to the Qt main thread through queued
|
||||
signals (the GPIO-button pattern) and invoke the AppWindow's existing buttons.
|
||||
|
||||
Two roles, kept separate from the Qt-free :mod:`python_app.webui` package:
|
||||
|
||||
* :class:`AppWindowWebController` — the Qt bridge implementing the web contract.
|
||||
* :class:`AppWindowWebMixin` — wiring that grabs the plot, refreshes snapshots,
|
||||
and starts/stops the server.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import contextlib
|
||||
import dataclasses
|
||||
import os
|
||||
import time
|
||||
|
||||
from PyQt6.QtCore import QBuffer, QIODevice, QObject, pyqtSignal
|
||||
|
||||
from python_app.orchestration.live_processing_config import ProcessingLiveConfig
|
||||
|
||||
_WEBUI_PORT_ENV = "RADAR_SYSTEM_WEBUI_PORT"
|
||||
_DEFAULT_PORT = 8080
|
||||
# Headless has no shown window, so give the offscreen window a usable size for the
|
||||
# grabbed plot. In GUI mode the user's real (shown) window size is used as-is.
|
||||
_HEADLESS_PLOT_SIZE = (1600, 900)
|
||||
# Grab/encode the plot at most this often (the plot only changes per result/redraw).
|
||||
_GRAB_INTERVAL_S = 0.2
|
||||
_LIVE_FIELD_NAMES = frozenset(field.name for field in dataclasses.fields(ProcessingLiveConfig))
|
||||
|
||||
|
||||
class AppWindowWebController(QObject):
|
||||
"""Qt bridge satisfying the web contract: streams the plot, forwards controls.
|
||||
|
||||
Mutating calls (made on the web thread) emit queued signals the AppWindow
|
||||
connects to its existing slots. Read calls return immutable snapshots the
|
||||
AppWindow refreshes on its poll tick — replaced atomically, never mutated in
|
||||
place, so the web thread reads a consistent value without locking.
|
||||
"""
|
||||
|
||||
start_requested = pyqtSignal()
|
||||
stop_requested = pyqtSignal()
|
||||
single_capture_requested = pyqtSignal()
|
||||
capture_requested = pyqtSignal()
|
||||
apply_settings_requested = pyqtSignal(dict)
|
||||
|
||||
def __init__(self, parent: QObject | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._status: dict = {}
|
||||
self._live_settings: list = []
|
||||
self._frame: dict | None = None
|
||||
|
||||
# -- Snapshot refresh (Qt main thread) -----------------------------------
|
||||
|
||||
def update_snapshot(self, *, status: dict, live_settings: dict, frame: dict | None) -> None:
|
||||
"""Replace the served snapshots; ``frame`` only when a new one was grabbed."""
|
||||
self._status = status
|
||||
self._live_settings = live_settings
|
||||
if frame is not None:
|
||||
self._frame = frame
|
||||
|
||||
# -- WebController reads (web thread) ------------------------------------
|
||||
|
||||
def status(self) -> dict:
|
||||
return dict(self._status)
|
||||
|
||||
def current_live_settings(self) -> list:
|
||||
return list(self._live_settings)
|
||||
|
||||
def peek_frame(self) -> dict | None:
|
||||
"""Return the most recent rendered-plot frame (or None before the first)."""
|
||||
return self._frame
|
||||
|
||||
# -- WebController controls (web thread -> Qt main thread) ---------------
|
||||
|
||||
def start(self) -> None:
|
||||
self.start_requested.emit()
|
||||
|
||||
def stop(self) -> None:
|
||||
self.stop_requested.emit()
|
||||
|
||||
def single_capture(self) -> None:
|
||||
self.single_capture_requested.emit()
|
||||
|
||||
def capture_tmp_reference(self) -> None:
|
||||
self.capture_requested.emit()
|
||||
|
||||
def apply_live_settings(self, fields: dict) -> dict:
|
||||
unknown = set(fields) - _LIVE_FIELD_NAMES
|
||||
if unknown:
|
||||
raise ValueError(f"Unknown live-settings fields: {', '.join(sorted(unknown))}")
|
||||
self.apply_settings_requested.emit(dict(fields))
|
||||
return self.current_live_settings()
|
||||
|
||||
|
||||
class AppWindowWebMixin:
|
||||
"""Start/stop the embedded web server and feed it the live plot + settings."""
|
||||
|
||||
def _init_web_ui(self) -> None:
|
||||
"""Start the web server (default-on, both modes), wired to existing buttons."""
|
||||
self._web_controller: AppWindowWebController | None = None
|
||||
self._web_server = None
|
||||
self._web_frame_seq = 0
|
||||
self._web_last_png: str | None = None
|
||||
self._web_last_grab_s = 0.0
|
||||
|
||||
try:
|
||||
from python_app.webui.server import WebUiServer
|
||||
|
||||
# Headless never shows the window; size it so the grabbed plot is usable.
|
||||
if self._is_truthy_env("RADAR_SYSTEM_HEADLESS"):
|
||||
self.resize(*_HEADLESS_PLOT_SIZE)
|
||||
|
||||
controller = AppWindowWebController(parent=self)
|
||||
controller.start_requested.connect(self._start_run)
|
||||
controller.stop_requested.connect(self._stop_run)
|
||||
controller.single_capture_requested.connect(self._start_single_capture)
|
||||
controller.capture_requested.connect(self._capture_tmp_reference)
|
||||
controller.apply_settings_requested.connect(self._apply_web_live_settings)
|
||||
|
||||
self._web_controller = controller
|
||||
self._web_update_snapshot() # seed snapshots before the first request
|
||||
|
||||
port = self._web_ui_port()
|
||||
self._web_server = WebUiServer(controller, port=port)
|
||||
self._web_server.start()
|
||||
self._log(f"Web UI started on http://0.0.0.0:{port}")
|
||||
except Exception as exc: # noqa: BLE001 - a missing dependency must not abort startup
|
||||
self._log_exception("Failed to start web UI", exc, level="WARN")
|
||||
self._web_controller = None
|
||||
self._web_server = None
|
||||
|
||||
def _web_update_snapshot(self) -> None:
|
||||
"""Refresh the snapshots the bridge serves (called on the Qt poll tick).
|
||||
|
||||
Must NEVER raise: it runs inside the periodic render tick, where an escaping
|
||||
exception would abort the Qt slot (qFatal) and kill the app.
|
||||
"""
|
||||
controller = getattr(self, "_web_controller", None)
|
||||
if controller is None:
|
||||
return
|
||||
try:
|
||||
controller.update_snapshot(
|
||||
status={
|
||||
"running": self._supervisor.is_running(),
|
||||
"processor_running": self._supervisor.is_processor_running(),
|
||||
"ring_name": self._defaults_config.rings.results.name,
|
||||
},
|
||||
live_settings=self._web_settings_schema(),
|
||||
frame=self._web_grab_frame_if_due(),
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - the render loop must survive this
|
||||
self._web_snapshot_errors = getattr(self, "_web_snapshot_errors", 0) + 1
|
||||
if self._web_snapshot_errors % 200 == 1:
|
||||
self._log_exception("Web UI snapshot refresh failed", exc, level="WARN")
|
||||
|
||||
def _web_grab_frame_if_due(self) -> dict | None:
|
||||
"""Grab the current plot as a PNG frame, throttled and change-gated."""
|
||||
now = time.monotonic()
|
||||
if now - self._web_last_grab_s < _GRAB_INTERVAL_S:
|
||||
return None
|
||||
self._web_last_grab_s = now
|
||||
png_b64 = self._grab_plot_png_b64()
|
||||
if png_b64 is None or png_b64 == self._web_last_png:
|
||||
return None # nothing rendered yet, or the plot is unchanged
|
||||
self._web_last_png = png_b64
|
||||
self._web_frame_seq += 1
|
||||
return {
|
||||
"type": "frame",
|
||||
"seq": self._web_frame_seq,
|
||||
"mode": self._processing_mode.currentText(),
|
||||
"png_b64": png_b64,
|
||||
}
|
||||
|
||||
def _grab_plot_png_b64(self) -> str | None:
|
||||
"""Render the currently-visible plot page to a base64 PNG (exactly as shown)."""
|
||||
widget = self._plot_stack.currentWidget()
|
||||
if widget is None or widget.width() <= 0 or widget.height() <= 0:
|
||||
return None
|
||||
pixmap = widget.grab()
|
||||
if pixmap.isNull():
|
||||
return None
|
||||
buffer = QBuffer()
|
||||
buffer.open(QIODevice.OpenModeFlag.WriteOnly)
|
||||
pixmap.save(buffer, "PNG")
|
||||
return base64.b64encode(bytes(buffer.data())).decode()
|
||||
|
||||
def _shutdown_web_ui(self) -> None:
|
||||
"""Stop the server (and its broadcaster) during teardown."""
|
||||
server = getattr(self, "_web_server", None)
|
||||
if server is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
server.stop()
|
||||
self._web_server = None
|
||||
self._web_controller = None
|
||||
|
||||
@staticmethod
|
||||
def _web_ui_port() -> int:
|
||||
"""Resolve the web UI port from the environment, defaulting to 8080."""
|
||||
raw = os.environ.get(_WEBUI_PORT_ENV, "").strip()
|
||||
if not raw:
|
||||
return _DEFAULT_PORT
|
||||
try:
|
||||
port = int(raw)
|
||||
except ValueError:
|
||||
return _DEFAULT_PORT
|
||||
return port if 1 <= port <= 65535 else _DEFAULT_PORT
|
||||
Reference in New Issue
Block a user