接入 Autocal Finger 视频预览

复用 shared FingerAcquisitionWorker 显示处理后画面,并保存视频与配置路径。通过针对性测试和真实视频验证。
This commit is contained in:
ykcheungab
2026-10-11 12:16:16 +08:00
parent 46c8c8cbf6
commit 12c2fb1c4b
8 changed files with 381 additions and 1 deletions
@@ -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,
@@ -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()
+19 -1
View File
@@ -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())
@@ -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