Skip to content

Commit 34f0f89

Browse files
committed
Centralize settings with QSettings store
Introduce DLCLiveGUISettingsStore and use it throughout the GUI to persist/load settings (session name, timestamp preference, last config path, full config snapshot). Replace ad-hoc QSettings helpers in main_window with calls to the new store, save snapshots/last-path when loading/saving configs, and wire session/timestamp persistence to the store. Replace internal DLC stats formatting with shared format_dlc_stats util. Add logging/debug messages and safer path normalization to ModelPathStore. Add pragma markers for coverage around OneEuroFilter in dlc_processor_socket. Update GUI tests to isolate QSettings (use INI in tmp) and disable modal dialogs during tests to avoid native crashes in CI.
1 parent 35df954 commit 34f0f89

File tree

4 files changed

+111
-126
lines changed

4 files changed

+111
-126
lines changed

dlclivegui/gui/main_window.py

Lines changed: 55 additions & 117 deletions
Original file line numberDiff line numberDiff line change
@@ -56,11 +56,11 @@
5656
scan_processor_folder,
5757
scan_processor_package,
5858
)
59-
from dlclivegui.services.dlc_processor import DLCLiveProcessor, PoseResult, ProcessorStats
59+
from dlclivegui.services.dlc_processor import DLCLiveProcessor, PoseResult
6060
from dlclivegui.services.multi_camera_controller import MultiCameraController, MultiFrameData, get_camera_id
61-
from dlclivegui.services.video_recorder import RecorderStats
6261
from dlclivegui.utils.display import compute_tile_info, create_tiled_frame, draw_bbox, draw_pose
63-
from dlclivegui.utils.settings_store import ModelPathStore
62+
from dlclivegui.utils.settings_store import DLCLiveGUISettingsStore, ModelPathStore
63+
from dlclivegui.utils.stats import format_dlc_stats
6464
from dlclivegui.utils.utils import FPSTracker
6565

6666
# logging.basicConfig(level=logging.INFO)
@@ -75,28 +75,43 @@ def __init__(self, config: ApplicationSettings | None = None):
7575
super().__init__()
7676
self.setWindowTitle("DeepLabCut Live GUI")
7777

78-
# Try to load myconfig.json from the application directory if no config provided
79-
# NOTE @C-Achard Leaving this as a convenience for now
80-
# TODO @C-Achard change this to a smarter "reload previous config" mechanism
78+
self.settings = QSettings("DeepLabCut", "DLCLiveGUI")
79+
self._model_path_store = ModelPathStore(self.settings)
80+
self._settings_store = DLCLiveGUISettingsStore(self.settings)
81+
8182
if config is None:
82-
# myconfig_path = Path(__file__).parent.parent / "myconfig.json"
83-
# if myconfig_path.exists():
84-
# try:
85-
# config = ApplicationSettings.load(str(myconfig_path))
86-
# self._config_path = myconfig_path
87-
# logger.info(f"Loaded configuration from {myconfig_path}")
88-
# except Exception as exc:
89-
# logger.warning(f"Failed to load myconfig.json: {exc}. Using default config.")
90-
# config = DEFAULT_CONFIG
91-
# self._config_path = None
92-
# else:
93-
config = DEFAULT_CONFIG
94-
self._config_path = None
83+
# 1) snapshot
84+
cfg = self._settings_store.load_full_config_snapshot()
85+
if cfg is not None:
86+
config = cfg
87+
self._config_path = None
88+
logger.info("Loaded configuration from QSettings snapshot.")
89+
else:
90+
# 2) last config file path
91+
last_cfg_path = self._settings_store.get_last_config_path()
92+
if last_cfg_path:
93+
try:
94+
p = Path(last_cfg_path)
95+
if p.exists() and p.is_file():
96+
config = ApplicationSettings.load(str(p))
97+
self._config_path = p
98+
logger.info(f"Loaded configuration from last config path: {p}")
99+
else:
100+
config = DEFAULT_CONFIG
101+
self._config_path = None
102+
except Exception as exc:
103+
logger.warning(
104+
f"Failed to load last config path ({last_cfg_path}): {exc}. Using default config."
105+
)
106+
config = DEFAULT_CONFIG
107+
self._config_path = None
108+
else:
109+
# 3) default
110+
config = DEFAULT_CONFIG
111+
self._config_path = None
95112
else:
96113
self._config_path = None
97114

98-
self.settings = QSettings("DeepLabCut", "DLCLiveGUI")
99-
self._model_path_store = ModelPathStore(self.settings)
100115
self._fps_tracker = FPSTracker()
101116
self._rec_manager = RecordingManager()
102117
self._dlc = DLCLiveProcessor()
@@ -167,7 +182,15 @@ def __init__(self, config: ApplicationSettings | None = None):
167182
self.statusBar().showMessage(f"Auto-loaded configuration from {self._config_path}", 5000)
168183

