From 11a747a771fd039265e48b70d42cbd79245972a1 Mon Sep 17 00:00:00 2001 From: ykcheungab Date: Wed, 15 Jul 2026 15:08:59 +0800 Subject: [PATCH] added camera search and select, auto preference saving and version tag to 0.4.2 --- docs/api.md | 2 +- docs/get_started.md | 10 +- examples/gui/camera_discovery.py | 139 +++++++++++++++++++ examples/gui/dashboard.py | 132 +++++++++++++++++- examples/gui/preference.json | 10 ++ examples/gui/preferences.py | 64 +++++++++ examples/gui/widgets/settings_panel.py | 182 ++++++++++++++++++++++++- src/orisys/__init__.py | 2 +- tests/test_pyproject.py | 2 +- 9 files changed, 525 insertions(+), 18 deletions(-) create mode 100644 examples/gui/camera_discovery.py create mode 100644 examples/gui/preference.json create mode 100644 examples/gui/preferences.py diff --git a/docs/api.md b/docs/api.md index 0925b45..ed626f0 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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 版本选择):** diff --git a/docs/get_started.md b/docs/get_started.md index dbd7159..1459600 100644 --- a/docs/get_started.md +++ b/docs/get_started.md @@ -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),则说明安装成功。 # 使用方法 ## 运行示例程序 diff --git a/examples/gui/camera_discovery.py b/examples/gui/camera_discovery.py new file mode 100644 index 0000000..b161abe --- /dev/null +++ b/examples/gui/camera_discovery.py @@ -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 diff --git a/examples/gui/dashboard.py b/examples/gui/dashboard.py index 820c24e..bd2b094 100644 --- a/examples/gui/dashboard.py +++ b/examples/gui/dashboard.py @@ -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() diff --git a/examples/gui/preference.json b/examples/gui/preference.json new file mode 100644 index 0000000..4e97c5d --- /dev/null +++ b/examples/gui/preference.json @@ -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 +} diff --git a/examples/gui/preferences.py b/examples/gui/preferences.py new file mode 100644 index 0000000..b363f76 --- /dev/null +++ b/examples/gui/preferences.py @@ -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 diff --git a/examples/gui/widgets/settings_panel.py b/examples/gui/widgets/settings_panel.py index 1338be7..539a5a8 100644 --- a/examples/gui/widgets/settings_panel.py +++ b/examples/gui/widgets/settings_panel.py @@ -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()) diff --git a/src/orisys/__init__.py b/src/orisys/__init__.py index 3e985bf..b9bdd0d 100644 --- a/src/orisys/__init__.py +++ b/src/orisys/__init__.py @@ -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 diff --git a/tests/test_pyproject.py b/tests/test_pyproject.py index 92d4768..485009d 100644 --- a/tests/test_pyproject.py +++ b/tests/test_pyproject.py @@ -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):