From 827ec1b1ff613132eda0a3d04691950f891f39a8 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 26 Jun 2026 15:21:28 -0500 Subject: [PATCH 01/20] Use one-by-one Basler grab strategy Switch Basler camera startup to `pylon.GrabStrategy_OneByOne` instead of `LatestImageOnly`, and update the nearby identity-persistence comment for clarity. --- dlclivegui/cameras/backends/basler_backend.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/dlclivegui/cameras/backends/basler_backend.py b/dlclivegui/cameras/backends/basler_backend.py index 802876f04..8960c5877 100644 --- a/dlclivegui/cameras/backends/basler_backend.py +++ b/dlclivegui/cameras/backends/basler_backend.py @@ -638,7 +638,8 @@ def open(self) -> None: pass self._camera.StartGrabbing( - pylon.GrabStrategy_LatestImageOnly, + # pylon.GrabStrategy_LatestImageOnly, + pylon.GrabStrategy_OneByOne, ) LOG.info( "[Basler] grabbing=%s max_buffers=%s", @@ -661,7 +662,7 @@ def open(self) -> None: ) # ---------------------------- - # Persist stable identity into namespace (migration-safe) + # Persist stable identity into namespace # ---------------------------- try: serial = device.GetSerialNumber() From 3ba454681f3027b7257fbd76fc905f8f2548c309 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 26 Jun 2026 15:21:42 -0500 Subject: [PATCH 02/20] Ignore profiling output artifacts Add ignore patterns for generated profiling files (`profile*.svg`, `scalene*.json`, and `scalene*.html`) so local performance analysis outputs are not accidentally committed. --- .gitignore | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gitignore b/.gitignore index 7c5b18d80..1782ab32c 100644 --- a/.gitignore +++ b/.gitignore @@ -113,3 +113,8 @@ venv.bak/ !dlclivegui/config.py # uv package files uv.lock + +# profiling +profile*.svg +scalene*.json +scalene*.html From 29fcfeb715f5928f32456341bd632af773b6c6cd Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 26 Jun 2026 15:21:55 -0500 Subject: [PATCH 03/20] Add profiling extra with Scalene Introduce a new `profiling` optional dependency group in `pyproject.toml` and include `scalene` so profiling tools can be installed independently from test and framework extras. --- pyproject.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 265d9530c..da48c7820 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,6 +63,9 @@ test = [ "tox", "tox-gh-actions", ] +profiling = [ + "scalene", +] tf = [ "deeplabcut-live[tf]>=1.1", ] From dd4e8636e0da7c7d3e99d4b69ff7b76589b1e72d Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 26 Jun 2026 15:24:16 -0500 Subject: [PATCH 04/20] Optimize recording pipeline and encoder options Adds a dedicated full-rate `recording_frame_ready` signal path so recording is decoupled from the inference/display frame flow, reducing processing overhead during capture. The GUI now exposes a fast-encoding toggle and persists it into recording settings, and recorder startup passes codec-specific writer options through to `VideoRecorder`. Recording telemetry was expanded to report enqueued vs written frames, writer FPS, queue fill against buffer size, backlog, and drops, improving visibility into recording throughput and pressure. --- dlclivegui/gui/main_window.py | 66 ++++++++++++++++--- dlclivegui/gui/recording_manager.py | 26 +++++++- .../services/multi_camera_controller.py | 17 ++++- dlclivegui/services/video_recorder.py | 53 ++++++++++++++- 4 files changed, 146 insertions(+), 16 deletions(-) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 57b006a13..038be981c 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -633,13 +633,29 @@ def _build_recording_group(self) -> QGroupBox: form.addRow(grid) - # Record with overlays + # Recording options self.record_with_overlays_checkbox = QCheckBox("Record video with overlays") self.record_with_overlays_checkbox.setToolTip( "Enable to include pose overlays in recorded video (keypoints & bounding boxes)" ) self.record_with_overlays_checkbox.setChecked(False) - form.addRow(self.record_with_overlays_checkbox) + + self.fast_encoding_checkbox = QCheckBox("Use faster encoding parameters") + self.fast_encoding_checkbox.setToolTip( + "Use faster FFmpeg parameters for supported codecs.\n" + "For libx264/libx265 this uses preset=ultrafast and tune=zerolatency.\n" + "This can improve recording throughput but may increase file size." + ) + self.fast_encoding_checkbox.setChecked(False) + + recording_options = QWidget() + recording_options_layout = QHBoxLayout(recording_options) + recording_options_layout.setContentsMargins(0, 0, 0, 0) + recording_options_layout.addWidget(self.record_with_overlays_checkbox) + recording_options_layout.addWidget(self.fast_encoding_checkbox) + recording_options_layout.addStretch(1) + + form.addRow(recording_options) # Wrap recording buttons in a widget to prevent shifting recording_button_widget = QWidget() @@ -772,6 +788,7 @@ def _connect_signals(self) -> None: # Multi-camera controller signals (used for both single and multi-camera modes) self.multi_camera_controller.frame_ready.connect(self._on_multi_frame_processing_ready) self.multi_camera_controller.display_ready.connect(self._on_multi_frame_display_ready) + self.multi_camera_controller.recording_frame_ready.connect(self._on_recording_frame_ready) self.multi_camera_controller.all_started.connect(self._on_multi_camera_started) self.multi_camera_controller.all_stopped.connect(self._on_multi_camera_stopped) self.multi_camera_controller.camera_error.connect(self._on_multi_camera_error) @@ -822,6 +839,10 @@ def _apply_config(self, config: ApplicationSettings) -> None: self.codec_combo.addItem(recording.codec) self.codec_combo.setCurrentIndex(self.codec_combo.count() - 1) self.crf_spin.setValue(int(recording.crf)) + + if hasattr(self, "fast_encoding_checkbox"): + self.fast_encoding_checkbox.setChecked(bool(getattr(recording, "fast_encoding", False))) + ## Restore persisted session name if empty if hasattr(self, "session_name_edit"): if not self.session_name_edit.text().strip(): @@ -934,6 +955,9 @@ def _recording_settings_from_ui(self) -> RecordingSettings: container=self.container_combo.currentText().strip() or "mp4", codec=self.codec_combo.currentText().strip() or "libx264", crf=int(self.crf_spin.value()), + fast_encoding=bool( + getattr(self, "fast_encoding_checkbox", None) and self.fast_encoding_checkbox.isChecked() + ), ) def _bbox_settings_from_ui(self) -> BoundingBoxSettings: @@ -1376,6 +1400,24 @@ def _render_overlays_for_recording(self, cam_id, frame): ) return output + def _on_recording_frame_ready(self, camera_id: str, frame: np.ndarray, timestamp: float) -> None: + """Handle full-rate per-camera frames for recording only. + + Intentionally lean: + - no MultiFrameData processing + - no DLC routing + - no display state updates + - no FPS tracker + - optional overlays only if user requested recording overlays + """ + if not self._rec_manager.is_active: + return + + if self.record_with_overlays_checkbox.isChecked(): + frame = self._render_overlays_for_recording(camera_id, frame) + + self._rec_manager.write_frame(camera_id, frame, timestamp) + def _on_multi_frame_processing_ready(self, frame_data: MultiFrameData) -> None: """Handle frames from multiple cameras. @@ -1429,15 +1471,15 @@ def _on_multi_frame_processing_ready(self, frame_data: MultiFrameData) -> None: self._dlc.enqueue_frame(frame, timestamp) # PRIORITY 2: Recording (queued, non-blocking) - if self._rec_manager.is_active and src_id in frame_data.frames: - frame = frame_data.frames[src_id] + # if self._rec_manager.is_active and src_id in frame_data.frames: + # frame = frame_data.frames[src_id] - if self.record_with_overlays_checkbox.isChecked(): - # Draw overlays for recording - frame = self._render_overlays_for_recording(src_id, frame) + # if self.record_with_overlays_checkbox.isChecked(): + # # Draw overlays for recording + # frame = self._render_overlays_for_recording(src_id, frame) - ts = frame_data.timestamps.get(src_id, time.time()) - self._rec_manager.write_frame(src_id, frame, ts) + # ts = frame_data.timestamps.get(src_id, time.time()) + # self._rec_manager.write_frame(src_id, frame, ts) def _on_multi_frame_display_ready(self, frame_data: MultiFrameData) -> None: """Throttled UI/display path. @@ -1518,6 +1560,7 @@ def _start_multi_camera_recording(self) -> None: if run_dir is None: self._show_error("Failed to start recording.") return + self.multi_camera_controller.set_recording_frame_do_emit(True) self._settings_store.set_session_name(session_name) self.start_record_button.setEnabled(False) @@ -1528,6 +1571,9 @@ def _start_multi_camera_recording(self) -> None: def _stop_multi_camera_recording(self) -> None: if not self._rec_manager.is_active: return + + self.multi_camera_controller.set_recording_frame_do_emit(False) + self._rec_manager.stop_all() self.start_record_button.setEnabled(True) self.stop_record_button.setEnabled(False) @@ -1719,6 +1765,8 @@ def _update_camera_controls_enabled(self) -> None: recording_editable = not multi_cam_recording self.codec_combo.setEnabled(recording_editable) self.crf_spin.setEnabled(recording_editable) + if hasattr(self, "fast_encoding_checkbox"): + self.fast_encoding_checkbox.setEnabled(recording_editable) # Config cameras button should be available when not in preview/recording self.config_cameras_button.setEnabled(allow_changes) diff --git a/dlclivegui/gui/recording_manager.py b/dlclivegui/gui/recording_manager.py index ae02388b5..eeb6fd7b5 100644 --- a/dlclivegui/gui/recording_manager.py +++ b/dlclivegui/gui/recording_manager.py @@ -138,15 +138,19 @@ def start_all( frame = current_frames.get(cam_id) frame_size = (frame.shape[0], frame.shape[1]) if frame is not None else None recorder_fps = self._resolve_recording_fps(cam, cam_id, frame_rates) + writer_options = recording.writegear_options(recorder_fps) log.debug( - "Starting recorder %s -> %s frame_size=%s requested_fps=%s detected_fps=%s recorder_fps=%s", + "Starting recorder %s -> %s frame_size=%s requested_fps=%s detected_fps=%s " + "recorder_fps=%s fast_encoding=%s writer_options=%s", cam_id, cam_path, frame_size, getattr(cam, "fps", None), self._backend_ns(cam).get("detected_fps"), f"{recorder_fps:.3f}" if recorder_fps else "auto/fallback", + bool(getattr(recording, "fast_encoding", False)), + writer_options, ) recorder = VideoRecorder( @@ -204,9 +208,13 @@ def write_frame(self, cam_id: str, frame: np.ndarray, timestamp: float | None = def get_stats_summary(self) -> str: totals = { + "enqueued": 0, "written": 0, "dropped": 0, "queue": 0, + "buffer": 0, + "backlog": 0, + "write_fps": 0.0, "max_latency": 0.0, "avg_latencies": [], } @@ -214,9 +222,13 @@ def get_stats_summary(self) -> str: stats: RecorderStats | None = rec.get_stats() if not stats: continue + totals["enqueued"] += stats.frames_enqueued totals["written"] += stats.frames_written totals["dropped"] += stats.dropped_frames totals["queue"] += stats.queue_size + totals["buffer"] += stats.buffer_size + totals["backlog"] += stats.backlog_frames + totals["write_fps"] += stats.write_fps totals["max_latency"] = max(totals["max_latency"], stats.last_latency) totals["avg_latencies"].append(stats.average_latency) @@ -230,8 +242,16 @@ def get_stats_summary(self) -> str: return "Recording..." else: avg = sum(totals["avg_latencies"]) / len(totals["avg_latencies"]) if totals["avg_latencies"] else 0.0 + + buffer = totals["buffer"] + queue_text = f"{totals['queue']}/{buffer}" if buffer > 0 else str(totals["queue"]) + fill_pct = (100.0 * totals["queue"] / buffer) if buffer > 0 else 0.0 + return ( - f"{len(self._recorders)} cams | {totals['written']} frames | " + f"{len(self._recorders)} cams | {totals['written']}/{totals['enqueued']} frames | " + f"writer {totals['write_fps']:.1f} fps | " f"latency {totals['max_latency'] * 1000:.1f}ms (avg {avg * 1000:.1f}ms) | " - f"queue {totals['queue']} | dropped {totals['dropped']}" + f"queue {queue_text} ({fill_pct:.0f}%) | " + f"backlog {totals['backlog']} | " + f"dropped {totals['dropped']}" ) diff --git a/dlclivegui/services/multi_camera_controller.py b/dlclivegui/services/multi_camera_controller.py index 0f5ab80b0..334d699f8 100644 --- a/dlclivegui/services/multi_camera_controller.py +++ b/dlclivegui/services/multi_camera_controller.py @@ -282,7 +282,8 @@ class MultiCameraController(QObject): """Controller for managing multiple cameras simultaneously.""" # Signals - frame_ready = Signal(object) # MultiFrameData (full cam FPS; recording and inference only) + frame_ready = Signal(object) # MultiFrameData (full cam FPS; inference only) + recording_frame_ready = Signal(str, object, float) # camera_id, frame, timestamp (full cam FPS; for recording) display_ready = Signal(object) # MultiFrameData for GUI display (throttled to GUI_MAX_DISPLAY_FPS) camera_started = Signal(str, object) # camera_id, settings camera_stopped = Signal(str) # camera_id @@ -305,6 +306,7 @@ def __init__(self): self._running = False self._stopping = False self._all_stopped_emitted = False + self._recording_frame_emission_enabled: bool = False self._started_cameras: set = set() self._display_ids: dict[str, str] = {} # camera_id -> display_id (for labeling) self._camera_display_order: list[str] = [] @@ -337,6 +339,14 @@ def _timing_for_camera(self, camera_id: str) -> WorkerTimingStats: self._timing_per_cam[camera_id] = timing return timing + def set_recording_frame_do_emit(self, enabled: bool) -> None: + """Enable/disable the lightweight per-camera recording frame signal. + + This avoids sending recording-only traffic when the user is only previewing + or running DLC. + """ + self._recording_frame_emission_enabled = bool(enabled) + def _should_emit_display_ready(self) -> bool: """Return True when the UI/display path should be updated. @@ -407,6 +417,7 @@ def start(self, camera_settings: list[CameraSettings]) -> None: self._running = True self._stopping = False self._all_stopped_emitted = False + self._recording_frame_emission_enabled = False self._frames.clear() self._timestamps.clear() self._started_cameras.clear() @@ -601,6 +612,10 @@ def _on_frame_captured(self, camera_id: str, frame: np.ndarray, timestamp: float if crop_region: frame = MultiCameraController.apply_crop(frame, crop_region) + if self._recording_frame_emission_enabled: + with timing.measure("Multi.emit.recording_frame_ready"): + self.recording_frame_ready.emit(camera_id, frame, timestamp) + with self._frame_lock: with timing.measure("Multi.store_latest"): self._frames[camera_id] = frame diff --git a/dlclivegui/services/video_recorder.py b/dlclivegui/services/video_recorder.py index d067dbc55..9ae0ea58d 100644 --- a/dlclivegui/services/video_recorder.py +++ b/dlclivegui/services/video_recorder.py @@ -61,7 +61,52 @@ def build_writegear_options( class VideoRecorder: - """Thin wrapper around :class:`vidgear.gears.WriteGear`.""" + """Asynchronous video recorder backed by VidGear/FFmpeg. + + `VideoRecorder` wraps VidGear's `WriteGear` writer with a bounded in-memory + queue and a dedicated writer thread. Calls to `write()` perform minimal frame + validation/preprocessing, enqueue accepted frames without blocking, and return + immediately. The writer thread consumes queued frames and writes them to disk, + while also recording timestamps for successfully written frames. + + The recorder is intended for high-throughput camera pipelines where frame + acquisition should not block on video encoding. If the internal queue fills, + incoming frames are dropped and counted in recorder statistics. Timestamp + sidecar files are written on `stop()` for frames that were actually written. + + Args: + output: Output video path. + frame_size: Expected frame size as `(height, width)`. If provided, + incoming frames with different dimensions are rejected and the + recorder enters an error state. + frame_rate: Output video frame rate. If missing or non-positive, the + recorder falls back to 30 FPS and logs a warning. + codec: FFmpeg video codec name passed to WriteGear, for example + `"libx264"`. + crf: Constant Rate Factor passed to compatible FFmpeg encoders. Lower + values generally increase quality and file size. + buffer_size: Maximum number of frames that may wait in the recorder + queue before new frames are dropped. + convert_grayscale_to_rgb: Whether 2D grayscale frames should be expanded + to 3-channel RGB before writing. Set to `False` to preserve mono + frames when supported by the chosen writer/codec path. + fast_encoding: Whether to apply faster FFmpeg encoder settings when + supported by the selected codec. This can improve throughput at the + cost of larger files and/or reduced compression efficiency. + + Attributes: + is_running: Whether the writer thread is currently alive. + + Raises: + RuntimeError: If VidGear is unavailable, if the recorder is abandoned + after a failed stop, or if a previous encoding error is detected + during `write()`. + + Notes: + This class does not guarantee that every submitted frame is written. + Frames may be dropped when the queue is full, and timestamps are only + saved for frames successfully consumed by the writer thread. + """ def __init__( self, @@ -167,7 +212,7 @@ def start(self) -> None: logger.info( "Starting VideoRecorder output=%s frame_size=%s frame_rate=%.3f " - "codec=%s crf=%s buffer_size=%s convert_grayscale_to_rgb=%s", + "codec=%s crf=%s buffer_size=%s convert_grayscale_to_rgb=%s writer_options=%s", self._output, self._frame_size, fps_value, @@ -175,6 +220,7 @@ def start(self) -> None: self._crf, self._buffer_size, self._convert_grayscale_to_rgb, + self._writer_options, ) self._output.parent.mkdir(parents=True, exist_ok=True) @@ -360,12 +406,13 @@ def get_stats(self) -> RecorderStats | None: avg_latency = self._total_latency / self._frames_written if self._frames_written else 0.0 last_latency = self._last_latency write_fps = self._compute_write_fps_locked() - buffer_seconds = queue_size * avg_latency if avg_latency > 0 else 0.0 + buffer_seconds = queue_size / write_fps if write_fps > 0 else 0.0 return RecorderStats( frames_enqueued=frames_enqueued, frames_written=frames_written, dropped_frames=dropped, queue_size=queue_size, + buffer_size=self._buffer_size, average_latency=avg_latency, last_latency=last_latency, write_fps=write_fps, From 05164964b2fb34b0bfa972f8da6cbeb523f31bc0 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 26 Jun 2026 15:25:31 -0500 Subject: [PATCH 05/20] Enable timing logs and enrich recorder stats Turns on timing logging for multi-camera worker, recorder, and Basler backend diagnostics. Recording settings now include a `fast_encoding` flag, with `writegear_options` made more robust for missing/invalid FPS and optional low-latency FFmpeg options (`ultrafast` + `zerolatency`) for x264/x265. Recorder stats were expanded with buffer capacity awareness (`buffer_size`), derived backlog/fill-ratio properties, and richer formatted output showing queue fill and backlog. --- dlclivegui/config.py | 6 +++--- dlclivegui/gui/recording_manager.py | 2 +- dlclivegui/services/video_recorder.py | 2 +- dlclivegui/utils/stats.py | 23 ++++++++++++++++++++++- 4 files changed, 27 insertions(+), 6 deletions(-) diff --git a/dlclivegui/config.py b/dlclivegui/config.py index e6b468ca6..1fbc6b6b0 100644 --- a/dlclivegui/config.py +++ b/dlclivegui/config.py @@ -29,11 +29,11 @@ ## Debug ### Timing logs SINGLE_CAMERA_WORKER_DO_LOG_TIMING: bool = False -MULTI_CAMERA_WORKER_DO_LOG_TIMING: bool = False -REC_DO_LOG_TIMING: bool = False +MULTI_CAMERA_WORKER_DO_LOG_TIMING: bool = True +REC_DO_LOG_TIMING: bool = True # MAIN_WINDOW_DO_LOG_TIMING: bool = False #### Backends -BASLER_DO_LOG_TIMING: bool = False +BASLER_DO_LOG_TIMING: bool = True class CameraSettings(BaseModel): diff --git a/dlclivegui/gui/recording_manager.py b/dlclivegui/gui/recording_manager.py index eeb6fd7b5..95ed44299 100644 --- a/dlclivegui/gui/recording_manager.py +++ b/dlclivegui/gui/recording_manager.py @@ -138,7 +138,7 @@ def start_all( frame = current_frames.get(cam_id) frame_size = (frame.shape[0], frame.shape[1]) if frame is not None else None recorder_fps = self._resolve_recording_fps(cam, cam_id, frame_rates) - writer_options = recording.writegear_options(recorder_fps) + writer_options = recording.writegear_overrides() or None log.debug( "Starting recorder %s -> %s frame_size=%s requested_fps=%s detected_fps=%s " diff --git a/dlclivegui/services/video_recorder.py b/dlclivegui/services/video_recorder.py index 9ae0ea58d..30f3a3063 100644 --- a/dlclivegui/services/video_recorder.py +++ b/dlclivegui/services/video_recorder.py @@ -220,7 +220,7 @@ def start(self) -> None: self._crf, self._buffer_size, self._convert_grayscale_to_rgb, - self._writer_options, + self._writer_options_overrides, ) self._output.parent.mkdir(parents=True, exist_ok=True) diff --git a/dlclivegui/utils/stats.py b/dlclivegui/utils/stats.py index 3a00c02c6..1edbf7890 100644 --- a/dlclivegui/utils/stats.py +++ b/dlclivegui/utils/stats.py @@ -18,11 +18,24 @@ class RecorderStats: frames_written: int = 0 dropped_frames: int = 0 queue_size: int = 0 + buffer_size: int = 0 average_latency: float = 0.0 last_latency: float = 0.0 write_fps: float = 0.0 buffer_seconds: float = 0.0 + @property + def backlog_frames(self) -> int: + """Frames accepted by recorder but not yet written.""" + return max(0, self.frames_enqueued - self.frames_written) + + @property + def queue_fill_ratio(self) -> float: + """Queue fill ratio in [0, 1], or 0 when capacity is unknown.""" + if self.buffer_size <= 0: + return 0.0 + return min(1.0, max(0.0, self.queue_size / self.buffer_size)) + class WorkerTimingStats: """Tiny timing accumulator for camera worker performance diagnostics. @@ -128,11 +141,19 @@ def format_recorder_stats(stats: RecorderStats) -> str: latency_ms = stats.last_latency * 1000.0 avg_ms = stats.average_latency * 1000.0 buffer_ms = stats.buffer_seconds * 1000.0 + + if stats.buffer_size > 0: + fill_pct = stats.queue_fill_ratio * 100.0 + queue_text = f"{stats.queue_size}/{stats.buffer_size} ({fill_pct:.0f}%, ~{buffer_ms:.0f} ms)" + else: + queue_text = f"{stats.queue_size} (~{buffer_ms:.0f} ms)" + return ( f"{stats.frames_written}/{stats.frames_enqueued} frames | " f"write {stats.write_fps:.1f} fps | " f"latency {latency_ms:.1f} ms (avg {avg_ms:.1f} ms) | " - f"queue {stats.queue_size} (~{buffer_ms:.0f} ms) | " + f"queue {queue_text} | " + f"backlog {stats.backlog_frames} | " f"dropped {stats.dropped_frames}" ) From 8cf3ae8d126c88d1a092666843d43fbcb425cebf Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 26 Jun 2026 15:26:34 -0500 Subject: [PATCH 06/20] Expand recording behavior test coverage Updates test fixtures and unit tests around recording flow changes: FakeVideoRecorder now mirrors new constructor/runtime fields, Basler fake includes one-by-one grab strategy, and GUI/controller tests validate recording-frame emission gating plus overlay recording via `_on_recording_frame_ready`. Tests also cover `RecordingSettings.writegear_options` (including fast x264 options and FPS fallback), RecordingManager writer option wiring, and richer recorder stats formatting/aggregation with backlog and queue capacity output. --- dlclivegui/cameras/backends/basler_backend.py | 5 ++ tests/cameras/backends/conftest.py | 1 + tests/conftest.py | 19 +++++- tests/gui/test_pose_overlay.py | 13 +--- tests/gui/test_rec_manager.py | 54 +++++++++++++++-- tests/services/test_multicam_controller.py | 48 +++++++++++++++ tests/test_config.py | 43 +++++++++++++ tests/utils/test_stats.py | 60 ++++++++++++------- 8 files changed, 204 insertions(+), 39 deletions(-) diff --git a/dlclivegui/cameras/backends/basler_backend.py b/dlclivegui/cameras/backends/basler_backend.py index 8960c5877..56661522e 100644 --- a/dlclivegui/cameras/backends/basler_backend.py +++ b/dlclivegui/cameras/backends/basler_backend.py @@ -31,6 +31,8 @@ genicam = None # type: ignore[assignment] pylon = None # type: ignore[assignment] +DEBUG_TRIGGER_LOGS = False + @register_backend("basler") class BaslerCameraBackend(CameraBackend): @@ -959,6 +961,9 @@ def _set_numeric_feature(self, name: str, value, *, strict: bool = False) -> boo return False def _debug_trigger_nodes(self, *, context: str = "") -> None: + if not LOG.isEnabledFor(logging.DEBUG) or not DEBUG_TRIGGER_LOGS: + return + names = ( "TriggerSelector", "TriggerMode", diff --git a/tests/cameras/backends/conftest.py b/tests/cameras/backends/conftest.py index 798b2b80b..011aa72c1 100644 --- a/tests/cameras/backends/conftest.py +++ b/tests/cameras/backends/conftest.py @@ -390,6 +390,7 @@ class FakePylon: """Fake for 'from pypylon import pylon' used by BaslerCameraBackend.""" GrabStrategy_LatestImageOnly = 1 + GrabStrategy_OneByOne = 2 TimeoutHandling_ThrowException = 1 PixelType_BGR8packed = 0x02180014 OutputBitAlignment_MsbAligned = 1 diff --git a/tests/conftest.py b/tests/conftest.py index 7d12a70a1..49cd1c66e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -349,12 +349,28 @@ def fake_processor(): class FakeVideoRecorder: """Lightweight test double for VideoRecorder (no threads/ffmpeg).""" - def __init__(self, output, frame_size=None, frame_rate=None, codec="libx264", crf=23, **kwargs): + def __init__( + self, + output, + frame_size=None, + frame_rate=None, + codec="libx264", + crf=23, + buffer_size=240, + convert_grayscale_to_rgb=True, + writer_options=None, + **kwargs, + ): self.output = Path(output) self.frame_size = frame_size self.frame_rate = frame_rate self.codec = codec self.crf = crf + self.buffer_size = buffer_size + self.convert_grayscale_to_rgb = convert_grayscale_to_rgb + self.writer_options = dict(writer_options) if writer_options is not None else None + self.extra_kwargs = dict(kwargs) + self.started = False self.stopped = False self.write_calls = [] @@ -370,6 +386,7 @@ def start(self): if self.raise_on_start: raise RuntimeError("start failed") self.started = True + self.stopped = False def stop(self): self.stopped = True diff --git a/tests/gui/test_pose_overlay.py b/tests/gui/test_pose_overlay.py index 3af35308f..511d44552 100644 --- a/tests/gui/test_pose_overlay.py +++ b/tests/gui/test_pose_overlay.py @@ -65,18 +65,9 @@ def test_record_overlay_toggle_affects_frames_sent_to_recorder(window, recording # Provide a frame raw = np.zeros((100, 100, 3), dtype=np.uint8) - # Build minimal frame_data to call _on_multi_frame_processing_ready - from dlclivegui.services.multi_camera_controller import MultiFrameData - - frame_data = MultiFrameData( - frames={cam_id: raw}, - timestamps={cam_id: 1.0}, - source_camera_id=cam_id, - ) - # 1) toggle OFF: should record raw window.record_with_overlays_checkbox.setChecked(False) - window._on_multi_frame_processing_ready(frame_data) + window._on_recording_frame_ready(cam_id, raw, 1.0) assert cam_id in recording_frame_spy recorded_off = recording_frame_spy[cam_id] @@ -84,7 +75,7 @@ def test_record_overlay_toggle_affects_frames_sent_to_recorder(window, recording # 2) toggle ON: should record overlay frame (different) window.record_with_overlays_checkbox.setChecked(True) - window._on_multi_frame_processing_ready(frame_data) + window._on_recording_frame_ready(cam_id, raw, 2.0) recorded_on = recording_frame_spy[cam_id] assert not np.array_equal(recorded_on, raw) diff --git a/tests/gui/test_rec_manager.py b/tests/gui/test_rec_manager.py index b3654a231..f97c43a57 100644 --- a/tests/gui/test_rec_manager.py +++ b/tests/gui/test_rec_manager.py @@ -266,18 +266,36 @@ def test_get_stats_summary_multi_aggregates( mgr.start_all(recording_settings, _active_cams_two, current_frames, session_name="Sess") ids = [get_camera_id(c) for c in _active_cams_two] + mgr.recorders[ids[0]]._stats = RecorderStats( - frames_written=10, dropped_frames=1, queue_size=2, average_latency=0.01, last_latency=0.02 + frames_enqueued=12, + frames_written=10, + dropped_frames=1, + queue_size=2, + buffer_size=10, + average_latency=0.01, + last_latency=0.02, + write_fps=25.0, ) mgr.recorders[ids[1]]._stats = RecorderStats( - frames_written=20, dropped_frames=3, queue_size=4, average_latency=0.03, last_latency=0.05 + frames_enqueued=24, + frames_written=20, + dropped_frames=3, + queue_size=4, + buffer_size=10, + average_latency=0.03, + last_latency=0.05, + write_fps=30.0, ) summary = mgr.get_stats_summary() + assert "2 cams" in summary - assert "30 frames" in summary # 10 + 20 - assert "dropped 4" in summary # 1 + 3 - assert "queue 6" in summary # 2 + 4 + assert "30/36 frames" in summary + assert "writer 55.0 fps" in summary + assert "dropped 4" in summary + assert "queue 6/20" in summary + assert "backlog 6" in summary @pytest.mark.unit @@ -378,3 +396,29 @@ def test_start_all_does_not_infer_frame_size_from_display_id( # Since RecordingManager uses stable IDs internally, it should not find this frame. rec = mgr.recorders[stable_id] assert rec.frame_size is None + + +@pytest.mark.unit +def test_start_all_passes_writegear_options( + recording_settings, + _active_cams_two, + current_frames, + patch_video_recorder, + patch_build_run_dir, +): + recording_settings.codec = "libx264" + recording_settings.crf = 23 + recording_settings.fast_encoding = True + + mgr = RecordingManager() + mgr.start_all(recording_settings, _active_cams_two, current_frames, session_name="Sess") + + for cam in _active_cams_two: + cam_id = get_camera_id(cam) + rec = mgr.recorders[cam_id] + + assert rec.writer_options is not None + assert rec.writer_options["-vcodec"] == "libx264" + assert rec.writer_options["-crf"] == "23" + assert rec.writer_options["-preset"] == "ultrafast" + assert rec.writer_options["-tune"] == "zerolatency" diff --git a/tests/services/test_multicam_controller.py b/tests/services/test_multicam_controller.py index c0b074c24..9abb34b8f 100644 --- a/tests/services/test_multicam_controller.py +++ b/tests/services/test_multicam_controller.py @@ -499,3 +499,51 @@ def _create(settings): if mc.is_running(): with qtbot.waitSignal(mc.all_stopped, timeout=2000): mc.stop(wait=True) + + +@pytest.mark.unit +def test_recording_frame_ready_only_emits_when_enabled(qtbot, patch_factory): + mc = MultiCameraController() + + cam = CameraSettings( + name="C", + backend="opencv", + index=0, + enabled=True, + properties={"opencv": {"device_id": "cam-0"}}, + ).apply_defaults() + + cam_id = get_camera_id(cam) + seen: list[tuple[str, tuple, float]] = [] + + def on_recording_frame(camera_id, frame, timestamp): + seen.append((camera_id, frame.shape, timestamp)) + + mc.recording_frame_ready.connect(on_recording_frame) + + try: + with qtbot.waitSignal(mc.all_started, timeout=1500): + mc.start([cam]) + + # Disabled by default: should not emit recording frames. + qtbot.wait(300) + assert seen == [] + + mc.set_recording_frame_do_emit(True) + + qtbot.waitUntil(lambda: bool(seen), timeout=2000) + + camera_id, shape, timestamp = seen[-1] + assert camera_id == cam_id + assert isinstance(timestamp, float) + assert len(shape) in (2, 3) + + mc.set_recording_frame_do_emit(False) + count_after_disable = len(seen) + + qtbot.wait(300) + assert len(seen) == count_after_disable + + finally: + with qtbot.waitSignal(mc.all_stopped, timeout=2000): + mc.stop(wait=True) diff --git a/tests/test_config.py b/tests/test_config.py index b7b4ceb15..7c2f64bf0 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -5,6 +5,7 @@ CameraSettings, CameraTriggerSettings, MultiCameraSettings, + RecordingSettings, ) @@ -73,3 +74,45 @@ def test_trigger_source_defaults_to_auto(): trigger = CameraTriggerSettings() assert trigger.source == "auto" + + +def test_recording_settings_writegear_options_default(): + settings = RecordingSettings(codec="libx264", crf=23, fast_encoding=False) + + opts = settings.writegear_options(100.0) + + assert opts["-input_framerate"] == "100.000000" + assert opts["-vcodec"] == "libx264" + assert opts["-crf"] == "23" + assert "-preset" not in opts + assert "-tune" not in opts + + +def test_recording_settings_writegear_options_fast_encoding_x264(): + settings = RecordingSettings(codec="libx264", crf=23, fast_encoding=True) + + opts = settings.writegear_options(100.0) + + assert opts["-input_framerate"] == "100.000000" + assert opts["-vcodec"] == "libx264" + assert opts["-crf"] == "23" + assert opts["-preset"] == "ultrafast" + assert opts["-tune"] == "zerolatency" + + +def test_recording_settings_writegear_options_fast_encoding_nvenc_no_x264_options(): + settings = RecordingSettings(codec="h264_nvenc", crf=23, fast_encoding=True) + + opts = settings.writegear_options(100.0) + + assert opts["-vcodec"] == "h264_nvenc" + assert "-preset" not in opts + assert "-tune" not in opts + + +def test_recording_settings_writegear_options_invalid_fps_falls_back_to_30(): + settings = RecordingSettings(codec="libx264", crf=23) + + opts = settings.writegear_options(None) + + assert opts["-input_framerate"] == "30.000000" diff --git a/tests/utils/test_stats.py b/tests/utils/test_stats.py index 1fa12400f..bd207cf9e 100644 --- a/tests/utils/test_stats.py +++ b/tests/utils/test_stats.py @@ -4,6 +4,7 @@ from hypothesis import given, settings from hypothesis import strategies as st +from dlclivegui.gui.recording_manager import RecorderStats from dlclivegui.utils.stats import format_dlc_stats, format_recorder_stats pytestmark = pytest.mark.unit @@ -14,19 +15,20 @@ def test_format_recorder_stats_exact(): - stats = SimpleNamespace( + stats = RecorderStats( frames_written=10, frames_enqueued=12, write_fps=29.94, - last_latency=0.01234, # 12.34 ms -> 12.3 - average_latency=0.05678, # 56.78 ms -> 56.8 - buffer_seconds=0.4321, # 432.1 ms -> 432 + last_latency=0.01234, + average_latency=0.05678, + buffer_seconds=0.4321, queue_size=3, + buffer_size=0, dropped_frames=2, ) assert format_recorder_stats(stats) == ( - "10/12 frames | write 29.9 fps | latency 12.3 ms (avg 56.8 ms) | queue 3 (~432 ms) | dropped 2" + "10/12 frames | write 29.9 fps | latency 12.3 ms (avg 56.8 ms) | queue 3 (~432 ms) | backlog 2 | dropped 2" ) @@ -115,6 +117,7 @@ def _fmt0(x: float) -> str: average_latency=finite_seconds_small, buffer_seconds=finite_seconds, queue_size=queue_size_int, + buffer_size=queue_size_int, dropped_frames=nonneg_int, ) def test_format_recorder_stats_properties( @@ -125,9 +128,10 @@ def test_format_recorder_stats_properties( average_latency, buffer_seconds, queue_size, + buffer_size, dropped_frames, ): - stats = SimpleNamespace( + stats = RecorderStats( frames_written=frames_written, frames_enqueued=frames_enqueued, write_fps=write_fps, @@ -135,28 +139,17 @@ def test_format_recorder_stats_properties( average_latency=average_latency, buffer_seconds=buffer_seconds, queue_size=queue_size, + buffer_size=buffer_size, dropped_frames=dropped_frames, ) s = format_recorder_stats(stats) - # Required structural tokens - assert " frames | write " in s - assert " fps | latency " in s - assert " ms (avg " in s - assert " ms) | queue " in s - assert " (~" in s - assert " ms) | dropped " in s - - # Exact numeric formatting expectations (substrings) - latency_ms = last_latency * 1000.0 - avg_ms = average_latency * 1000.0 - buffer_ms = buffer_seconds * 1000.0 - assert f"{frames_written}/{frames_enqueued} frames" in s - assert f"write {_fmt1(write_fps)} fps" in s - assert f"latency {_fmt1(latency_ms)} ms (avg {_fmt1(avg_ms)} ms)" in s - assert f"queue {queue_size} (~{_fmt0(buffer_ms)} ms)" in s + assert "write " in s + assert "latency " in s + assert "queue " in s + assert "backlog " in s assert f"dropped {dropped_frames}" in s @@ -251,3 +244,26 @@ def test_format_dlc_stats_profile_properties(stats): assert f"(GPU:{_fmt1(gpu_ms)}ms+proc:{_fmt1(proc_ms)}ms)" in s else: assert "GPU:" not in s + + +def test_format_recorder_stats_exact_with_buffer_capacity(): + stats = RecorderStats( + frames_written=10, + frames_enqueued=12, + write_fps=29.94, + last_latency=0.01234, + average_latency=0.05678, + buffer_seconds=0.4321, + queue_size=3, + buffer_size=10, + dropped_frames=2, + ) + + assert format_recorder_stats(stats) == ( + "10/12 frames | " + "write 29.9 fps | " + "latency 12.3 ms (avg 56.8 ms) | " + "queue 3/10 (30%, ~432 ms) | " + "backlog 2 | " + "dropped 2" + ) From b5eed13d578bf72cb3590348498144bc17160b46 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 29 Jun 2026 16:23:21 +0200 Subject: [PATCH 07/20] Persist fast encoding setting in GUI Wire the fast encoding checkbox to QSettings so its value is restored on startup and saved when toggled. This adds typed get/set helpers for `recording/fast_encoding` in `DLCLiveGUISettingsStore`, updates `main_window` to prefer persisted values over config defaults, and includes a roundtrip unit test for the new setting. It also removes an obsolete commented-out recording block in the frame processing path. --- dlclivegui/gui/main_window.py | 18 ++++++------------ dlclivegui/utils/settings_store.py | 9 +++++++++ tests/utils/test_settings_store.py | 11 +++++++++++ 3 files changed, 26 insertions(+), 12 deletions(-) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 038be981c..281fb5364 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -813,6 +813,7 @@ def _connect_signals(self) -> None: self.filename_edit.textChanged.connect(lambda _t: self._update_recording_path_preview()) if hasattr(self, "container_combo"): self.container_combo.currentTextChanged.connect(lambda _t: self._update_recording_path_preview()) + self.fast_encoding_checkbox.stateChanged.connect(self._on_fast_encoding_changed) # ------------------------------------------------------------------ # Config @@ -841,7 +842,8 @@ def _apply_config(self, config: ApplicationSettings) -> None: self.crf_spin.setValue(int(recording.crf)) if hasattr(self, "fast_encoding_checkbox"): - self.fast_encoding_checkbox.setChecked(bool(getattr(recording, "fast_encoding", False))) + config_fast_encoding = bool(getattr(recording, "fast_encoding", False)) + self.fast_encoding_checkbox.setChecked(self._settings_store.get_fast_encoding(default=config_fast_encoding)) ## Restore persisted session name if empty if hasattr(self, "session_name_edit"): @@ -1217,6 +1219,9 @@ def _on_use_timestamp_changed(self, _state: int) -> None: self._settings_store.set_use_timestamp(self.use_timestamp_checkbox.isChecked()) self._update_recording_path_preview() + def _on_fast_encoding_changed(self, _state: int) -> None: + self._settings_store.set_fast_encoding(self.fast_encoding_checkbox.isChecked()) + def _on_colormap_changed(self, _index: int) -> None: self._colormap = color_ui.get_cmap_name_from_combo(self.cmap_combo, fallback=self._colormap) if self._current_frame is not None: @@ -1470,17 +1475,6 @@ def _on_multi_frame_processing_ready(self, frame_data: MultiFrameData) -> None: timestamp = frame_data.timestamps.get(dlc_cam_id, time.time()) self._dlc.enqueue_frame(frame, timestamp) - # PRIORITY 2: Recording (queued, non-blocking) - # if self._rec_manager.is_active and src_id in frame_data.frames: - # frame = frame_data.frames[src_id] - - # if self.record_with_overlays_checkbox.isChecked(): - # # Draw overlays for recording - # frame = self._render_overlays_for_recording(src_id, frame) - - # ts = frame_data.timestamps.get(src_id, time.time()) - # self._rec_manager.write_frame(src_id, frame, ts) - def _on_multi_frame_display_ready(self, frame_data: MultiFrameData) -> None: """Throttled UI/display path. diff --git a/dlclivegui/utils/settings_store.py b/dlclivegui/utils/settings_store.py index fcf36fdd3..a0c5677f4 100644 --- a/dlclivegui/utils/settings_store.py +++ b/dlclivegui/utils/settings_store.py @@ -51,6 +51,15 @@ def get_use_timestamp(self, default: bool = True) -> bool: def set_use_timestamp(self, value: bool) -> None: self._s.setValue("recording/use_timestamp", bool(value)) + def get_fast_encoding(self, default: bool = False) -> bool: + value = self._s.value("recording/fast_encoding", default) + if isinstance(value, bool): + return value + return str(value).strip().lower() in {"1", "true", "yes", "on"} + + def set_fast_encoding(self, enabled: bool) -> None: + self._s.setValue("recording/fast_encoding", bool(enabled)) + # --- optional: snapshot full config as JSON in QSettings --- def save_full_config_snapshot(self, cfg: ApplicationSettings) -> None: self._s.setValue("app/config_json", cfg.model_dump_json()) diff --git a/tests/utils/test_settings_store.py b/tests/utils/test_settings_store.py index 7eba56aef..318dc49cd 100644 --- a/tests/utils/test_settings_store.py +++ b/tests/utils/test_settings_store.py @@ -95,6 +95,17 @@ def model_validate_json(raw: str): assert settstore.load_full_config_snapshot() is None +def test_qt_settings_store_fast_encoding_roundtrip(): + s = InMemoryQSettings() + settstore = store.DLCLiveGUISettingsStore(qsettings=s) + + settstore.set_fast_encoding(True) + assert settstore.get_fast_encoding(default=False) is True + + settstore.set_fast_encoding(False) + assert settstore.get_fast_encoding(default=True) is False + + # ----------------------------- # ModelPathStore helpers # ----------------------------- From d671625ee96302a80529914f1c84fb93e30841ef Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 29 Jun 2026 16:24:09 +0200 Subject: [PATCH 08/20] Always wire recording setting signal handlers Remove defensive `hasattr` checks when connecting recording settings signals in `DLCLiveMainWindow`. The widgets are expected to exist, so connecting unconditionally avoids silently skipping persistence and recording path preview updates if an expected widget is missing or renamed. --- dlclivegui/gui/main_window.py | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 281fb5364..7c6eff32e 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -803,16 +803,11 @@ def _connect_signals(self) -> None: # Recording settings ## Session name persistence + preview updates - if hasattr(self, "session_name_edit"): - self.session_name_edit.editingFinished.connect(self._on_session_name_editing_finished) - if hasattr(self, "use_timestamp_checkbox"): - self.use_timestamp_checkbox.stateChanged.connect(self._on_use_timestamp_changed) - if hasattr(self, "output_directory_edit"): - self.output_directory_edit.textChanged.connect(lambda _t: self._update_recording_path_preview()) - if hasattr(self, "filename_edit"): - self.filename_edit.textChanged.connect(lambda _t: self._update_recording_path_preview()) - if hasattr(self, "container_combo"): - self.container_combo.currentTextChanged.connect(lambda _t: self._update_recording_path_preview()) + self.session_name_edit.editingFinished.connect(self._on_session_name_editing_finished) + self.use_timestamp_checkbox.stateChanged.connect(self._on_use_timestamp_changed) + self.output_directory_edit.textChanged.connect(lambda _t: self._update_recording_path_preview()) + self.filename_edit.textChanged.connect(lambda _t: self._update_recording_path_preview()) + self.container_combo.currentTextChanged.connect(lambda _t: self._update_recording_path_preview()) self.fast_encoding_checkbox.stateChanged.connect(self._on_fast_encoding_changed) # ------------------------------------------------------------------ From 7efe8f93cb83448b915313815ea6cfc6ad6a9a7a Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 29 Jun 2026 16:33:20 +0200 Subject: [PATCH 09/20] Disable default timing logs and fix stats import Turns off multi-camera, recorder, and Basler timing logs by default to reduce debug noise in normal runs. Also cleans up stale priority wording in main window comments, updates VideoRecorder docstrings to reflect `writer_options`, and fixes stats tests to import `RecorderStats` from `dlclivegui.utils.stats`. --- dlclivegui/config.py | 6 +++--- dlclivegui/gui/main_window.py | 5 ++--- dlclivegui/services/video_recorder.py | 5 ++--- tests/utils/test_stats.py | 3 +-- 4 files changed, 8 insertions(+), 11 deletions(-) diff --git a/dlclivegui/config.py b/dlclivegui/config.py index 1fbc6b6b0..e6b468ca6 100644 --- a/dlclivegui/config.py +++ b/dlclivegui/config.py @@ -29,11 +29,11 @@ ## Debug ### Timing logs SINGLE_CAMERA_WORKER_DO_LOG_TIMING: bool = False -MULTI_CAMERA_WORKER_DO_LOG_TIMING: bool = True -REC_DO_LOG_TIMING: bool = True +MULTI_CAMERA_WORKER_DO_LOG_TIMING: bool = False +REC_DO_LOG_TIMING: bool = False # MAIN_WINDOW_DO_LOG_TIMING: bool = False #### Backends -BASLER_DO_LOG_TIMING: bool = True +BASLER_DO_LOG_TIMING: bool = False class CameraSettings(BaseModel): diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 7c6eff32e..43110bf75 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -1422,8 +1422,7 @@ def _on_multi_frame_processing_ready(self, frame_data: MultiFrameData) -> None: """Handle frames from multiple cameras. Priority: - 1. DLC processing (highest priority - enqueue immediately, only for DLC camera) - 2. Recording (queued writes, non-blocking) + - DLC processing (highest priority - enqueue immediately, only for DLC camera) """ self._multi_camera_frames = frame_data.frames self._multi_camera_display_ids = frame_data.display_ids or {} @@ -1464,7 +1463,7 @@ def _on_multi_frame_processing_ready(self, frame_data: MultiFrameData) -> None: self._raw_frame = frame self._dlc_tile_offset, self._dlc_tile_scale = compute_tile_info(dlc_cam_id, frame, frame_data.frames) - # PRIORITY 1: DLC processing - only enqueue when DLC camera frame arrives! + # PRIORITY: DLC processing - only enqueue when DLC camera frame arrives! if self._dlc_active and is_dlc_camera_frame and dlc_cam_id in frame_data.frames: frame = frame_data.frames[dlc_cam_id] timestamp = frame_data.timestamps.get(dlc_cam_id, time.time()) diff --git a/dlclivegui/services/video_recorder.py b/dlclivegui/services/video_recorder.py index 30f3a3063..ef964521a 100644 --- a/dlclivegui/services/video_recorder.py +++ b/dlclivegui/services/video_recorder.py @@ -90,9 +90,8 @@ class VideoRecorder: convert_grayscale_to_rgb: Whether 2D grayscale frames should be expanded to 3-channel RGB before writing. Set to `False` to preserve mono frames when supported by the chosen writer/codec path. - fast_encoding: Whether to apply faster FFmpeg encoder settings when - supported by the selected codec. This can improve throughput at the - cost of larger files and/or reduced compression efficiency. + writer_options: Optional dictionary of additional keyword arguments passed + to `WriteGear`. If provided, this overrides the default options. Attributes: is_running: Whether the writer thread is currently alive. diff --git a/tests/utils/test_stats.py b/tests/utils/test_stats.py index bd207cf9e..bc1ae31f2 100644 --- a/tests/utils/test_stats.py +++ b/tests/utils/test_stats.py @@ -4,8 +4,7 @@ from hypothesis import given, settings from hypothesis import strategies as st -from dlclivegui.gui.recording_manager import RecorderStats -from dlclivegui.utils.stats import format_dlc_stats, format_recorder_stats +from dlclivegui.utils.stats import RecorderStats, format_dlc_stats, format_recorder_stats pytestmark = pytest.mark.unit From c1d22020dec51b03faa8738fbdea224147111990 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 29 Jun 2026 16:33:53 +0200 Subject: [PATCH 10/20] Fix writer defaults and buffer time fallback Ensure ffmpeg writer defaults (`-input_framerate`, `-vcodec`, `-crf`) are always applied, even when custom writer options are provided, while still allowing overrides via `writer_options`. Also improve recorder stats by estimating `buffer_seconds` from average or last frame latency when write FPS is unavailable, avoiding zero/underreported buffer duration. --- dlclivegui/services/video_recorder.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/dlclivegui/services/video_recorder.py b/dlclivegui/services/video_recorder.py index ef964521a..b0a37c9f4 100644 --- a/dlclivegui/services/video_recorder.py +++ b/dlclivegui/services/video_recorder.py @@ -405,7 +405,15 @@ def get_stats(self) -> RecorderStats | None: avg_latency = self._total_latency / self._frames_written if self._frames_written else 0.0 last_latency = self._last_latency write_fps = self._compute_write_fps_locked() - buffer_seconds = queue_size / write_fps if write_fps > 0 else 0.0 + + if write_fps > 0: + buffer_seconds = queue_size / write_fps + elif avg_latency > 0: + buffer_seconds = queue_size * avg_latency + elif last_latency > 0: + buffer_seconds = queue_size * last_latency + else: + buffer_seconds = 0.0 return RecorderStats( frames_enqueued=frames_enqueued, frames_written=frames_written, From 19fbcac0662b5d64e415e126eb87c7707d1c5795 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 30 Jun 2026 14:33:22 +0200 Subject: [PATCH 11/20] Defer recording until preview frames are ready Adds a pending-recording flow in the main window so "Start recording" while preview is stopped first starts preview, then begins recording only after all active cameras have produced frames. The pending state is cleared on stop/error/init failure to avoid stale triggers and duplicate starts. Adds GUI tests covering deferred start, waiting for all camera frames, frame-ready trigger behavior, and no double-starts. --- dlclivegui/gui/main_window.py | 33 ++++++ tests/gui/test_recording_gui.py | 173 ++++++++++++++++++++++++++++++++ 2 files changed, 206 insertions(+) create mode 100644 tests/gui/test_recording_gui.py diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 43110bf75..cc2659ded 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -138,6 +138,7 @@ def __init__(self, config: ApplicationSettings | None = None): self._raw_frame: np.ndarray | None = None self._last_pose: PoseResult | None = None self._dlc_active: bool = False + self._pending_recording_after_preview = False self._active_camera_settings: CameraSettings | None = None self._last_drop_warning = 0.0 self._last_recorder_summary = "Recorder idle" @@ -1426,6 +1427,7 @@ def _on_multi_frame_processing_ready(self, frame_data: MultiFrameData) -> None: """ self._multi_camera_frames = frame_data.frames self._multi_camera_display_ids = frame_data.display_ids or {} + self._try_start_pending_recording() src_id = frame_data.source_camera_id if src_id: self._fps_tracker.note_frame(src_id) # Track FPS @@ -1491,6 +1493,7 @@ def _on_multi_camera_stopped(self) -> None: """Handle all cameras stopped event.""" # Stop all multi-camera recorders self._stop_multi_camera_recording() + self._pending_recording_after_preview = False self.preview_button.setEnabled(True) self.stop_preview_button.setEnabled(False) @@ -1505,6 +1508,7 @@ def _on_multi_camera_stopped(self) -> None: def _on_multi_camera_error(self, camera_id: str, message: str) -> None: """Handle error from a camera in multi-camera mode.""" + self._pending_recording_after_preview = False self._show_warning(f"Camera {camera_id} error: {message}\nRecording stopped.") self._refresh_dlc_camera_list_running() if self.dlc_camera_combo.count() <= 1: @@ -1513,6 +1517,7 @@ def _on_multi_camera_error(self, camera_id: str, message: str) -> None: def _on_multi_camera_initialization_failed(self, failures: list) -> None: """Handle complete failure to initialize cameras.""" + self._pending_recording_after_preview = False # Build error message with details for each failed camera error_lines = ["Failed to initialize camera(s):"] for camera_id, error_msg in failures: @@ -1673,6 +1678,7 @@ def _stop_preview(self) -> None: self._stop_multi_camera_recording() self.multi_camera_controller.stop(wait=True) + self._pending_recording_after_preview = False self._stop_inference(show_message=False) self._fps_tracker.clear() self._last_display_time = 0.0 @@ -1956,6 +1962,7 @@ def _start_recording(self) -> None: """Start recording from all active cameras.""" # Auto-start preview if not running if not self.multi_camera_controller.is_running(): + self._pending_recording_after_preview = True self._start_preview() # Wait a moment for cameras to initialize before recording # The recording will start after preview is confirmed running @@ -1967,6 +1974,32 @@ def _start_recording(self) -> None: # Preview already running, start recording immediately self._start_multi_camera_recording() + def _try_start_pending_recording(self) -> None: + if not self._pending_recording_after_preview: + return + + if self._rec_manager.is_active: + self._pending_recording_after_preview = False + return + + if not self.multi_camera_controller.is_running(): + return + + active_cams = self._config.multi_camera.get_active_cameras() + expected_ids = {get_camera_id(cam) for cam in active_cams} + + if not expected_ids: + self._pending_recording_after_preview = False + return + + available_ids = set(self._multi_camera_frames.keys()) + + if not expected_ids.issubset(available_ids): + return + + self._pending_recording_after_preview = False + self._start_multi_camera_recording() + def _stop_recording(self) -> None: """Stop recording from all cameras.""" self._stop_multi_camera_recording() diff --git a/tests/gui/test_recording_gui.py b/tests/gui/test_recording_gui.py new file mode 100644 index 000000000..9ef4c4c32 --- /dev/null +++ b/tests/gui/test_recording_gui.py @@ -0,0 +1,173 @@ +import numpy as np +import pytest + +from dlclivegui.services.multi_camera_controller import MultiFrameData, get_camera_id + + +@pytest.mark.gui +class TestPendingRecordingAfterPreview: + def test_start_recording_when_preview_stopped_defers_until_preview_frames( + self, + window, + monkeypatch, + ): + calls = { + "start_preview": 0, + "start_recording": 0, + } + + monkeypatch.setattr( + window.multi_camera_controller, + "is_running", + lambda: False, + ) + + def fake_start_preview(): + calls["start_preview"] += 1 + + def fake_start_multi_camera_recording(): + calls["start_recording"] += 1 + + monkeypatch.setattr(window, "_start_preview", fake_start_preview) + monkeypatch.setattr(window, "_start_multi_camera_recording", fake_start_multi_camera_recording) + + window._pending_recording_after_preview = False + + window._start_recording() + + assert calls["start_preview"] == 1 + assert calls["start_recording"] == 0 + assert window._pending_recording_after_preview is True + + def test_pending_recording_waits_until_all_active_cameras_have_frames( + self, + window, + monkeypatch, + ): + active_cams = window._config.multi_camera.get_active_cameras() + assert len(active_cams) >= 2 + + cam0_id = get_camera_id(active_cams[0]) + cam1_id = get_camera_id(active_cams[1]) + + calls = { + "start_recording": 0, + } + + monkeypatch.setattr( + window.multi_camera_controller, + "is_running", + lambda: True, + ) + + def fake_start_multi_camera_recording(): + calls["start_recording"] += 1 + + monkeypatch.setattr(window, "_start_multi_camera_recording", fake_start_multi_camera_recording) + + window._pending_recording_after_preview = True + window._multi_camera_frames = { + cam0_id: np.zeros((10, 10, 3), dtype=np.uint8), + } + + window._try_start_pending_recording() + + assert calls["start_recording"] == 0 + assert window._pending_recording_after_preview is True + + window._multi_camera_frames[cam1_id] = np.zeros((10, 10, 3), dtype=np.uint8) + + window._try_start_pending_recording() + + assert calls["start_recording"] == 1 + assert window._pending_recording_after_preview is False + + def test_pending_recording_is_triggered_from_multi_frame_processing_ready( + self, + window, + monkeypatch, + ): + active_cams = window._config.multi_camera.get_active_cameras() + assert len(active_cams) >= 2 + + cam0_id = get_camera_id(active_cams[0]) + cam1_id = get_camera_id(active_cams[1]) + + calls = { + "start_recording": 0, + } + + monkeypatch.setattr( + window.multi_camera_controller, + "is_running", + lambda: True, + ) + + def fake_start_multi_camera_recording(): + calls["start_recording"] += 1 + + monkeypatch.setattr(window, "_start_multi_camera_recording", fake_start_multi_camera_recording) + + window._pending_recording_after_preview = True + + frame0 = np.zeros((10, 10, 3), dtype=np.uint8) + frame1 = np.zeros((10, 10, 3), dtype=np.uint8) + + frame_data = MultiFrameData( + frames={ + cam0_id: frame0, + cam1_id: frame1, + }, + timestamps={ + cam0_id: 1.0, + cam1_id: 1.0, + }, + source_camera_id=cam0_id, + display_ids={ + cam0_id: "Cam0", + cam1_id: "Cam1", + }, + ) + + window._on_multi_frame_processing_ready(frame_data) + + assert calls["start_recording"] == 1 + assert window._pending_recording_after_preview is False + + def test_pending_recording_does_not_start_twice( + self, + window, + monkeypatch, + ): + active_cams = window._config.multi_camera.get_active_cameras() + assert len(active_cams) >= 2 + + cam0_id = get_camera_id(active_cams[0]) + cam1_id = get_camera_id(active_cams[1]) + + calls = { + "start_recording": 0, + } + + monkeypatch.setattr( + window.multi_camera_controller, + "is_running", + lambda: True, + ) + + def fake_start_multi_camera_recording(): + calls["start_recording"] += 1 + + monkeypatch.setattr(window, "_start_multi_camera_recording", fake_start_multi_camera_recording) + + window._pending_recording_after_preview = True + window._multi_camera_frames = { + cam0_id: np.zeros((10, 10, 3), dtype=np.uint8), + cam1_id: np.zeros((10, 10, 3), dtype=np.uint8), + } + + window._try_start_pending_recording() + window._try_start_pending_recording() + + assert calls["start_recording"] == 1 + assert window._pending_recording_after_preview is False From b978818e1fe8f96dcd98d163a2003135b617f6b5 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 30 Jun 2026 16:15:53 +0200 Subject: [PATCH 12/20] Disable delayed auto-start of recording In the multi-camera recording flow, the `QTimer.singleShot` call that automatically triggered `_start_multi_camera_recording` after starting preview is commented out. This stops the delayed automatic recording start when preview is not yet running. --- dlclivegui/gui/main_window.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index cc2659ded..1ccf412b5 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -1968,7 +1968,7 @@ def _start_recording(self) -> None: # The recording will start after preview is confirmed running self.statusBar().showMessage("Starting preview before recording...", 3000) # Use a single-shot timer to start recording after preview starts - QTimer.singleShot(500, self._start_multi_camera_recording) + # QTimer.singleShot(500, self._start_multi_camera_recording) return # Preview already running, start recording immediately From b610d680ad8b768252d145ba6bd4d651f2dd3869 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 30 Jun 2026 16:15:59 +0200 Subject: [PATCH 13/20] Update main_window.py --- dlclivegui/gui/main_window.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 1ccf412b5..9c7f0abd1 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -801,6 +801,8 @@ def _connect_signals(self) -> None: self._dlc.initialized.connect(self._on_dlc_initialised) self.dlc_camera_combo.currentIndexChanged.connect(self._on_dlc_camera_changed) self.dlc_camera_combo.currentTextChanged.connect(self.dlc_camera_combo.update_shrink_width) + self.allow_processor_ctrl_checkbox.stateChanged.connect(lambda _s: self._update_dlc_controls_enabled()) + self.allow_processor_ctrl_checkbox.stateChanged.connect(lambda _s: self._update_processor_status()) # Recording settings ## Session name persistence + preview updates @@ -1488,6 +1490,7 @@ def _on_multi_camera_started(self) -> None: self.statusBar().showMessage(f"Multi-camera preview started: {active_count} camera(s)", 5000) self._update_inference_buttons() self._update_camera_controls_enabled() + self._update_dlc_controls_enabled() def _on_multi_camera_stopped(self) -> None: """Handle all cameras stopped event.""" @@ -1505,6 +1508,7 @@ def _on_multi_camera_stopped(self) -> None: self.statusBar().showMessage("Multi-camera preview stopped", 3000) self._update_inference_buttons() self._update_camera_controls_enabled() + self._update_dlc_controls_enabled() def _on_multi_camera_error(self, camera_id: str, message: str) -> None: """Handle error from a camera in multi-camera mode.""" From e609b6a0ef3805374c4aa4f5885026b018631610 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 11 Aug 2026 10:28:12 +0200 Subject: [PATCH 14/20] Centralize trigger debug logging flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves `DEBUG_TRIGGER_LOGS` into `config.py` so camera backends share a single toggle for trigger diagnostics. Basler now imports the shared flag instead of defining a local constant, and GenTL’s trigger-node dump is now guarded by both DEBUG log level and the config flag to avoid noisy logs unless explicitly enabled. --- dlclivegui/cameras/backends/basler_backend.py | 4 +--- dlclivegui/cameras/backends/gentl_backend.py | 5 ++++- dlclivegui/config.py | 2 ++ 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/dlclivegui/cameras/backends/basler_backend.py b/dlclivegui/cameras/backends/basler_backend.py index 56661522e..3edde055a 100644 --- a/dlclivegui/cameras/backends/basler_backend.py +++ b/dlclivegui/cameras/backends/basler_backend.py @@ -9,7 +9,7 @@ import numpy as np -from ...config import BASLER_DO_LOG_TIMING, CameraTriggerSettings +from ...config import BASLER_DO_LOG_TIMING, DEBUG_TRIGGER_LOGS, CameraTriggerSettings from ...utils.stats import WorkerTimingStats from ..base import CameraBackend, SupportLevel, register_backend @@ -31,8 +31,6 @@ genicam = None # type: ignore[assignment] pylon = None # type: ignore[assignment] -DEBUG_TRIGGER_LOGS = False - @register_backend("basler") class BaslerCameraBackend(CameraBackend): diff --git a/dlclivegui/cameras/backends/gentl_backend.py b/dlclivegui/cameras/backends/gentl_backend.py index cc836435b..78d4f75e7 100644 --- a/dlclivegui/cameras/backends/gentl_backend.py +++ b/dlclivegui/cameras/backends/gentl_backend.py @@ -12,7 +12,7 @@ import cv2 import numpy as np -from ...config import CameraTriggerSettings +from ...config import DEBUG_TRIGGER_LOGS, CameraTriggerSettings from ..base import CameraBackend, SupportLevel, register_backend from ..factory import DetectedCamera from .utils import gentl_discovery as cti_finder @@ -199,6 +199,9 @@ def static_capabilities(cls) -> dict[str, SupportLevel]: } def _debug_trigger_nodes(self, node_map, *, context: str = "") -> None: + if not LOG.isEnabledFor(logging.DEBUG) or not DEBUG_TRIGGER_LOGS: + return + names = ( "TriggerMode", "TriggerSelector", diff --git a/dlclivegui/config.py b/dlclivegui/config.py index e6b468ca6..c24a0e085 100644 --- a/dlclivegui/config.py +++ b/dlclivegui/config.py @@ -31,6 +31,8 @@ SINGLE_CAMERA_WORKER_DO_LOG_TIMING: bool = False MULTI_CAMERA_WORKER_DO_LOG_TIMING: bool = False REC_DO_LOG_TIMING: bool = False +### Trigger debug logging +DEBUG_TRIGGER_LOGS = False # MAIN_WINDOW_DO_LOG_TIMING: bool = False #### Backends BASLER_DO_LOG_TIMING: bool = False From 6e3a7cd9dcad03dd1b92e1ddcbfda82a05b75f0f Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 11 Aug 2026 12:05:13 +0200 Subject: [PATCH 15/20] Normalize WriteGear options and rename overrides Refactors recorder option handling to use `writer_options_overrides` instead of replacing all defaults, and updates the recording manager call site accordingly. Recorder creation now normalizes and validates key FFmpeg/WriteGear options (`-input_framerate`, `-crf`, `-vcodec`) to enforce numeric/string types before initializing `WriteGear`. Recording settings were also updated to emit native numeric values for framerate and CRF, aligning config output with the new normalization path. --- dlclivegui/services/video_recorder.py | 38 +++++++++++++++++++++++++-- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/dlclivegui/services/video_recorder.py b/dlclivegui/services/video_recorder.py index b0a37c9f4..c225d1762 100644 --- a/dlclivegui/services/video_recorder.py +++ b/dlclivegui/services/video_recorder.py @@ -90,8 +90,9 @@ class VideoRecorder: convert_grayscale_to_rgb: Whether 2D grayscale frames should be expanded to 3-channel RGB before writing. Set to `False` to preserve mono frames when supported by the chosen writer/codec path. - writer_options: Optional dictionary of additional keyword arguments passed - to `WriteGear`. If provided, this overrides the default options. + writer_options_overrides: Optional WriteGear/FFmpeg option overrides. + These values are merged over the recorder defaults. + Known numeric options are validated and normalized before WriteGear is created. Attributes: is_running: Whether the writer thread is currently alive. @@ -116,7 +117,11 @@ def __init__( crf: int = 23, buffer_size: int = 240, convert_grayscale_to_rgb: bool = True, +<<<<<<< HEAD writer_options_overrides: WriteGearOptionOverrides | None = None, +======= + writer_options_overrides: dict[str, Any] | None = None, +>>>>>>> 45da9a1 (Normalize WriteGear options and rename overrides) ): # Config self._output = Path(output) @@ -127,7 +132,13 @@ def __init__( self._crf = int(crf) self._buffer_size = max(1, int(buffer_size)) self._convert_grayscale_to_rgb = bool(convert_grayscale_to_rgb) +<<<<<<< HEAD self._writer_options_overrides = dict(writer_options_overrides) if writer_options_overrides else {} +======= + self._writer_options_overrides = ( + dict(writer_options_overrides) if writer_options_overrides is not None else None + ) +>>>>>>> 45da9a1 (Normalize WriteGear options and rename overrides) # Worker state self._queue: queue.Queue[Any] | None = None self._writer_thread: threading.Thread | None = None @@ -509,6 +520,29 @@ def _finalize_writer(self) -> None: except Exception: logger.exception("Failed to close WriteGear during finalisation") + @staticmethod + def _normalize_writer_options( + options: dict[str, Any], + ) -> dict[str, Any]: + normalized = dict(options) + + try: + normalized["-input_framerate"] = float(normalized["-input_framerate"]) + except (KeyError, TypeError, ValueError) as exc: + raise ValueError("WriteGear option '-input_framerate' must be numeric.") from exc + + try: + normalized["-crf"] = int(normalized["-crf"]) + except (KeyError, TypeError, ValueError) as exc: + raise ValueError("WriteGear option '-crf' must be an integer.") from exc + + codec = str(normalized.get("-vcodec") or "").strip() + if not codec: + raise ValueError("WriteGear option '-vcodec' must be a non-empty string.") + + normalized["-vcodec"] = codec + return normalized + def _compute_write_fps_locked(self) -> float: if len(self._written_times) < 2: return 0.0 From 59ed0763e7fa36d3318e5c772b06a61eee1b62bf Mon Sep 17 00:00:00 2001 From: C-Achard Date: Wed, 12 Aug 2026 11:22:42 +0200 Subject: [PATCH 16/20] Align tests with writer options override rename Update test fixtures and GUI recorder-manager assertions to use `writer_options_overrides` instead of `writer_options`. This keeps the fake recorder interface consistent with the current recorder API and ensures writegear option checks target the renamed field. --- tests/conftest.py | 4 ++-- tests/gui/test_rec_manager.py | 11 ++++++----- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 49cd1c66e..dcc9c7930 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -358,7 +358,7 @@ def __init__( crf=23, buffer_size=240, convert_grayscale_to_rgb=True, - writer_options=None, + writer_options_overrides=None, **kwargs, ): self.output = Path(output) @@ -368,7 +368,7 @@ def __init__( self.crf = crf self.buffer_size = buffer_size self.convert_grayscale_to_rgb = convert_grayscale_to_rgb - self.writer_options = dict(writer_options) if writer_options is not None else None + self.writer_options_overrides = dict(writer_options_overrides) if writer_options_overrides is not None else None self.extra_kwargs = dict(kwargs) self.started = False diff --git a/tests/gui/test_rec_manager.py b/tests/gui/test_rec_manager.py index f97c43a57..635eb9488 100644 --- a/tests/gui/test_rec_manager.py +++ b/tests/gui/test_rec_manager.py @@ -417,8 +417,9 @@ def test_start_all_passes_writegear_options( cam_id = get_camera_id(cam) rec = mgr.recorders[cam_id] - assert rec.writer_options is not None - assert rec.writer_options["-vcodec"] == "libx264" - assert rec.writer_options["-crf"] == "23" - assert rec.writer_options["-preset"] == "ultrafast" - assert rec.writer_options["-tune"] == "zerolatency" + opts_ovrr = rec.writer_options_overrides + assert opts_ovrr is not None + assert opts_ovrr["-vcodec"] == "libx264" + assert opts_ovrr["-crf"] == "23" + assert opts_ovrr["-preset"] == "ultrafast" + assert opts_ovrr["-tune"] == "zerolatency" From cbbe14fc528864e9840a6096af1c0b3e24e4a08d Mon Sep 17 00:00:00 2001 From: C-Achard Date: Wed, 12 Aug 2026 18:24:16 +0200 Subject: [PATCH 17/20] Fix merge conflict --- dlclivegui/services/video_recorder.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/dlclivegui/services/video_recorder.py b/dlclivegui/services/video_recorder.py index c225d1762..3086bfa93 100644 --- a/dlclivegui/services/video_recorder.py +++ b/dlclivegui/services/video_recorder.py @@ -117,11 +117,7 @@ def __init__( crf: int = 23, buffer_size: int = 240, convert_grayscale_to_rgb: bool = True, -<<<<<<< HEAD writer_options_overrides: WriteGearOptionOverrides | None = None, -======= - writer_options_overrides: dict[str, Any] | None = None, ->>>>>>> 45da9a1 (Normalize WriteGear options and rename overrides) ): # Config self._output = Path(output) @@ -132,13 +128,7 @@ def __init__( self._crf = int(crf) self._buffer_size = max(1, int(buffer_size)) self._convert_grayscale_to_rgb = bool(convert_grayscale_to_rgb) -<<<<<<< HEAD self._writer_options_overrides = dict(writer_options_overrides) if writer_options_overrides else {} -======= - self._writer_options_overrides = ( - dict(writer_options_overrides) if writer_options_overrides is not None else None - ) ->>>>>>> 45da9a1 (Normalize WriteGear options and rename overrides) # Worker state self._queue: queue.Queue[Any] | None = None self._writer_thread: threading.Thread | None = None From 9c32b26022b24d7e3a65854e4429acfa2634ed33 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 18 Aug 2026 15:31:55 +0200 Subject: [PATCH 18/20] Guard recording start when no cameras configured Prevent starting recording from triggering preview when no active cameras are configured. `start_recording` now checks for active cameras first, shows a clear error message directing users to "Configure Cameras...", and exits early instead of proceeding with pending recording setup. --- dlclivegui/gui/main_window.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 9c7f0abd1..2b1a861eb 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -1966,6 +1966,11 @@ def _start_recording(self) -> None: """Start recording from all active cameras.""" # Auto-start preview if not running if not self.multi_camera_controller.is_running(): + active_cams = self._config.multi_camera.get_active_cameras() + if not active_cams: + self._show_error("No cameras configured. Use 'Configure Cameras...' to add cameras.") + return + self._pending_recording_after_preview = True self._start_preview() # Wait a moment for cameras to initialize before recording From b9feabd5d32b96fed506d706bb4e49da6331e28c Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 18 Aug 2026 15:32:24 +0200 Subject: [PATCH 19/20] Update tests for writegear option builder Refactors recording option tests to target `build_writegear_options` directly instead of `RecordingSettings.writegear_options`, and aligns expectations with the newer option types/shape (numeric fps/crf values and explicit fast-encoding overrides). Also updates recording manager assertions to validate recorder codec/crf fields and narrowed writer overrides for x264 fast encoding. --- tests/gui/test_rec_manager.py | 19 +++++--- tests/test_config.py | 87 ++++++++++++++++++++++++++--------- 2 files changed, 76 insertions(+), 30 deletions(-) diff --git a/tests/gui/test_rec_manager.py b/tests/gui/test_rec_manager.py index 635eb9488..6e4e9f0ef 100644 --- a/tests/gui/test_rec_manager.py +++ b/tests/gui/test_rec_manager.py @@ -411,15 +411,20 @@ def test_start_all_passes_writegear_options( recording_settings.fast_encoding = True mgr = RecordingManager() - mgr.start_all(recording_settings, _active_cams_two, current_frames, session_name="Sess") + mgr.start_all( + recording_settings, + _active_cams_two, + current_frames, + session_name="Sess", + ) for cam in _active_cams_two: cam_id = get_camera_id(cam) rec = mgr.recorders[cam_id] - opts_ovrr = rec.writer_options_overrides - assert opts_ovrr is not None - assert opts_ovrr["-vcodec"] == "libx264" - assert opts_ovrr["-crf"] == "23" - assert opts_ovrr["-preset"] == "ultrafast" - assert opts_ovrr["-tune"] == "zerolatency" + assert rec.codec == "libx264" + assert rec.crf == 23 + assert rec.writer_options_overrides == { + "-preset": "ultrafast", + "-tune": "zerolatency", + } diff --git a/tests/test_config.py b/tests/test_config.py index 7c2f64bf0..1f89fbf5e 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,12 +1,14 @@ import pytest from dlclivegui.config import ( + DEFAULT_RECORDING_FPS, ApplicationSettings, CameraSettings, CameraTriggerSettings, MultiCameraSettings, RecordingSettings, ) +from dlclivegui.services.video_recorder import build_writegear_options @pytest.mark.unit @@ -76,43 +78,82 @@ def test_trigger_source_defaults_to_auto(): assert trigger.source == "auto" -def test_recording_settings_writegear_options_default(): - settings = RecordingSettings(codec="libx264", crf=23, fast_encoding=False) +def test_build_writegear_options_default(): + settings = RecordingSettings( + codec="libx264", + crf=23, + fast_encoding=False, + ) - opts = settings.writegear_options(100.0) + opts = build_writegear_options( + frame_rate=100.0, + codec=settings.codec, + crf=settings.crf, + overrides=settings.writegear_overrides(), + ) - assert opts["-input_framerate"] == "100.000000" - assert opts["-vcodec"] == "libx264" - assert opts["-crf"] == "23" - assert "-preset" not in opts - assert "-tune" not in opts + assert opts == { + "-input_framerate": 100.0, + "-vcodec": "libx264", + "-crf": 23, + } -def test_recording_settings_writegear_options_fast_encoding_x264(): - settings = RecordingSettings(codec="libx264", crf=23, fast_encoding=True) +def test_build_writegear_options_fast_encoding_x264(): + settings = RecordingSettings( + codec="libx264", + crf=23, + fast_encoding=True, + ) - opts = settings.writegear_options(100.0) + opts = build_writegear_options( + frame_rate=100.0, + codec=settings.codec, + crf=settings.crf, + overrides=settings.writegear_overrides(), + ) - assert opts["-input_framerate"] == "100.000000" - assert opts["-vcodec"] == "libx264" - assert opts["-crf"] == "23" - assert opts["-preset"] == "ultrafast" - assert opts["-tune"] == "zerolatency" + assert opts == { + "-input_framerate": 100.0, + "-vcodec": "libx264", + "-crf": 23, + "-preset": "ultrafast", + "-tune": "zerolatency", + } -def test_recording_settings_writegear_options_fast_encoding_nvenc_no_x264_options(): - settings = RecordingSettings(codec="h264_nvenc", crf=23, fast_encoding=True) +def test_build_writegear_options_fast_encoding_nvenc(): + settings = RecordingSettings( + codec="h264_nvenc", + crf=23, + fast_encoding=True, + ) - opts = settings.writegear_options(100.0) + opts = build_writegear_options( + frame_rate=100.0, + codec=settings.codec, + crf=settings.crf, + overrides=settings.writegear_overrides(), + ) + assert opts["-input_framerate"] == 100.0 assert opts["-vcodec"] == "h264_nvenc" + assert opts["-crf"] == 23 assert "-preset" not in opts assert "-tune" not in opts -def test_recording_settings_writegear_options_invalid_fps_falls_back_to_30(): - settings = RecordingSettings(codec="libx264", crf=23) +def test_build_writegear_options_invalid_fps_uses_default(): + settings = RecordingSettings( + codec="libx264", + crf=23, + ) - opts = settings.writegear_options(None) + opts = build_writegear_options( + frame_rate=None, + codec=settings.codec, + crf=settings.crf, + overrides=settings.writegear_overrides(), + ) - assert opts["-input_framerate"] == "30.000000" + assert opts["-input_framerate"] == DEFAULT_RECORDING_FPS From 9bcd61b41767272dbca4f66fd70983f614297a6d Mon Sep 17 00:00:00 2001 From: C-Achard Date: Wed, 19 Aug 2026 10:58:14 +0200 Subject: [PATCH 20/20] Add Basler acquisition stats debug logging Adds rate-limited debug logging of Basler acquisition and stream-grabber buffer statistics to help diagnose frame retrieval issues. The backend now logs these stats after successful frame reads and after frame read errors, with new helper methods to collect camera and stream-grabber counters. --- dlclivegui/cameras/backends/basler_backend.py | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/dlclivegui/cameras/backends/basler_backend.py b/dlclivegui/cameras/backends/basler_backend.py index 3edde055a..1a4933c74 100644 --- a/dlclivegui/cameras/backends/basler_backend.py +++ b/dlclivegui/cameras/backends/basler_backend.py @@ -51,6 +51,7 @@ def __init__(self, settings): ) self._camera_pixel_format: str | None = None self._logged_first_frame: bool = False + self._debug_last_acquis_stats_log: float = 0.0 # Optional fast-start hint for probe workers # (may skip StartGrabbing and converter setup for faster capability probing; not suitable for normal capture) @@ -723,6 +724,8 @@ def read(self) -> tuple[np.ndarray, float]: grab_result.Release() grab_result = None + self._debug_log_acquisition_stats(context="after frame read") + if self._actual_width is None or self._actual_height is None: h, w = frame.shape[:2] self._actual_width = int(w) @@ -747,6 +750,8 @@ def read(self) -> tuple[np.ndarray, float]: self._timing.note_error() self._timing.maybe_log() + self._debug_log_acquisition_stats(context=f"after frame read error: {type(exc).__name__}") + raise RuntimeError("Failed to retrieve image from Basler camera.") from exc def close(self) -> None: @@ -958,6 +963,53 @@ def _set_numeric_feature(self, name: str, value, *, strict: bool = False) -> boo LOG.warning("Failed to set Basler feature '%s' to '%s': %s", name, value, exc) return False + def _debug_log_acquisition_stats( + self, + *, + context: str, + force: bool = False, + ) -> None: + if not BASLER_DO_LOG_TIMING or not LOG.isEnabledFor(logging.DEBUG): + return + + now = time.monotonic() + if not force and now - self._debug_last_acquisition_stats_log < 1.0: + return + + self._debug_last_acquisition_stats_log = now + stats = self._debug_read_acquisition_stats() + if stats: + LOG.debug( + "[Basler] acquisition stats context=%s values=%s", + context, + stats, + ) + + def _debug_read_acquisition_stats(self) -> dict[str, int]: + cam = self._camera + if cam is None or not cam.IsGrabbing(): + return {} + + stats: dict[str, int] = {} + for name in ("NumReadyBuffers", "NumQueuedBuffers", "MaxNumBuffer"): + try: + stats[name] = int(getattr(cam, name).GetValue()) + except Exception: + pass + + try: + sg = cam.StreamGrabber + for name in ( + "Statistic_Buffer_Underrun_Count", + "Statistic_Missed_Frame_Count", + "Statistic_Failed_Buffer_Count", + ): + stats[name] = int(getattr(sg, name).GetValue()) + except Exception: + pass + + return stats + def _debug_trigger_nodes(self, *, context: str = "") -> None: if not LOG.isEnabledFor(logging.DEBUG) or not DEBUG_TRIGGER_LOGS: return