169184
# Validate cameras from loaded config (deferred to allow window to show first)
185+
# NOTE IMPORTANT (tests/CI): This is scheduled via a QTimer and may fire during pytest-qt teardown.
170186
QTimer.singleShot(100, self._validate_configured_cameras)
187+
# If validation triggers a modal QMessageBox (warning/error) while the parent window is closing,
188+
# it can cause Windows native crashes (heap corruption / access violations).
189+
#
190+
# Mitigations for tests/CI:
191+
# - Disable this timer by monkeypatching _validate_configured_cameras in GUI tests
192+
# - OR monkeypatch/override _show_warning/_show_error to no-op in GUI tests (easiest)
193+
# - OR use a cancellable QTimer attribute and stop() it in closeEven
171194

172195
def resizeEvent(self, event):
173196
super().resizeEvent(event)
@@ -677,12 +700,12 @@ def _apply_config(self, config: ApplicationSettings) -> None:
677700
## Restore persisted session name if empty
678701
if hasattr(self, "session_name_edit"):
679702
if not self.session_name_edit.text().strip():
680-
persisted = self._load_persisted_session_name()
703+
persisted = self._settings_store.get_session_name()
681704
if persisted:
682705
self.session_name_edit.setText(persisted)
683706
## Restore "Use timestamp" checkbox state
684707
if hasattr(self, "use_timestamp_checkbox"):
685-
self.use_timestamp_checkbox.setChecked(self._load_persisted_use_timestamp())
708+
self.use_timestamp_checkbox.setChecked(self._settings_store.get_use_timestamp(default=True))
686709

687710
# Set bounding box settings from config
688711
bbox = config.bbox
@@ -772,6 +795,8 @@ def _action_load_config(self) -> None:
772795
except Exception as exc: # pragma: no cover - GUI interaction
773796
self._show_error(str(exc))
774797
return
798+
self._settings_store.set_last_config_path(file_name)
799+
self._settings_store.save_full_config_snapshot(config)
775800
self._config = config
776801
self._config_path = Path(file_name)
777802
self._apply_config(config)
@@ -799,6 +824,8 @@ def _save_config_to_path(self, path: Path) -> None:
799824
try:
800825
config = self._current_config()
801826
config.save(path)
827+
self._settings_store.set_last_config_path(str(path))
828+
self._settings_store.save_full_config_snapshot(config)
802829
except Exception as exc: # pragma: no cover - GUI interaction
803830
self._show_error(str(exc))
804831
return
@@ -919,7 +946,7 @@ def _refresh_processors(self) -> None:
919946
# Recording path preview and session name persistence
920947
def _on_session_name_editing_finished(self) -> None:
921948
name = self.session_name_edit.text().strip()
922-
self._persist_session_name(name)
949+
self._settings_store.set_session_name(name)
923950
self._update_recording_path_preview()
924951

925952
def _update_recording_path_preview(self) -> None:
@@ -941,7 +968,7 @@ def _update_recording_path_preview(self) -> None:
941968
)
942969

943970
def _on_use_timestamp_changed(self, _state: int) -> None:
944-
self._persist_use_timestamp(self.use_timestamp_checkbox.isChecked())
971+
self._settings_store.set_use_timestamp(self.use_timestamp_checkbox.isChecked())
945972
self._update_recording_path_preview()
946973

947974
# ------------------------------------------------------------------
@@ -1249,7 +1276,7 @@ def _start_multi_camera_recording(self) -> None:
12491276
self._show_error("Failed to start recording.")
12501277
return
12511278

1252-
self._persist_session_name(session_name)
1279+
self._settings_store.set_session_name(session_name)
12531280
self.start_record_button.setEnabled(False)
12541281
self.stop_record_button.setEnabled(True)
12551282
self.statusBar().showMessage(f"Recording {len(active_cams)} camera(s) to {run_dir}", 5000)
@@ -1469,55 +1496,6 @@ def _update_display_from_pending(self) -> None:
14691496
self._current_frame = tiled
14701497
self._update_video_display(tiled)
14711498

