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