From 12c2fb1c4b17cc6535512f99486327b62799819e Mon Sep 17 00:00:00 2001 From: ykcheungab Date: Sun, 11 Oct 2026 12:16:16 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8E=A5=E5=85=A5=20Autocal=20Finger=20?= =?UTF-8?q?=E8=A7=86=E9=A2=91=E9=A2=84=E8=A7=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复用 shared FingerAcquisitionWorker 显示处理后画面,并保存视频与配置路径。通过针对性测试和真实视频验证。 --- apps/autocal/config/locales/en_US.json | 20 ++ apps/autocal/config/locales/zh_CN.json | 20 ++ apps/autocal/config/preferences.default.json | 2 + .../autocal/src/orisys_autocal/preferences.py | 2 + .../orisys_autocal/ui/finger_source_panel.py | 222 ++++++++++++++++++ apps/autocal/src/orisys_autocal/ui/window.py | 20 +- .../src/orisys_autocal/ui/workspace.py | 5 + .../autocal/tests/test_finger_source_panel.py | 91 +++++++ 8 files changed, 381 insertions(+), 1 deletion(-) create mode 100644 apps/autocal/src/orisys_autocal/ui/finger_source_panel.py create mode 100644 apps/autocal/tests/test_finger_source_panel.py diff --git a/apps/autocal/config/locales/en_US.json b/apps/autocal/config/locales/en_US.json index e9a608a..1b6ecf5 100644 --- a/apps/autocal/config/locales/en_US.json +++ b/apps/autocal/config/locales/en_US.json @@ -232,6 +232,26 @@ "reserved_surface": "Multi-point calibration sensitivity will be drawn as a colour map on the sensor's 3D surface here: each point's sensitivity interpolated and coloured, with a legend and range.", "reserved_points": "The calibration point table and capture controls will appear here: platform coordinates, reference load, sampling statistics and the resulting sensitivity." }, + "source": { + "video": "Finger video", + "config": "Sensor config", + "browse": "Browse…", + "start": "Play video", + "stop": "Stop", + "idle": "Choose a video and sensor.json", + "no_frame": "Waiting for processed frames", + "choose_video": "Choose Finger video", + "choose_config": "Choose sensor.json", + "video_filter": "Video files (*.mp4 *.avi *.mov *.mkv);;All files (*)", + "missing_input": "Choose an existing video file and sensor.json", + "opening": "Opening video…", + "running": "Processing video", + "stopping": "Stopping…", + "stopped": "Stopped", + "finished": "Video finished", + "error": "Video processing failed: {detail}", + "frame": "Frame {frame} · acquired at {time:.3f}" + }, "finger": { "title": "Finger calibration-point debug", "bundle_path": "Export bundle", diff --git a/apps/autocal/config/locales/zh_CN.json b/apps/autocal/config/locales/zh_CN.json index bd71992..bdd26f6 100644 --- a/apps/autocal/config/locales/zh_CN.json +++ b/apps/autocal/config/locales/zh_CN.json @@ -232,6 +232,26 @@ "reserved_surface": "多点标定的灵敏度将在此以色图叠加在传感器三维表面上:每个标定点的灵敏度经插值着色,并给出色标与范围。", "reserved_points": "标定点列表与采集控制将在此显示:平台坐标、参考载荷、采样统计与灵敏度结果。" }, + "source": { + "video": "Finger 视频", + "config": "传感器配置", + "browse": "选择…", + "start": "开始播放", + "stop": "停止", + "idle": "请选择视频和 sensor.json", + "no_frame": "等待处理后的画面", + "choose_video": "选择 Finger 视频", + "choose_config": "选择 sensor.json", + "video_filter": "视频文件 (*.mp4 *.avi *.mov *.mkv);;所有文件 (*)", + "missing_input": "请选择存在的视频文件和 sensor.json", + "opening": "正在打开视频…", + "running": "正在处理视频", + "stopping": "正在停止…", + "stopped": "已停止", + "finished": "视频播放完毕", + "error": "视频处理失败:{detail}", + "frame": "帧 {frame} · 采集时间 {time:.3f}" + }, "finger": { "title": "手指标定点调试", "bundle_path": "导出包", diff --git a/apps/autocal/config/preferences.default.json b/apps/autocal/config/preferences.default.json index 4caa949..5836f10 100644 --- a/apps/autocal/config/preferences.default.json +++ b/apps/autocal/config/preferences.default.json @@ -16,6 +16,8 @@ "sdo_last": { "node": 0, "index": 24704, "subindex": 0, "type": 0 }, "rtex_last": { "axis": 0, "parameter": 0 }, "show_diagnostics": false, + "finger_video_path": "", + "finger_config_ref": "", "safety_limits": { "max_travel_mm": 100.0, "max_descent_mm": 10.0, diff --git a/apps/autocal/src/orisys_autocal/preferences.py b/apps/autocal/src/orisys_autocal/preferences.py index 3c81db4..4dee22c 100644 --- a/apps/autocal/src/orisys_autocal/preferences.py +++ b/apps/autocal/src/orisys_autocal/preferences.py @@ -209,6 +209,8 @@ def default_preferences() -> dict[str, Any]: #: The export bundle the debug screen loads. Empty means "ask", and the #: default below points at the revision the bench confirmed. "finger_bundle_path": DEFAULT_FINGER_BUNDLE, + "finger_video_path": "", + "finger_config_ref": "", #: The UV-space texture for the flat picker. Preferred over the derived #: path when it exists; empty falls back to the layout map. "finger_texture_path": DEFAULT_FINGER_TEXTURE, diff --git a/apps/autocal/src/orisys_autocal/ui/finger_source_panel.py b/apps/autocal/src/orisys_autocal/ui/finger_source_panel.py new file mode 100644 index 0000000..cd2b166 --- /dev/null +++ b/apps/autocal/src/orisys_autocal/ui/finger_source_panel.py @@ -0,0 +1,222 @@ +"""Finger video input and preview for the AutoCal sensor workspace.""" + +from __future__ import annotations + +from pathlib import Path + +import numpy as np +from orisys_app_shared.acquisition.finger_worker import FingerAcquisitionWorker +from orisys_app_shared.i18n import t +from PySide6.QtCore import Qt, QTimer, Signal +from PySide6.QtGui import QImage, QPixmap +from PySide6.QtWidgets import ( + QFileDialog, + QGridLayout, + QHBoxLayout, + QLabel, + QLineEdit, + QPushButton, + QVBoxLayout, + QWidget, +) + + +class FingerSourcePanel(QWidget): + """Run the shared finger processing path from a video file. + + Worker callbacks originate on its acquisition thread; the GUI timer alone + reads packets and changes widgets. The same worker also accepts a camera ID. + """ + + errorReported = Signal(str) + + def __init__( + self, + video_path: str = "", + config_ref: str = "", + parent: QWidget | None = None, + ) -> None: + super().__init__(parent) + self.worker = FingerAcquisitionWorker(on_error=self.errorReported.emit) + self._last_frame_id: tuple[int, int] | None = None + self._last_image: QImage | None = None + self._active = False + self._error = "" + self._status_key = "source.idle" + + layout = QVBoxLayout(self) + fields = QGridLayout() + self.video_label = QLabel(self) + self.video_path = QLineEdit(video_path, self) + self.video_path.setObjectName("fingerVideoPath") + self.video_browse = QPushButton(self) + self.config_label = QLabel(self) + self.config_ref = QLineEdit(config_ref, self) + self.config_ref.setObjectName("fingerConfigRef") + self.config_browse = QPushButton(self) + fields.addWidget(self.video_label, 0, 0) + fields.addWidget(self.video_path, 0, 1) + fields.addWidget(self.video_browse, 0, 2) + fields.addWidget(self.config_label, 1, 0) + fields.addWidget(self.config_ref, 1, 1) + fields.addWidget(self.config_browse, 1, 2) + layout.addLayout(fields) + + controls = QHBoxLayout() + self.start_button = QPushButton(self) + self.start_button.setObjectName("fingerVideoStart") + self.stop_button = QPushButton(self) + self.stop_button.setObjectName("fingerVideoStop") + self.stop_button.setEnabled(False) + self.status_label = QLabel(self) + self.status_label.setObjectName("fingerVideoStatus") + controls.addWidget(self.start_button) + controls.addWidget(self.stop_button) + controls.addWidget(self.status_label, 1) + layout.addLayout(controls) + + self.preview = QLabel(self) + self.preview.setObjectName("fingerVideoPreview") + self.preview.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.preview.setMinimumSize(320, 260) + layout.addWidget(self.preview, 1) + self.frame_label = QLabel(self) + layout.addWidget(self.frame_label) + + self._timer = QTimer(self) + self._timer.setInterval(33) + self._timer.timeout.connect(self._poll) + self.errorReported.connect(self._on_error) + self.video_browse.clicked.connect(self._choose_video) + self.config_browse.clicked.connect(self._choose_config) + self.start_button.clicked.connect(self.start) + self.stop_button.clicked.connect(self.stop) + self.retranslate() + + def retranslate(self) -> None: + self.video_label.setText(t("source.video")) + self.config_label.setText(t("source.config")) + self.video_browse.setText(t("source.browse")) + self.config_browse.setText(t("source.browse")) + self.start_button.setText(t("source.start")) + self.stop_button.setText(t("source.stop")) + self._set_status(self._status_key) + if self._last_image is None: + self.preview.setText(t("source.no_frame")) + + def _choose_video(self) -> None: + path, _ = QFileDialog.getOpenFileName( + self, + t("source.choose_video"), + self.video_path.text(), + t("source.video_filter"), + ) + if path: + self.video_path.setText(path) + + def _choose_config(self) -> None: + path, _ = QFileDialog.getOpenFileName( + self, t("source.choose_config"), self.config_ref.text(), "JSON (*.json)" + ) + if path: + self.config_ref.setText(path) + + def start(self) -> None: + video = Path(self.video_path.text().strip()) + config = Path(self.config_ref.text().strip()) + if not video.is_file() or not config.is_file(): + self._show_error(t("source.missing_input")) + return + self._error = "" + self._last_frame_id = None + self._last_image = None + self.preview.clear() + self.preview.setText(t("source.no_frame")) + self.frame_label.clear() + try: + self.worker.start(str(video), config_ref=str(config)) + except Exception as exc: # noqa: BLE001 - show source/config errors to operator + self._show_error(str(exc)) + return + self._active = True + self._set_running_controls(True) + self._set_status("source.opening") + self._timer.start() + + def stop(self) -> None: + if self._active: + self.worker.stop() + self._set_status("source.stopping") + + def shutdown(self) -> None: + self._timer.stop() + self.worker.stop(wait=True) + + def _set_running_controls(self, running: bool) -> None: + self.start_button.setEnabled(not running) + self.stop_button.setEnabled(running) + for widget in ( + self.video_path, + self.video_browse, + self.config_ref, + self.config_browse, + ): + widget.setEnabled(not running) + + def _show_error(self, message: str) -> None: + self._error = message + self._set_status("source.error") + + def _set_status(self, key: str) -> None: + self._status_key = key + self.status_label.setText(t(key).format(detail=self._error)) + + def _on_error(self, message: str) -> None: + self._show_error(message) + + def _poll(self) -> None: + packet = self.worker.latest() + if packet is not None: + identity = (packet.session_id, packet.frame_id) + if identity != self._last_frame_id: + self._last_frame_id = identity + self._show_frame(packet.processed_frame) + self.frame_label.setText( + t("source.frame").format( + frame=packet.frame_id, time=packet.timestamp + ) + ) + if not self._error: + self._set_status("source.running") + if not self.worker.is_running: + reason = self.worker.consume_end_reason() + self._timer.stop() + self._active = False + self._set_running_controls(False) + if not self._error: + self._set_status( + "source.finished" if reason == "ended" else "source.stopped" + ) + + def _show_frame(self, frame: np.ndarray) -> None: + if frame.ndim != 3 or frame.shape[2] != 3: + return + height, width = frame.shape[:2] + self._last_image = QImage( + frame.data, width, height, frame.strides[0], QImage.Format.Format_BGR888 + ).copy() + self._scale_preview() + + def _scale_preview(self) -> None: + if self._last_image is not None: + self.preview.setPixmap( + QPixmap.fromImage(self._last_image).scaled( + self.preview.size(), + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation, + ) + ) + + def resizeEvent(self, event) -> None: # noqa: N802 - Qt override + super().resizeEvent(event) + self._scale_preview() diff --git a/apps/autocal/src/orisys_autocal/ui/window.py b/apps/autocal/src/orisys_autocal/ui/window.py index 175a60f..f34894c 100644 --- a/apps/autocal/src/orisys_autocal/ui/window.py +++ b/apps/autocal/src/orisys_autocal/ui/window.py @@ -65,6 +65,7 @@ from .brand_bar import BrandBar from .bus_data_panel import BusDataPanel from .bus_panel import default_bas_dir from .device_setup import DeviceSetupDialog +from .finger_source_panel import FingerSourcePanel from .sensor_panel import FingerPanel from .sequence_panel import SequencePanel from .status_strip import RunBar, StatusStrip @@ -287,8 +288,17 @@ class AutoCalMainWindow(QMainWindow): tool_xy_mm=_triple(self.prefs.get("finger_tool_xy_mm"), 2), parent=body, ) + self.finger_source_panel = FingerSourcePanel( + video_path=str(self.prefs.get("finger_video_path") or ""), + config_ref=str(self.prefs.get("finger_config_ref") or ""), + parent=body, + ) self.workspace = WorkspaceTabs( - self.axis_panel, self.sequence_panel, self.finger_panel, body + self.axis_panel, + self.sequence_panel, + self.finger_panel, + body, + sensor_panel=self.finger_source_panel, ) self.main_splitter = QSplitter(Qt.Orientation.Horizontal, body) @@ -1273,6 +1283,7 @@ class AutoCalMainWindow(QMainWindow): self.axis_readout.retranslate() self.sequence_panel.retranslate() self.finger_panel.retranslate() + self.finger_source_panel.retranslate() self.workspace.retranslate() self.menu_file.setTitle(t("menu.file")) @@ -1353,6 +1364,12 @@ class AutoCalMainWindow(QMainWindow): # The sequence profile is the P5 design artefact: whatever is on screen is # what a later run would use, so it is persisted with everything else. self.prefs["sequence_profile"] = self.sequence_panel.profile() + self.prefs["finger_video_path"] = ( + self.finger_source_panel.video_path.text().strip() + ) + self.prefs["finger_config_ref"] = ( + self.finger_source_panel.config_ref.text().strip() + ) try: save_preferences(self.prefs) except OSError: @@ -1360,6 +1377,7 @@ class AutoCalMainWindow(QMainWindow): def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802 - Qt override """Stop the platform, close the controller, then persist and leave.""" + self.finger_source_panel.shutdown() logger.info("Closing: stopping axes and shutting the poller down") try: self.poller.submit(lambda service: service.stop_all()) diff --git a/apps/autocal/src/orisys_autocal/ui/workspace.py b/apps/autocal/src/orisys_autocal/ui/workspace.py index 1a027b0..9d03800 100644 --- a/apps/autocal/src/orisys_autocal/ui/workspace.py +++ b/apps/autocal/src/orisys_autocal/ui/workspace.py @@ -80,6 +80,7 @@ class WorkspaceTabs(QTabWidget): sequence_panel: SequencePanel, finger_panel: QWidget | None = None, parent: QWidget | None = None, + sensor_panel: QWidget | None = None, ) -> None: super().__init__(parent) self.setObjectName("workspaceTabs") @@ -90,7 +91,11 @@ class WorkspaceTabs(QTabWidget): self._pages["sequence"] = sequence_panel if finger_panel is not None: self._pages["finger"] = finger_panel + if sensor_panel is not None: + self._pages["sensor"] = sensor_panel for key, locale_key in _PLACEHOLDERS.items(): + if key in self._pages: + continue page = PlaceholderPage(locale_key, self) self._pages[key] = page self._placeholder_keys[key] = locale_key diff --git a/apps/autocal/tests/test_finger_source_panel.py b/apps/autocal/tests/test_finger_source_panel.py new file mode 100644 index 0000000..c0071d8 --- /dev/null +++ b/apps/autocal/tests/test_finger_source_panel.py @@ -0,0 +1,91 @@ +"""The Autocal sensor tab drives the shared finger acquisition interface.""" + +from __future__ import annotations + +from types import SimpleNamespace + +import numpy as np + + +class FakeFingerWorker: + def __init__(self, *, on_error): + self.on_error = on_error + self.is_running = False + self.started = None + self.stopped = [] + self.packet = None + + def start(self, video, *, config_ref): + self.started = (video, config_ref) + self.is_running = True + + def stop(self, *, wait=False): + self.stopped.append(wait) + self.is_running = False + + def latest(self): + return self.packet + + def consume_end_reason(self): + return "stopped" + + +def test_video_controls_use_shared_worker_and_show_processed_frame( + qt_app, tmp_dir, monkeypatch +): + from orisys_autocal.ui import finger_source_panel + + monkeypatch.setattr( + finger_source_panel, "FingerAcquisitionWorker", FakeFingerWorker + ) + video = tmp_dir / "input.mp4" + config = tmp_dir / "sensor.json" + video.write_bytes(b"video") + config.write_text("{}", encoding="utf-8") + + panel = finger_source_panel.FingerSourcePanel(str(video), str(config)) + panel.start_button.click() + assert panel.worker.started == (str(video), str(config)) + assert not panel.start_button.isEnabled() + + panel.worker.packet = SimpleNamespace( + session_id=1, + frame_id=7, + timestamp=123.5, + processed_frame=np.full((20, 30, 3), (10, 20, 30), dtype=np.uint8), + ) + panel._poll() + assert panel._last_image.size().width() == 30 + assert panel.frame_label.text().find("7") >= 0 + assert not panel.preview.pixmap().isNull() + + panel.stop_button.click() + panel._poll() + assert panel.worker.stopped == [False] + assert panel.start_button.isEnabled() + panel.shutdown() + + +def test_sensor_tab_is_live_panel_and_paths_are_saved(make_window, tmp_dir): + window = make_window() + assert window.workspace.page("sensor") is window.finger_source_panel + window.finger_source_panel.video_path.setText(str(tmp_dir / "input.mp4")) + window.finger_source_panel.config_ref.setText(str(tmp_dir / "sensor.json")) + window._persist_prefs() + assert window.prefs["finger_video_path"].endswith("input.mp4") + assert window.prefs["finger_config_ref"].endswith("sensor.json") + + +def test_missing_input_is_visible_and_does_not_start_worker(qt_app, monkeypatch): + from orisys_autocal.ui import finger_source_panel + + monkeypatch.setattr( + finger_source_panel, "FingerAcquisitionWorker", FakeFingerWorker + ) + panel = finger_source_panel.FingerSourcePanel() + panel.start_button.click() + assert panel.worker.started is None + assert panel.start_button.isEnabled() + assert panel._error + assert panel._error in panel.status_label.text() + panel.shutdown()