1472-
def _format_recorder_stats(self, stats: RecorderStats) -> str:
1473-
latency_ms = stats.last_latency * 1000.0
1474-
avg_ms = stats.average_latency * 1000.0
1475-
buffer_ms = stats.buffer_seconds * 1000.0
1476-
write_fps = stats.write_fps
1477-
enqueue = stats.frames_enqueued
1478-
written = stats.frames_written
1479-
dropped = stats.dropped_frames
1480-
return (
1481-
f"{written}/{enqueue} frames | write {write_fps:.1f} fps | "
1482-
f"latency {latency_ms:.1f} ms (avg {avg_ms:.1f} ms) | "
1483-
f"queue {stats.queue_size} (~{buffer_ms:.0f} ms) | dropped {dropped}"
1484-
)
1485-
1486-
def _format_dlc_stats(self, stats: ProcessorStats) -> str:
1487-
"""Format DLC processor statistics for display."""
1488-
latency_ms = stats.last_latency * 1000.0
1489-
avg_ms = stats.average_latency * 1000.0
1490-
processing_fps = stats.processing_fps
1491-
enqueue = stats.frames_enqueued
1492-
processed = stats.frames_processed
1493-
dropped = stats.frames_dropped
1494-
1495-
# Add profiling info if available
1496-
profile_info = ""
1497-
if stats.avg_inference_time > 0:
1498-
inf_ms = stats.avg_inference_time * 1000.0
1499-
queue_ms = stats.avg_queue_wait * 1000.0
1500-
signal_ms = stats.avg_signal_emit_time * 1000.0
1501-
total_ms = stats.avg_total_process_time * 1000.0
1502-
1503-
# Add GPU vs processor breakdown if available
1504-
gpu_breakdown = ""
1505-
if stats.avg_gpu_inference_time > 0 or stats.avg_processor_overhead > 0:
1506-
gpu_ms = stats.avg_gpu_inference_time * 1000.0
1507-
proc_ms = stats.avg_processor_overhead * 1000.0
1508-
gpu_breakdown = f" (GPU:{gpu_ms:.1f}ms+proc:{proc_ms:.1f}ms)"
1509-
1510-
profile_info = (
1511-
f"\n[Profile] inf:{inf_ms:.1f}ms{gpu_breakdown} queue:{queue_ms:.1f}ms "
1512-
f"signal:{signal_ms:.1f}ms total:{total_ms:.1f}ms"
1513-
)
1514-
1515-
return (
1516-
f"{processed}/{enqueue} frames | inference {processing_fps:.1f} fps | "
1517-
f"latency {latency_ms:.1f} ms (avg {avg_ms:.1f} ms) | "
1518-
f"queue {stats.queue_size} | dropped {dropped}{profile_info}"
1519-
)
1520-
15211499
def _update_metrics(self) -> None:
15221500
# --- Camera stats ---
15231501
if hasattr(self, "camera_stats_label"):
@@ -1553,7 +1531,7 @@ def _update_metrics(self) -> None:
15531531
if hasattr(self, "dlc_stats_label"):
15541532
if self._dlc_active and self._dlc_initialized:
15551533
stats = self._dlc.get_stats()
1556-
summary = self._format_dlc_stats(stats)
1534+
summary = format_dlc_stats(stats)
15571535
self.dlc_stats_label.setText(summary)
15581536
else:
15591537
self.dlc_stats_label.setText("DLC processor idle")
@@ -1613,7 +1591,7 @@ def _update_processor_status(self) -> None:
16131591

16141592
# Processor overrides session name field + persist it
16151593
self.session_name_edit.setText(session_name)
1616-
self._persist_session_name(session_name)
1594+
self._settings_store.set_session_name(session_name)
16171595

16181596
# Optional: set base filename to session name (readable stable filenames)
16191597
self.filename_edit.setText(session_name)
@@ -1792,46 +1770,6 @@ def _show_info(self, message: str) -> None:
17921770
self.statusBar().showMessage(message, 5000)
17931771
QMessageBox.information(self, "Information", message)
17941772

1795-
# FIXME @C-Achard move to config/dedicated Store class
1796-
def _session_settings_key(self) -> str:
1797-
return "recording/session_name"
1798-
1799-
def _use_timestamp_settings_key(self) -> str:
1800-
return "recording/use_timestamp"
1801-
1802-
def _load_persisted_session_name(self) -> str:
1803-
try:
1804-
return self.settings.value(self._session_settings_key(), "", type=str) or ""
1805-
except Exception:
1806-
return ""
1807-
1808-
def _persist_session_name(self, name: str) -> None:
1809-
try:
1810-
self.settings.setValue(self._session_settings_key(), name)
1811-
except Exception:
1812-
pass
1813-
1814-
def _load_persisted_use_timestamp(self) -> bool:
1815-
"""Load checkbox state from QSettings (defaults to True)."""
1816-
try:
1817-
# QSettings sometimes returns strings; type=bool helps but isn't perfect everywhere.
1818-
v = self.settings.value(self._use_timestamp_settings_key(), True)
1819-
if isinstance(v, bool):
1820-
return v
1821-
if isinstance(v, (int, float)):
1822-
return bool(v)
1823-
if isinstance(v, str):
1824-
return v.strip().lower() in ("1", "true", "yes", "on")
1825-
return True
1826-
except Exception:
1827-
return True
1828-
1829-
def _persist_use_timestamp(self, value: bool) -> None:
1830-
try:
1831-
self.settings.setValue(self._use_timestamp_settings_key(), bool(value))
1832-
except Exception:
1833-
pass
1834-
18351773
# ------------------------------------------------------------------
18361774
# Qt overrides
18371775
def closeEvent(self, event: QCloseEvent) -> None: # pragma: no cover - GUI behaviour

