接入 Autocal Finger 视频预览
复用 shared FingerAcquisitionWorker 显示处理后画面,并保存视频与配置路径。通过针对性测试和真实视频验证。
This commit is contained in:
@@ -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()
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user