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
|
||||
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 版本选择):**
|
||||
|
||||
+5
-5
@@ -1,5 +1,5 @@
|
||||
叠动科技软件使用手册
|
||||
版本: 0.4.1
|
||||
版本: 0.4.2
|
||||
更新日期: 2026年7月13日
|
||||
|
||||
# 简介
|
||||
@@ -57,9 +57,9 @@ orisys SDK 以 .whl 文件形式提供,您需要根据操作系统、Python
|
||||
## 下载正确的 .whl 文件
|
||||
从官方渠道获取 .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)
|
||||
操作系统:Windows(win)
|
||||
硬件架构:64位(amd64)
|
||||
@@ -71,7 +71,7 @@ Python 版本:3.10(cp310)
|
||||
|
||||
``` bash
|
||||
# 请将文件名替换为您实际下载的文件名
|
||||
pip install orisys-0.4.1-cp310-cp310-win_amd64.whl
|
||||
pip install orisys-0.4.2-cp310-cp310-win_amd64.whl
|
||||
```
|
||||
|
||||
## 安装 CuPy(可选)
|
||||
@@ -108,7 +108,7 @@ python
|
||||
import orisys
|
||||
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 .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 (
|
||||
DashboardLayout,
|
||||
LAYOUT,
|
||||
@@ -102,26 +104,45 @@ class FingerDashboardWindow(QMainWindow):
|
||||
mesh_state=self.mesh_state,
|
||||
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_config_path(self.config.config)
|
||||
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_csv_save_dir(self.config.csv_save_dir)
|
||||
|
||||
self.settings_panel.log_visibility_changed.connect(self._set_log_panel_visible)
|
||||
self.settings_panel.free_rotation_changed.connect(self.mesh_viewport.set_free_rotation)
|
||||
self._preferences = load_preferences()
|
||||
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:
|
||||
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.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_finger_assets_requested.connect(self._select_finger_assets)
|
||||
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.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_reset_tracker.clicked.connect(self._on_reset)
|
||||
self.settings_panel.set_play_pause_state("run")
|
||||
self.settings_panel.set_csv_controls_for_pipeline(False)
|
||||
self._refresh_cameras(announce=True)
|
||||
|
||||
self._panel_widgets: dict[str, QWidget] = {}
|
||||
self._panel_widgets["mesh"] = self.mesh_viewport
|
||||
@@ -162,6 +183,37 @@ class FingerDashboardWindow(QMainWindow):
|
||||
self.log_widget.setVisible(self._log_panel_visible)
|
||||
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:
|
||||
setup = self.layout.panel_setup
|
||||
scale = self._layout_scale_factor()
|
||||
@@ -323,6 +375,14 @@ class FingerDashboardWindow(QMainWindow):
|
||||
self.config.export_dir = self.settings_panel.finger_export_dir()
|
||||
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:
|
||||
self.pipeline = FingerPipeline(self.config)
|
||||
self.pipeline.open()
|
||||
@@ -342,6 +402,7 @@ class FingerDashboardWindow(QMainWindow):
|
||||
self.timer.start(self.layout.timer_interval_ms)
|
||||
self._set_play_pause_button("pause")
|
||||
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)
|
||||
print(
|
||||
f"▶ 启动 — 摄像机={self.config.video}, 配置={self.config.config}, "
|
||||
@@ -364,7 +425,9 @@ class FingerDashboardWindow(QMainWindow):
|
||||
self._set_play_pause_button("run")
|
||||
self.settings_panel.btn_reset_tracker.setEnabled(False)
|
||||
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._update_play_enabled_for_source()
|
||||
|
||||
def _on_reset(self) -> None:
|
||||
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:
|
||||
path, _ = QFileDialog.getOpenFileName(
|
||||
self,
|
||||
@@ -468,6 +585,9 @@ class FingerDashboardWindow(QMainWindow):
|
||||
)
|
||||
if 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:
|
||||
path, _ = QFileDialog.getOpenFileName(
|
||||
@@ -478,6 +598,8 @@ class FingerDashboardWindow(QMainWindow):
|
||||
)
|
||||
if path:
|
||||
self.settings_panel.set_config_path(path)
|
||||
self.config.config = path
|
||||
self._save_preferences()
|
||||
|
||||
def _select_finger_assets(self) -> None:
|
||||
path = QFileDialog.getExistingDirectory(
|
||||
@@ -487,6 +609,8 @@ class FingerDashboardWindow(QMainWindow):
|
||||
)
|
||||
if path:
|
||||
self.settings_panel.set_finger_export_dir(path)
|
||||
self.config.export_dir = path
|
||||
self._save_preferences()
|
||||
|
||||
def _select_csv_dir(self) -> None:
|
||||
path = QFileDialog.getExistingDirectory(
|
||||
@@ -497,6 +621,7 @@ class FingerDashboardWindow(QMainWindow):
|
||||
if path:
|
||||
self.settings_panel.set_csv_save_dir(path)
|
||||
self.config.csv_save_dir = path
|
||||
self._save_preferences()
|
||||
|
||||
def _on_csv_record_toggled(self, start: bool) -> None:
|
||||
if start:
|
||||
@@ -556,6 +681,7 @@ class FingerDashboardWindow(QMainWindow):
|
||||
def closeEvent(self, event):
|
||||
self.timer.stop()
|
||||
self._stop_csv_recording(announce=True)
|
||||
self._save_preferences()
|
||||
self.mesh_viewport.close_renderer()
|
||||
self.pipeline.close()
|
||||
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_csv_dir_requested = pyqtSignal()
|
||||
csv_record_toggled = pyqtSignal(bool)
|
||||
camera_refresh_requested = pyqtSignal()
|
||||
camera_selection_changed = pyqtSignal(str)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -105,9 +107,16 @@ class SettingsPanelWidget(QWidget):
|
||||
root.addWidget(self._caption("Video source"))
|
||||
video_row = QHBoxLayout()
|
||||
video_row.setSpacing(6)
|
||||
self.video_edit = QLineEdit("0")
|
||||
self.video_edit.setPlaceholderText("Camera ID or video path")
|
||||
video_row.addWidget(self.video_edit, 1)
|
||||
self.camera_combo = QComboBox()
|
||||
self.camera_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||
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.setFixedWidth(32)
|
||||
self.btn_browse_video.setToolTip("Browse video file")
|
||||
@@ -115,6 +124,16 @@ class SettingsPanelWidget(QWidget):
|
||||
video_row.addWidget(self.btn_browse_video)
|
||||
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"))
|
||||
config_row = QHBoxLayout()
|
||||
config_row.setSpacing(6)
|
||||
@@ -218,8 +237,103 @@ class SettingsPanelWidget(QWidget):
|
||||
style.polish(btn)
|
||||
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:
|
||||
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:
|
||||
self.config_edit.setText(value)
|
||||
@@ -290,9 +404,6 @@ class SettingsPanelWidget(QWidget):
|
||||
value = self.arrow_background_combo.currentData()
|
||||
return value if value else "unrolled"
|
||||
|
||||
def video_source(self) -> str:
|
||||
return self.video_edit.text().strip()
|
||||
|
||||
def config_path(self) -> str:
|
||||
return self.config_edit.text().strip()
|
||||
|
||||
@@ -304,3 +415,60 @@ class SettingsPanelWidget(QWidget):
|
||||
|
||||
def csv_save_dir(self) -> str:
|
||||
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.
|
||||
"""
|
||||
|
||||
__version__ = "0.4.1"
|
||||
__version__ = "0.4.2"
|
||||
__author__ = "Orisys"
|
||||
|
||||
from .sensor import Sensor
|
||||
|
||||
@@ -35,7 +35,7 @@ def test_pyproject_dynamic_version(packaging_utils):
|
||||
sys.path.insert(0, str(build_dir))
|
||||
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):
|
||||
|
||||
Reference in New Issue
Block a user