dlclivegui/processors/dlc_processor_socket.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ def register_processor(cls):
3232
return cls
3333

3434

35+
# pragma: no cover
3536
class OneEuroFilter:
3637
def __init__(self, t0, x0, dx0=None, min_cutoff=1.0, beta=0.0, d_cutoff=1.0):
3738
self.min_cutoff = min_cutoff
@@ -71,6 +72,9 @@ def __call__(self, t, x):
7172
return x_hat
7273

7374

75+
# pragma: cover
76+
77+
7478
# @register_processor # Not registering base class in the GUI
7579
class BaseProcessorSocket(Processor):
7680
"""

dlclivegui/utils/settings_store.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
# dlclivegui/utils/settings_store.py
2+
import logging
23
from pathlib import Path
34

45
from PySide6.QtCore import QSettings
56

67
from ..config import ApplicationSettings
78
from .utils import is_model_file
89

10+
logger = logging.getLogger(__name__)
11+
912

1013
class DLCLiveGUISettingsStore:
1114
def __init__(self, qsettings: QSettings | None = None):
@@ -57,6 +60,7 @@ def load_full_config_snapshot(self) -> ApplicationSettings | None:
5760
try:
5861
return ApplicationSettings.model_validate_json(str(raw))
5962
except Exception:
63+
logger.debug("Failed to load full config snapshot from QSettings")
6064
return None
6165

6266

@@ -70,8 +74,9 @@ def _norm(self, p: str | None) -> str | None:
7074
if not p:
7175
return None
7276
try:
73-
return str(Path(p).expanduser())
77+
return str(Path(p).expanduser().resolve())
7478
except Exception:
79+
logger.debug("Failed to normalize path: %s", p)
7580
return None
7681

7782
def load_last(self) -> str | None:
@@ -82,6 +87,7 @@ def load_last(self) -> str | None:
8287
try:
8388
return path if is_model_file(path) else None
8489
except Exception:
90+
logger.debug("Last model path is not a valid model file: %s", path)
8591
return None
8692

8793
def load_last_dir(self) -> str | None:
@@ -93,6 +99,7 @@ def load_last_dir(self) -> str | None:
9399
p = Path(d)
94100
return str(p) if p.exists() and p.is_dir() else None
95101
except Exception:
102+
logger.debug("Last model dir is not a valid directory: %s", d)
96103
return None
97104

98105
def save_if_valid(self, path: str) -> None:
@@ -107,6 +114,7 @@ def save_if_valid(self, path: str) -> None:
107114
if is_model_file(path):
108115
self._settings.setValue("dlc/last_model_path", str(Path(path)))
109116
except Exception:
117+
logger.debug("Failed to save last model path: %s", path)
110118
pass
111119

112120
def save_last_dir(self, directory: str) -> None:
@@ -128,6 +136,7 @@ def resolve(self, config_path: str | None) -> str:
128136
if is_model_file(config_path):
129137
return config_path
130138
except Exception:
139+
logger.debug("Config path is not a valid model file: %s", config_path)
131140
pass
132141

133142
persisted = self.load_last()
@@ -155,6 +164,7 @@ def suggest_start_dir(self, fallback_dir: str | None = None) -> str:
155164
if parent.exists():
156165
return str(parent)
157166
except Exception:
167+
logger.debug("Failed to get parent of last model file: %s", last_file)
158168
pass
159169

160170
# 3) fallback dir (config.model_directory) if valid
@@ -164,6 +174,7 @@ def suggest_start_dir(self, fallback_dir: str | None = None) -> str:
164174
if p.exists() and p.is_dir():
165175
return str(p)
166176
except Exception:
177+
logger.debug("Fallback dir is not a valid directory: %s", fallback_dir)
167178
pass
168179

169180
# 4) last resort: home
@@ -178,4 +189,5 @@ def suggest_selected_file(self) -> str | None:
178189
p = Path(last_file)
179190
return str(p) if p.exists() and p.is_file() else None
180191
except Exception:
192+
logger.debug("Failed to check existence of last model file: %s", last_file)
181193
return None

0 commit comments

Comments
 (0)