added camera search and select, auto preference saving and version tag to 0.4.2
This commit is contained in:
+1
-1
@@ -25,7 +25,7 @@ pip install orisys-{version}-{python-version}-{python-version}-{os}_{machine}.wh
|
|||||||
```
|
```
|
||||||
如
|
如
|
||||||
```bash
|
```bash
|
||||||
pip install orisys-0.4.1-cp310-cp310-win_amd64.whl
|
pip install orisys-0.4.2-cp310-cp310-win_amd64.whl
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **安装 CuPy(根据您的 CUDA 版本选择):**
|
3. **安装 CuPy(根据您的 CUDA 版本选择):**
|
||||||
|
|||||||
+5
-5
@@ -1,5 +1,5 @@
|
|||||||
叠动科技软件使用手册
|
叠动科技软件使用手册
|
||||||
版本: 0.4.1
|
版本: 0.4.2
|
||||||
更新日期: 2026年7月13日
|
更新日期: 2026年7月13日
|
||||||
|
|
||||||
# 简介
|
# 简介
|
||||||
@@ -57,9 +57,9 @@ orisys SDK 以 .whl 文件形式提供,您需要根据操作系统、Python
|
|||||||
## 下载正确的 .whl 文件
|
## 下载正确的 .whl 文件
|
||||||
从官方渠道获取 .whl 文件,文件名格式通常为:
|
从官方渠道获取 .whl 文件,文件名格式通常为:
|
||||||
orisys-{版本}-{Python版本}-{Python版本}-{操作系统}_{硬件架构}.whl
|
orisys-{版本}-{Python版本}-{Python版本}-{操作系统}_{硬件架构}.whl
|
||||||
例如:orisys-0.4.1-cp310-cp310-win_amd64.whl 表示:
|
例如:orisys-0.4.2-cp310-cp310-win_amd64.whl 表示:
|
||||||
|
|
||||||
版本:0.4.1
|
版本:0.4.2
|
||||||
Python 版本:3.10(cp310)
|
Python 版本:3.10(cp310)
|
||||||
操作系统:Windows(win)
|
操作系统:Windows(win)
|
||||||
硬件架构:64位(amd64)
|
硬件架构:64位(amd64)
|
||||||
@@ -71,7 +71,7 @@ Python 版本:3.10(cp310)
|
|||||||
|
|
||||||
``` bash
|
``` bash
|
||||||
# 请将文件名替换为您实际下载的文件名
|
# 请将文件名替换为您实际下载的文件名
|
||||||
pip install orisys-0.4.1-cp310-cp310-win_amd64.whl
|
pip install orisys-0.4.2-cp310-cp310-win_amd64.whl
|
||||||
```
|
```
|
||||||
|
|
||||||
## 安装 CuPy(可选)
|
## 安装 CuPy(可选)
|
||||||
@@ -108,7 +108,7 @@ python
|
|||||||
import orisys
|
import orisys
|
||||||
print(orisys.__version__)
|
print(orisys.__version__)
|
||||||
```
|
```
|
||||||
如果输出版本号(例如 0.4.1),则说明安装成功。
|
如果输出版本号(例如 0.4.2),则说明安装成功。
|
||||||
|
|
||||||
# 使用方法
|
# 使用方法
|
||||||
## 运行示例程序
|
## 运行示例程序
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
"""Discover USB cameras for the dashboard (Windows DSHOW/MSMF, Linux V4L2)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
|
||||||
|
DEFAULT_MAX_CAMERA_INDEX = 10
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CameraInfo:
|
||||||
|
index: int
|
||||||
|
backend_name: str
|
||||||
|
backend_api: int
|
||||||
|
width: int
|
||||||
|
height: int
|
||||||
|
|
||||||
|
@property
|
||||||
|
def source(self) -> str:
|
||||||
|
return str(self.index)
|
||||||
|
|
||||||
|
def label(self) -> str:
|
||||||
|
return f"{self.index} ({self.width}x{self.height}) [{self.backend_name}]"
|
||||||
|
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def _suppress_opencv_noise():
|
||||||
|
"""Hide OpenCV VideoCapture probe warnings (stderr + OpenCV log)."""
|
||||||
|
prev_level = None
|
||||||
|
set_level = None
|
||||||
|
try:
|
||||||
|
logging = getattr(getattr(cv2, "utils", None), "logging", None)
|
||||||
|
if logging is not None:
|
||||||
|
set_level = getattr(logging, "setLogLevel", None)
|
||||||
|
get_level = getattr(logging, "getLogLevel", None)
|
||||||
|
silent = getattr(logging, "LOG_LEVEL_SILENT", 0)
|
||||||
|
if set_level is not None:
|
||||||
|
if get_level is not None:
|
||||||
|
prev_level = get_level()
|
||||||
|
set_level(silent)
|
||||||
|
except Exception:
|
||||||
|
prev_level = None
|
||||||
|
|
||||||
|
devnull = open(os.devnull, "w")
|
||||||
|
try:
|
||||||
|
with contextlib.redirect_stderr(devnull):
|
||||||
|
stderr_fd = sys.stderr.fileno()
|
||||||
|
saved_fd = os.dup(stderr_fd)
|
||||||
|
try:
|
||||||
|
os.dup2(devnull.fileno(), stderr_fd)
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
os.dup2(saved_fd, stderr_fd)
|
||||||
|
os.close(saved_fd)
|
||||||
|
finally:
|
||||||
|
devnull.close()
|
||||||
|
if set_level is not None and prev_level is not None:
|
||||||
|
try:
|
||||||
|
set_level(prev_level)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _backend_candidates() -> list[tuple[str, int]]:
|
||||||
|
candidates: list[tuple[str, int]] = []
|
||||||
|
if sys.platform == "win32":
|
||||||
|
if hasattr(cv2, "CAP_DSHOW"):
|
||||||
|
candidates.append(("DSHOW", cv2.CAP_DSHOW))
|
||||||
|
if hasattr(cv2, "CAP_MSMF"):
|
||||||
|
candidates.append(("MSMF", cv2.CAP_MSMF))
|
||||||
|
elif sys.platform.startswith("linux"):
|
||||||
|
# Prefer v4l2 for USB camera indices on Linux.
|
||||||
|
if hasattr(cv2, "CAP_V4L2"):
|
||||||
|
candidates.append(("V4L2", cv2.CAP_V4L2))
|
||||||
|
if not candidates:
|
||||||
|
candidates.append(("ANY", cv2.CAP_ANY))
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
|
def _try_open(index: int, backend_name: str, backend_api: int) -> CameraInfo | None:
|
||||||
|
cap = cv2.VideoCapture(index, backend_api)
|
||||||
|
if not cap.isOpened():
|
||||||
|
cap.release()
|
||||||
|
return None
|
||||||
|
ok, frame = cap.read()
|
||||||
|
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH) or 0)
|
||||||
|
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT) or 0)
|
||||||
|
if not ok or frame is None:
|
||||||
|
if width <= 0 or height <= 0:
|
||||||
|
cap.release()
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
h, w = frame.shape[:2]
|
||||||
|
width = width or int(w)
|
||||||
|
height = height or int(h)
|
||||||
|
cap.release()
|
||||||
|
return CameraInfo(
|
||||||
|
index=index,
|
||||||
|
backend_name=backend_name,
|
||||||
|
backend_api=backend_api,
|
||||||
|
width=width,
|
||||||
|
height=height,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def list_cameras(max_index: int = DEFAULT_MAX_CAMERA_INDEX) -> list[CameraInfo]:
|
||||||
|
"""Probe indices ``0 .. max_index`` and return working cameras."""
|
||||||
|
found: list[CameraInfo] = []
|
||||||
|
seen_indices: set[int] = set()
|
||||||
|
backends = _backend_candidates()
|
||||||
|
|
||||||
|
with _suppress_opencv_noise():
|
||||||
|
for index in range(0, max_index + 1):
|
||||||
|
for backend_name, backend_api in backends:
|
||||||
|
info = _try_open(index, backend_name, backend_api)
|
||||||
|
if info is None:
|
||||||
|
continue
|
||||||
|
if index in seen_indices:
|
||||||
|
break
|
||||||
|
found.append(info)
|
||||||
|
seen_indices.add(index)
|
||||||
|
break
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def pick_default_camera(cameras: list[CameraInfo], preferred: str | None = None) -> str | None:
|
||||||
|
if not cameras:
|
||||||
|
return None
|
||||||
|
preferred = (preferred or "").strip()
|
||||||
|
if preferred:
|
||||||
|
for cam in cameras:
|
||||||
|
if cam.source == preferred:
|
||||||
|
return cam.source
|
||||||
|
return cameras[0].source
|
||||||
+129
-3
@@ -22,6 +22,8 @@ from pipeline.csv_recorder import CsvRecorder
|
|||||||
from pipeline.service import FingerPipeline
|
from pipeline.service import FingerPipeline
|
||||||
|
|
||||||
from .app_paths import resource_path
|
from .app_paths import resource_path
|
||||||
|
from .camera_discovery import list_cameras, pick_default_camera
|
||||||
|
from .preferences import get_preferred_video_source, load_preferences, save_preferences
|
||||||
from .layout import (
|
from .layout import (
|
||||||
DashboardLayout,
|
DashboardLayout,
|
||||||
LAYOUT,
|
LAYOUT,
|
||||||
@@ -102,26 +104,45 @@ class FingerDashboardWindow(QMainWindow):
|
|||||||
mesh_state=self.mesh_state,
|
mesh_state=self.mesh_state,
|
||||||
show_log=self._log_panel_visible,
|
show_log=self._log_panel_visible,
|
||||||
)
|
)
|
||||||
|
# CLI / pipeline defaults first, then overlay saved user preferences.
|
||||||
self.settings_panel.set_video_source(str(self.config.video))
|
self.settings_panel.set_video_source(str(self.config.video))
|
||||||
self.settings_panel.set_config_path(self.config.config)
|
self.settings_panel.set_config_path(self.config.config)
|
||||||
self.settings_panel.set_calibration_path(self.config.calib or "")
|
self.settings_panel.set_calibration_path(self.config.calib or "")
|
||||||
self.settings_panel.set_finger_export_dir(self.config.export_dir)
|
self.settings_panel.set_finger_export_dir(self.config.export_dir)
|
||||||
self.settings_panel.set_csv_save_dir(self.config.csv_save_dir)
|
self.settings_panel.set_csv_save_dir(self.config.csv_save_dir)
|
||||||
|
|
||||||
self.settings_panel.log_visibility_changed.connect(self._set_log_panel_visible)
|
self._preferences = load_preferences()
|
||||||
self.settings_panel.free_rotation_changed.connect(self.mesh_viewport.set_free_rotation)
|
self.settings_panel.apply_preference_state(self._preferences)
|
||||||
|
self._log_panel_visible = bool(self.settings_panel.show_log_check.isChecked())
|
||||||
|
self.mesh_viewport.set_free_rotation(bool(self.settings_panel.free_rotate_check.isChecked()))
|
||||||
if self.arrow_panel is not None:
|
if self.arrow_panel is not None:
|
||||||
self.settings_panel.arrow_background_changed.connect(self.arrow_panel.set_background)
|
self.arrow_panel.set_background(self.settings_panel.arrow_background())
|
||||||
|
# Sync pipeline config fields from restored panel values.
|
||||||
|
self.config.video = self.settings_panel.video_source() or self.config.video
|
||||||
|
self.config.config = self.settings_panel.config_path() or self.config.config
|
||||||
|
self.config.export_dir = self.settings_panel.finger_export_dir() or self.config.export_dir
|
||||||
|
self.config.csv_save_dir = self.settings_panel.csv_save_dir() or self.config.csv_save_dir
|
||||||
|
|
||||||
|
self.settings_panel.log_visibility_changed.connect(self._on_log_visibility_changed)
|
||||||
|
self.settings_panel.free_rotation_changed.connect(self._on_free_rotation_changed)
|
||||||
|
if self.arrow_panel is not None:
|
||||||
|
self.settings_panel.arrow_background_changed.connect(self._on_arrow_background_changed)
|
||||||
|
else:
|
||||||
|
self.settings_panel.arrow_background_changed.connect(lambda _v: self._save_preferences())
|
||||||
self.settings_panel.view_reset_requested.connect(self.mesh_viewport.reset_view)
|
self.settings_panel.view_reset_requested.connect(self.mesh_viewport.reset_view)
|
||||||
self.settings_panel.browse_video_requested.connect(self._select_camera)
|
self.settings_panel.browse_video_requested.connect(self._select_camera)
|
||||||
|
self.settings_panel.camera_refresh_requested.connect(self._refresh_cameras)
|
||||||
|
self.settings_panel.camera_selection_changed.connect(self._on_camera_selection_changed)
|
||||||
self.settings_panel.browse_config_requested.connect(self._select_config)
|
self.settings_panel.browse_config_requested.connect(self._select_config)
|
||||||
self.settings_panel.browse_finger_assets_requested.connect(self._select_finger_assets)
|
self.settings_panel.browse_finger_assets_requested.connect(self._select_finger_assets)
|
||||||
self.settings_panel.browse_csv_dir_requested.connect(self._select_csv_dir)
|
self.settings_panel.browse_csv_dir_requested.connect(self._select_csv_dir)
|
||||||
self.settings_panel.csv_record_toggled.connect(self._on_csv_record_toggled)
|
self.settings_panel.csv_record_toggled.connect(self._on_csv_record_toggled)
|
||||||
|
self.settings_panel.show_flow_arrows_check.toggled.connect(lambda _v: self._save_preferences())
|
||||||
self.settings_panel.btn_play_pause.clicked.connect(self._on_play_pause_clicked)
|
self.settings_panel.btn_play_pause.clicked.connect(self._on_play_pause_clicked)
|
||||||
self.settings_panel.btn_reset_tracker.clicked.connect(self._on_reset)
|
self.settings_panel.btn_reset_tracker.clicked.connect(self._on_reset)
|
||||||
self.settings_panel.set_play_pause_state("run")
|
self.settings_panel.set_play_pause_state("run")
|
||||||
self.settings_panel.set_csv_controls_for_pipeline(False)
|
self.settings_panel.set_csv_controls_for_pipeline(False)
|
||||||
|
self._refresh_cameras(announce=True)
|
||||||
|
|
||||||
self._panel_widgets: dict[str, QWidget] = {}
|
self._panel_widgets: dict[str, QWidget] = {}
|
||||||
self._panel_widgets["mesh"] = self.mesh_viewport
|
self._panel_widgets["mesh"] = self.mesh_viewport
|
||||||
@@ -162,6 +183,37 @@ class FingerDashboardWindow(QMainWindow):
|
|||||||
self.log_widget.setVisible(self._log_panel_visible)
|
self.log_widget.setVisible(self._log_panel_visible)
|
||||||
QTimer.singleShot(0, self._apply_panel_layout)
|
QTimer.singleShot(0, self._apply_panel_layout)
|
||||||
|
|
||||||
|
def _on_log_visibility_changed(self, visible: bool) -> None:
|
||||||
|
self._set_log_panel_visible(visible)
|
||||||
|
self._save_preferences()
|
||||||
|
|
||||||
|
def _on_free_rotation_changed(self, enabled: bool) -> None:
|
||||||
|
self.mesh_viewport.set_free_rotation(enabled)
|
||||||
|
self._save_preferences()
|
||||||
|
|
||||||
|
def _on_arrow_background_changed(self, value: str) -> None:
|
||||||
|
if self.arrow_panel is not None:
|
||||||
|
self.arrow_panel.set_background(value)
|
||||||
|
self._save_preferences()
|
||||||
|
|
||||||
|
def _save_preferences(self) -> None:
|
||||||
|
prefs = self.settings_panel.preference_state()
|
||||||
|
self._preferences = prefs
|
||||||
|
save_preferences(prefs)
|
||||||
|
# Keep in-memory pipeline config aligned with panel values.
|
||||||
|
video = prefs.get("video_source")
|
||||||
|
if isinstance(video, str) and video.strip():
|
||||||
|
self.config.video = video.strip()
|
||||||
|
config_path = prefs.get("config_path")
|
||||||
|
if isinstance(config_path, str) and config_path.strip():
|
||||||
|
self.config.config = config_path.strip()
|
||||||
|
assets = prefs.get("finger_assets_dir")
|
||||||
|
if isinstance(assets, str) and assets.strip():
|
||||||
|
self.config.export_dir = assets.strip()
|
||||||
|
csv_dir = prefs.get("csv_save_dir")
|
||||||
|
if isinstance(csv_dir, str) and csv_dir.strip():
|
||||||
|
self.config.csv_save_dir = csv_dir.strip()
|
||||||
|
|
||||||
def _apply_panel_layout(self) -> None:
|
def _apply_panel_layout(self) -> None:
|
||||||
setup = self.layout.panel_setup
|
setup = self.layout.panel_setup
|
||||||
scale = self._layout_scale_factor()
|
scale = self._layout_scale_factor()
|
||||||
@@ -323,6 +375,14 @@ class FingerDashboardWindow(QMainWindow):
|
|||||||
self.config.export_dir = self.settings_panel.finger_export_dir()
|
self.config.export_dir = self.settings_panel.finger_export_dir()
|
||||||
self.config.csv_save_dir = self.settings_panel.csv_save_dir() or "./outputs/csv"
|
self.config.csv_save_dir = self.settings_panel.csv_save_dir() or "./outputs/csv"
|
||||||
|
|
||||||
|
if not self.config.video:
|
||||||
|
self._set_play_pause_button("run")
|
||||||
|
self.settings_panel.set_finger_export_dir_enabled(True)
|
||||||
|
print("请先选择摄像机或视频文件。")
|
||||||
|
return
|
||||||
|
|
||||||
|
self._save_preferences()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.pipeline = FingerPipeline(self.config)
|
self.pipeline = FingerPipeline(self.config)
|
||||||
self.pipeline.open()
|
self.pipeline.open()
|
||||||
@@ -342,6 +402,7 @@ class FingerDashboardWindow(QMainWindow):
|
|||||||
self.timer.start(self.layout.timer_interval_ms)
|
self.timer.start(self.layout.timer_interval_ms)
|
||||||
self._set_play_pause_button("pause")
|
self._set_play_pause_button("pause")
|
||||||
self.settings_panel.btn_reset_tracker.setEnabled(True)
|
self.settings_panel.btn_reset_tracker.setEnabled(True)
|
||||||
|
self.settings_panel.set_camera_controls_enabled(False)
|
||||||
self.settings_panel.set_csv_controls_for_pipeline(True)
|
self.settings_panel.set_csv_controls_for_pipeline(True)
|
||||||
print(
|
print(
|
||||||
f"▶ 启动 — 摄像机={self.config.video}, 配置={self.config.config}, "
|
f"▶ 启动 — 摄像机={self.config.video}, 配置={self.config.config}, "
|
||||||
@@ -364,7 +425,9 @@ class FingerDashboardWindow(QMainWindow):
|
|||||||
self._set_play_pause_button("run")
|
self._set_play_pause_button("run")
|
||||||
self.settings_panel.btn_reset_tracker.setEnabled(False)
|
self.settings_panel.btn_reset_tracker.setEnabled(False)
|
||||||
self.settings_panel.set_finger_export_dir_enabled(True)
|
self.settings_panel.set_finger_export_dir_enabled(True)
|
||||||
|
self.settings_panel.set_camera_controls_enabled(True)
|
||||||
self.settings_panel.set_csv_controls_for_pipeline(False)
|
self.settings_panel.set_csv_controls_for_pipeline(False)
|
||||||
|
self._update_play_enabled_for_source()
|
||||||
|
|
||||||
def _on_reset(self) -> None:
|
def _on_reset(self) -> None:
|
||||||
if not self._running:
|
if not self._running:
|
||||||
@@ -459,6 +522,60 @@ class FingerDashboardWindow(QMainWindow):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _refresh_cameras(self, announce: bool = True) -> None:
|
||||||
|
"""Scan cameras and populate the settings dropdown."""
|
||||||
|
keep_file = self.settings_panel.is_file_source()
|
||||||
|
existing_file = self.settings_panel.video_source() if keep_file else ""
|
||||||
|
|
||||||
|
preferred = None
|
||||||
|
current = self.settings_panel.selected_camera_source()
|
||||||
|
saved = get_preferred_video_source(self._preferences)
|
||||||
|
cli_video = str(self.config.video).strip()
|
||||||
|
if current.isdigit():
|
||||||
|
preferred = current
|
||||||
|
elif saved and str(saved).isdigit():
|
||||||
|
preferred = str(saved)
|
||||||
|
elif cli_video.isdigit():
|
||||||
|
preferred = cli_video
|
||||||
|
|
||||||
|
cameras = list_cameras()
|
||||||
|
selected = pick_default_camera(cameras, preferred)
|
||||||
|
|
||||||
|
self.settings_panel.set_camera_options(cameras, selected)
|
||||||
|
if keep_file and existing_file:
|
||||||
|
self.settings_panel.set_video_source(existing_file)
|
||||||
|
elif selected:
|
||||||
|
self.settings_panel.set_video_source(selected)
|
||||||
|
self.config.video = selected
|
||||||
|
elif not keep_file:
|
||||||
|
self.config.video = ""
|
||||||
|
|
||||||
|
self._update_play_enabled_for_source()
|
||||||
|
self._save_preferences()
|
||||||
|
if announce:
|
||||||
|
if cameras:
|
||||||
|
names = ", ".join(cam.label() for cam in cameras)
|
||||||
|
print(f"已发现 {len(cameras)} 路摄像机:{names}")
|
||||||
|
if selected and not keep_file:
|
||||||
|
print(f"当前摄像机:{selected}")
|
||||||
|
else:
|
||||||
|
print("未发现可用摄像机。可点击 Refresh 重扫,或通过 … 选择视频文件。")
|
||||||
|
|
||||||
|
def _update_play_enabled_for_source(self) -> None:
|
||||||
|
if self._running:
|
||||||
|
return
|
||||||
|
usable = self.settings_panel.has_usable_source()
|
||||||
|
self.settings_panel.set_play_pause_state("run")
|
||||||
|
self.settings_panel.btn_play_pause.setEnabled(usable)
|
||||||
|
|
||||||
|
def _on_camera_selection_changed(self, camera_id: str) -> None:
|
||||||
|
camera_id = str(camera_id).strip()
|
||||||
|
if not camera_id:
|
||||||
|
return
|
||||||
|
self.config.video = camera_id
|
||||||
|
self._save_preferences()
|
||||||
|
self._update_play_enabled_for_source()
|
||||||
|
|
||||||
def _select_camera(self) -> None:
|
def _select_camera(self) -> None:
|
||||||
path, _ = QFileDialog.getOpenFileName(
|
path, _ = QFileDialog.getOpenFileName(
|
||||||
self,
|
self,
|
||||||
@@ -468,6 +585,9 @@ class FingerDashboardWindow(QMainWindow):
|
|||||||
)
|
)
|
||||||
if path:
|
if path:
|
||||||
self.settings_panel.set_video_source(path)
|
self.settings_panel.set_video_source(path)
|
||||||
|
self.config.video = path
|
||||||
|
self._save_preferences()
|
||||||
|
self._update_play_enabled_for_source()
|
||||||
|
|
||||||
def _select_config(self) -> None:
|
def _select_config(self) -> None:
|
||||||
path, _ = QFileDialog.getOpenFileName(
|
path, _ = QFileDialog.getOpenFileName(
|
||||||
@@ -478,6 +598,8 @@ class FingerDashboardWindow(QMainWindow):
|
|||||||
)
|
)
|
||||||
if path:
|
if path:
|
||||||
self.settings_panel.set_config_path(path)
|
self.settings_panel.set_config_path(path)
|
||||||
|
self.config.config = path
|
||||||
|
self._save_preferences()
|
||||||
|
|
||||||
def _select_finger_assets(self) -> None:
|
def _select_finger_assets(self) -> None:
|
||||||
path = QFileDialog.getExistingDirectory(
|
path = QFileDialog.getExistingDirectory(
|
||||||
@@ -487,6 +609,8 @@ class FingerDashboardWindow(QMainWindow):
|
|||||||
)
|
)
|
||||||
if path:
|
if path:
|
||||||
self.settings_panel.set_finger_export_dir(path)
|
self.settings_panel.set_finger_export_dir(path)
|
||||||
|
self.config.export_dir = path
|
||||||
|
self._save_preferences()
|
||||||
|
|
||||||
def _select_csv_dir(self) -> None:
|
def _select_csv_dir(self) -> None:
|
||||||
path = QFileDialog.getExistingDirectory(
|
path = QFileDialog.getExistingDirectory(
|
||||||
@@ -497,6 +621,7 @@ class FingerDashboardWindow(QMainWindow):
|
|||||||
if path:
|
if path:
|
||||||
self.settings_panel.set_csv_save_dir(path)
|
self.settings_panel.set_csv_save_dir(path)
|
||||||
self.config.csv_save_dir = path
|
self.config.csv_save_dir = path
|
||||||
|
self._save_preferences()
|
||||||
|
|
||||||
def _on_csv_record_toggled(self, start: bool) -> None:
|
def _on_csv_record_toggled(self, start: bool) -> None:
|
||||||
if start:
|
if start:
|
||||||
@@ -556,6 +681,7 @@ class FingerDashboardWindow(QMainWindow):
|
|||||||
def closeEvent(self, event):
|
def closeEvent(self, event):
|
||||||
self.timer.stop()
|
self.timer.stop()
|
||||||
self._stop_csv_recording(announce=True)
|
self._stop_csv_recording(announce=True)
|
||||||
|
self._save_preferences()
|
||||||
self.mesh_viewport.close_renderer()
|
self.mesh_viewport.close_renderer()
|
||||||
self.pipeline.close()
|
self.pipeline.close()
|
||||||
event.accept()
|
event.accept()
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"video_source": "0",
|
||||||
|
"config_path": "./config/dd02-ov.json",
|
||||||
|
"finger_assets_dir": "D:/repo/orisys-sdk/assets/finger/30fps-0703",
|
||||||
|
"csv_save_dir": "./outputs/csv",
|
||||||
|
"show_log": true,
|
||||||
|
"show_flow_arrows": false,
|
||||||
|
"arrow_background": "pure_color",
|
||||||
|
"free_rotate": true
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""Load/save dashboard user preferences near this module."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .app_paths import resource_path
|
||||||
|
|
||||||
|
PREFERENCE_FILE = "preference.json"
|
||||||
|
LEGACY_CAMERA_SELECTION_FILE = "camera_selection.json"
|
||||||
|
_GUI_DIR = Path(__file__).resolve().parent
|
||||||
|
|
||||||
|
|
||||||
|
def preference_path() -> Path:
|
||||||
|
# Keep preference file beside this module: examples/gui/preference.json
|
||||||
|
return _GUI_DIR / PREFERENCE_FILE
|
||||||
|
|
||||||
|
|
||||||
|
def legacy_camera_selection_path() -> Path:
|
||||||
|
return resource_path(LEGACY_CAMERA_SELECTION_FILE)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_json(path: Path) -> dict[str, Any]:
|
||||||
|
if not path.is_file():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
data = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return {}
|
||||||
|
return data if isinstance(data, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def load_preferences() -> dict[str, Any]:
|
||||||
|
"""Load preferences, migrating camera_id from legacy file when needed."""
|
||||||
|
prefs = _read_json(preference_path())
|
||||||
|
if "video_source" not in prefs:
|
||||||
|
legacy = _read_json(legacy_camera_selection_path())
|
||||||
|
camera_id = legacy.get("camera_id")
|
||||||
|
if camera_id is not None and str(camera_id).strip():
|
||||||
|
prefs["video_source"] = str(camera_id).strip()
|
||||||
|
return prefs
|
||||||
|
|
||||||
|
|
||||||
|
def save_preferences(prefs: dict[str, Any]) -> None:
|
||||||
|
"""Write preferences atomically enough for dashboard use."""
|
||||||
|
if not isinstance(prefs, dict):
|
||||||
|
return
|
||||||
|
path = preference_path()
|
||||||
|
try:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(json.dumps(prefs, indent=2) + "\n", encoding="utf-8")
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def get_preferred_video_source(prefs: dict[str, Any] | None = None) -> str | None:
|
||||||
|
data = prefs if prefs is not None else load_preferences()
|
||||||
|
value = data.get("video_source")
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
text = str(value).strip()
|
||||||
|
return text or None
|
||||||
@@ -43,6 +43,8 @@ class SettingsPanelWidget(QWidget):
|
|||||||
browse_finger_assets_requested = pyqtSignal()
|
browse_finger_assets_requested = pyqtSignal()
|
||||||
browse_csv_dir_requested = pyqtSignal()
|
browse_csv_dir_requested = pyqtSignal()
|
||||||
csv_record_toggled = pyqtSignal(bool)
|
csv_record_toggled = pyqtSignal(bool)
|
||||||
|
camera_refresh_requested = pyqtSignal()
|
||||||
|
camera_selection_changed = pyqtSignal(str)
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -105,9 +107,16 @@ class SettingsPanelWidget(QWidget):
|
|||||||
root.addWidget(self._caption("Video source"))
|
root.addWidget(self._caption("Video source"))
|
||||||
video_row = QHBoxLayout()
|
video_row = QHBoxLayout()
|
||||||
video_row.setSpacing(6)
|
video_row.setSpacing(6)
|
||||||
self.video_edit = QLineEdit("0")
|
self.camera_combo = QComboBox()
|
||||||
self.video_edit.setPlaceholderText("Camera ID or video path")
|
self.camera_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||||
video_row.addWidget(self.video_edit, 1)
|
self.camera_combo.setToolTip("Select an available camera")
|
||||||
|
self.camera_combo.currentIndexChanged.connect(self._on_camera_combo_changed)
|
||||||
|
video_row.addWidget(self.camera_combo, 1)
|
||||||
|
self.btn_refresh_cameras = QPushButton("Refresh")
|
||||||
|
self.btn_refresh_cameras.setFixedWidth(64)
|
||||||
|
self.btn_refresh_cameras.setToolTip("Re-scan available cameras")
|
||||||
|
self.btn_refresh_cameras.clicked.connect(self.camera_refresh_requested.emit)
|
||||||
|
video_row.addWidget(self.btn_refresh_cameras)
|
||||||
self.btn_browse_video = QPushButton("…")
|
self.btn_browse_video = QPushButton("…")
|
||||||
self.btn_browse_video.setFixedWidth(32)
|
self.btn_browse_video.setFixedWidth(32)
|
||||||
self.btn_browse_video.setToolTip("Browse video file")
|
self.btn_browse_video.setToolTip("Browse video file")
|
||||||
@@ -115,6 +124,16 @@ class SettingsPanelWidget(QWidget):
|
|||||||
video_row.addWidget(self.btn_browse_video)
|
video_row.addWidget(self.btn_browse_video)
|
||||||
root.addLayout(video_row)
|
root.addLayout(video_row)
|
||||||
|
|
||||||
|
self.video_file_label = QLabel("")
|
||||||
|
self.video_file_label.setObjectName("settingsCaption")
|
||||||
|
self.video_file_label.setWordWrap(True)
|
||||||
|
self.video_file_label.hide()
|
||||||
|
root.addWidget(self.video_file_label)
|
||||||
|
|
||||||
|
self._source_mode = "camera" # "camera" | "file"
|
||||||
|
self._video_file_path = ""
|
||||||
|
self._updating_camera_options = False
|
||||||
|
|
||||||
root.addWidget(self._caption("Config"))
|
root.addWidget(self._caption("Config"))
|
||||||
config_row = QHBoxLayout()
|
config_row = QHBoxLayout()
|
||||||
config_row.setSpacing(6)
|
config_row.setSpacing(6)
|
||||||
@@ -218,8 +237,103 @@ class SettingsPanelWidget(QWidget):
|
|||||||
style.polish(btn)
|
style.polish(btn)
|
||||||
btn.update()
|
btn.update()
|
||||||
|
|
||||||
|
def set_camera_options(self, cameras, selected_value: str | None = None) -> None:
|
||||||
|
"""Populate camera combo from discovered cameras.
|
||||||
|
|
||||||
|
``cameras`` items may be objects with ``.source`` / ``.label()`` or
|
||||||
|
``(source, label)`` tuples.
|
||||||
|
"""
|
||||||
|
self._updating_camera_options = True
|
||||||
|
self.camera_combo.blockSignals(True)
|
||||||
|
self.camera_combo.clear()
|
||||||
|
|
||||||
|
sources: list[str] = []
|
||||||
|
for cam in cameras:
|
||||||
|
if hasattr(cam, "source") and callable(getattr(cam, "label", None)):
|
||||||
|
source = str(cam.source)
|
||||||
|
label = str(cam.label())
|
||||||
|
else:
|
||||||
|
source, label = cam
|
||||||
|
source = str(source)
|
||||||
|
label = str(label)
|
||||||
|
self.camera_combo.addItem(label, source)
|
||||||
|
sources.append(source)
|
||||||
|
|
||||||
|
preferred = (selected_value or "").strip()
|
||||||
|
idx = self.camera_combo.findData(preferred) if preferred else -1
|
||||||
|
if idx < 0 and self.camera_combo.count() > 0:
|
||||||
|
idx = 0
|
||||||
|
if idx >= 0:
|
||||||
|
self.camera_combo.setCurrentIndex(idx)
|
||||||
|
# File mode is cleared only when we intentionally select a camera.
|
||||||
|
if self._source_mode != "file":
|
||||||
|
self._source_mode = "camera"
|
||||||
|
self._video_file_path = ""
|
||||||
|
self.video_file_label.hide()
|
||||||
|
self.camera_combo.setEnabled(self.camera_combo.count() > 0)
|
||||||
|
self.camera_combo.blockSignals(False)
|
||||||
|
self._updating_camera_options = False
|
||||||
|
|
||||||
def set_video_source(self, value: str) -> None:
|
def set_video_source(self, value: str) -> None:
|
||||||
self.video_edit.setText(value)
|
"""Set current video source (camera index string or file path)."""
|
||||||
|
value = (value or "").strip()
|
||||||
|
if not value:
|
||||||
|
return
|
||||||
|
# Numeric camera index → select in combo if present; else keep as preferred text via adding.
|
||||||
|
if value.isdigit() or (value.startswith("-") and value[1:].isdigit()):
|
||||||
|
idx = self.camera_combo.findData(value)
|
||||||
|
if idx >= 0:
|
||||||
|
self._source_mode = "camera"
|
||||||
|
self._video_file_path = ""
|
||||||
|
self.video_file_label.hide()
|
||||||
|
self.camera_combo.blockSignals(True)
|
||||||
|
self.camera_combo.setCurrentIndex(idx)
|
||||||
|
self.camera_combo.blockSignals(False)
|
||||||
|
else:
|
||||||
|
# Not yet scanned / not present: remember as pending camera selection.
|
||||||
|
self._source_mode = "camera"
|
||||||
|
self._video_file_path = ""
|
||||||
|
self.video_file_label.hide()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Treat as video file path.
|
||||||
|
self._source_mode = "file"
|
||||||
|
self._video_file_path = value
|
||||||
|
self.video_file_label.setText(f"File: {value}")
|
||||||
|
self.video_file_label.show()
|
||||||
|
|
||||||
|
def selected_camera_source(self) -> str:
|
||||||
|
data = self.camera_combo.currentData()
|
||||||
|
return str(data) if data is not None else ""
|
||||||
|
|
||||||
|
def video_source(self) -> str:
|
||||||
|
if self._source_mode == "file" and self._video_file_path:
|
||||||
|
return self._video_file_path
|
||||||
|
return self.selected_camera_source()
|
||||||
|
|
||||||
|
def has_usable_source(self) -> bool:
|
||||||
|
if self._source_mode == "file" and self._video_file_path:
|
||||||
|
return True
|
||||||
|
return bool(self.selected_camera_source())
|
||||||
|
|
||||||
|
def is_file_source(self) -> bool:
|
||||||
|
return self._source_mode == "file" and bool(self._video_file_path)
|
||||||
|
|
||||||
|
def set_camera_controls_enabled(self, enabled: bool) -> None:
|
||||||
|
self.btn_refresh_cameras.setEnabled(enabled)
|
||||||
|
self.btn_browse_video.setEnabled(enabled)
|
||||||
|
self.camera_combo.setEnabled(enabled and self.camera_combo.count() > 0)
|
||||||
|
|
||||||
|
def _on_camera_combo_changed(self, _index: int) -> None:
|
||||||
|
if self._updating_camera_options:
|
||||||
|
return
|
||||||
|
source = self.selected_camera_source()
|
||||||
|
if not source:
|
||||||
|
return
|
||||||
|
self._source_mode = "camera"
|
||||||
|
self._video_file_path = ""
|
||||||
|
self.video_file_label.hide()
|
||||||
|
self.camera_selection_changed.emit(source)
|
||||||
|
|
||||||
def set_config_path(self, value: str) -> None:
|
def set_config_path(self, value: str) -> None:
|
||||||
self.config_edit.setText(value)
|
self.config_edit.setText(value)
|
||||||
@@ -290,9 +404,6 @@ class SettingsPanelWidget(QWidget):
|
|||||||
value = self.arrow_background_combo.currentData()
|
value = self.arrow_background_combo.currentData()
|
||||||
return value if value else "unrolled"
|
return value if value else "unrolled"
|
||||||
|
|
||||||
def video_source(self) -> str:
|
|
||||||
return self.video_edit.text().strip()
|
|
||||||
|
|
||||||
def config_path(self) -> str:
|
def config_path(self) -> str:
|
||||||
return self.config_edit.text().strip()
|
return self.config_edit.text().strip()
|
||||||
|
|
||||||
@@ -304,3 +415,60 @@ class SettingsPanelWidget(QWidget):
|
|||||||
|
|
||||||
def csv_save_dir(self) -> str:
|
def csv_save_dir(self) -> str:
|
||||||
return self.csv_dir_edit.text().strip()
|
return self.csv_dir_edit.text().strip()
|
||||||
|
|
||||||
|
def preference_state(self) -> dict:
|
||||||
|
"""Serialize user-facing settings suitable for preference.json."""
|
||||||
|
return {
|
||||||
|
"video_source": self.video_source(),
|
||||||
|
"config_path": self.config_path(),
|
||||||
|
"finger_assets_dir": self.finger_export_dir(),
|
||||||
|
"csv_save_dir": self.csv_save_dir(),
|
||||||
|
"show_log": bool(self.show_log_check.isChecked()),
|
||||||
|
"show_flow_arrows": bool(self.show_flow_arrows_check.isChecked()),
|
||||||
|
"arrow_background": self.arrow_background(),
|
||||||
|
"free_rotate": bool(self.free_rotate_check.isChecked()),
|
||||||
|
}
|
||||||
|
|
||||||
|
def apply_preference_state(self, state: dict | None) -> None:
|
||||||
|
"""Restore user-facing settings from preference.json."""
|
||||||
|
if not isinstance(state, dict) or not state:
|
||||||
|
return
|
||||||
|
|
||||||
|
show_log = state.get("show_log")
|
||||||
|
if isinstance(show_log, bool):
|
||||||
|
self.show_log_check.blockSignals(True)
|
||||||
|
self.show_log_check.setChecked(show_log)
|
||||||
|
self.show_log_check.blockSignals(False)
|
||||||
|
|
||||||
|
show_flow = state.get("show_flow_arrows")
|
||||||
|
if isinstance(show_flow, bool):
|
||||||
|
self.show_flow_arrows_check.blockSignals(True)
|
||||||
|
self.show_flow_arrows_check.setChecked(show_flow)
|
||||||
|
self.show_flow_arrows_check.blockSignals(False)
|
||||||
|
self._mesh_state.set_flow_arrows_enabled(show_flow)
|
||||||
|
|
||||||
|
arrow_bg = state.get("arrow_background")
|
||||||
|
if isinstance(arrow_bg, str) and arrow_bg:
|
||||||
|
self.set_arrow_background(arrow_bg) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
free_rotate = state.get("free_rotate")
|
||||||
|
if isinstance(free_rotate, bool):
|
||||||
|
self.free_rotate_check.blockSignals(True)
|
||||||
|
self.free_rotate_check.setChecked(free_rotate)
|
||||||
|
self.free_rotate_check.blockSignals(False)
|
||||||
|
|
||||||
|
config_path = state.get("config_path")
|
||||||
|
if isinstance(config_path, str) and config_path.strip():
|
||||||
|
self.set_config_path(config_path.strip())
|
||||||
|
|
||||||
|
assets = state.get("finger_assets_dir")
|
||||||
|
if isinstance(assets, str) and assets.strip():
|
||||||
|
self.set_finger_export_dir(assets.strip())
|
||||||
|
|
||||||
|
csv_dir = state.get("csv_save_dir")
|
||||||
|
if isinstance(csv_dir, str) and csv_dir.strip():
|
||||||
|
self.set_csv_save_dir(csv_dir.strip())
|
||||||
|
|
||||||
|
video = state.get("video_source")
|
||||||
|
if isinstance(video, str) and video.strip():
|
||||||
|
self.set_video_source(video.strip())
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ A Python library for processing and analyzing optical tactile sensor data,
|
|||||||
including image stitching, optical flow tracking, and deformation decomposition.
|
including image stitching, optical flow tracking, and deformation decomposition.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "0.4.1"
|
__version__ = "0.4.2"
|
||||||
__author__ = "Orisys"
|
__author__ = "Orisys"
|
||||||
|
|
||||||
from .sensor import Sensor
|
from .sensor import Sensor
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ def test_pyproject_dynamic_version(packaging_utils):
|
|||||||
sys.path.insert(0, str(build_dir))
|
sys.path.insert(0, str(build_dir))
|
||||||
from pyproject_utils import get_version
|
from pyproject_utils import get_version
|
||||||
|
|
||||||
assert get_version(REPO_ROOT) == "0.4.1"
|
assert get_version(REPO_ROOT) == "0.4.2"
|
||||||
|
|
||||||
|
|
||||||
def test_pyproject_bundle_manifest(packaging_utils):
|
def test_pyproject_bundle_manifest(packaging_utils):
|
||||||
|
|||||||
Reference in New Issue
Block a user