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
6060from dlclivegui .services .multi_camera_controller import MultiCameraController , MultiFrameData , get_camera_id
61- from dlclivegui .services .video_recorder import RecorderStats
6261from 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
6464from 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
0 commit comments