From bec70f2b238e3c3cd04f8154365af1ed54e995fd Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 26 Jun 2026 15:21:28 -0500 Subject: [PATCH 01/14] 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 cd605a94..6f84c404 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 defb1789b94818080b300d20ed2a72ac120c2864 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 26 Jun 2026 15:21:42 -0500 Subject: [PATCH 02/14] 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 7c5b18d8..1782ab32 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 0dce9b21251c2258e898cdfecc7964922dabd107 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 26 Jun 2026 15:21:55 -0500 Subject: [PATCH 03/14] 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 265d9530..da48c782 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 77335b50c82bd25a0cd1a8bb119abad3961d8e64 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 26 Jun 2026 15:24:16 -0500 Subject: [PATCH 04/14] 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 | 27 ++++++- .../services/multi_camera_controller.py | 24 ++++++- dlclivegui/services/video_recorder.py | 71 +++++++++++++++++-- 4 files changed, 167 insertions(+), 21 deletions(-) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 5b70883a..ddc13738 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -632,13 +632,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() @@ -771,6 +787,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) @@ -821,6 +838,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(): @@ -931,6 +952,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: @@ -1372,6 +1396,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. @@ -1425,15 +1467,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. @@ -1514,6 +1556,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) @@ -1524,6 +1567,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) @@ -1715,6 +1761,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 d12b19ed..f3509ac6 100644 --- a/dlclivegui/gui/recording_manager.py +++ b/dlclivegui/gui/recording_manager.py @@ -148,15 +148,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( @@ -166,6 +170,7 @@ def start_all( codec=recording.codec, crf=recording.crf, convert_grayscale_to_rgb=not bool(getattr(cam, "preserve_mono", False)), + writer_options=writer_options, ) try: recorder.start() @@ -213,9 +218,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": [], } @@ -223,9 +232,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) @@ -239,8 +252,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 e3eb1e49..8836bded 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() @@ -477,11 +488,13 @@ def _cleanup_camera(self, camera_id: str, *, finalize: bool = True) -> None: def _maybe_finalize_stop(self) -> None: """Finalize shutdown after every owned camera thread has finished.""" - # FUTURE FIXME: clear runtime info if not self._stopping: return - if any(thread is not None and thread.isRunning() for thread in self._threads.values()): + if any( + thread is not None and thread.isRunning() + for thread in self._threads.values() + ): return for camera_id, thread in list(self._threads.items()): @@ -517,6 +530,7 @@ def _maybe_finalize_stop(self) -> None: self._all_stopped_emitted = True self.all_stopped.emit() + def stop(self, wait: bool = True) -> None: """Request shutdown of all cameras. @@ -601,6 +615,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 76bfc1d1..6c0afda3 100644 --- a/dlclivegui/services/video_recorder.py +++ b/dlclivegui/services/video_recorder.py @@ -32,7 +32,52 @@ 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, @@ -43,6 +88,7 @@ def __init__( crf: int = 23, buffer_size: int = 240, convert_grayscale_to_rgb: bool = True, + writer_options: dict[str, Any] | None = None, ): # Config self._output = Path(output) @@ -53,6 +99,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) + self._writer_options = dict(writer_options) if writer_options is not None else None # Worker state self._queue: queue.Queue[Any] | None = None self._writer_thread: threading.Thread | None = None @@ -122,7 +169,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, @@ -130,15 +177,26 @@ def start(self) -> None: self._crf, self._buffer_size, self._convert_grayscale_to_rgb, + self._writer_options, ) + codec_value = (self._codec or "libx264").strip() or "libx264" writer_kwargs: dict[str, Any] = { "compression_mode": True, "logging": False, - "-input_framerate": fps_value, - "-vcodec": (self._codec or "libx264").strip() or "libx264", - "-crf": int(self._crf), } + + if self._writer_options is not None: + writer_kwargs.update(self._writer_options) + else: + writer_kwargs.update( + { + "-input_framerate": fps_value, + "-vcodec": codec_value, + "-crf": int(self._crf), + } + ) + # if not self._convert_grayscale_to_rgb: # writer_kwargs.update( # { @@ -332,12 +390,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 137aa2af791da494a96cb1a0fb77b69639720389 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 26 Jun 2026 15:25:31 -0500 Subject: [PATCH 05/14] 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 | 44 ++++++++++++++++++++++++++++++++------- dlclivegui/utils/stats.py | 23 +++++++++++++++++++- 2 files changed, 59 insertions(+), 8 deletions(-) diff --git a/dlclivegui/config.py b/dlclivegui/config.py index 6b3afacb..c0befcb7 100644 --- a/dlclivegui/config.py +++ b/dlclivegui/config.py @@ -25,11 +25,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): @@ -515,6 +515,7 @@ class RecordingSettings(BaseModel): container: Literal["mp4", "avi", "mov"] = "mp4" codec: str = "libx264" crf: int = Field(default=23, ge=0, le=51) + fast_encoding: bool = False def output_path(self) -> Path: """Return the absolute output path for recordings.""" @@ -528,18 +529,47 @@ def output_path(self) -> Path: filename = name.with_suffix(f".{self.container}") return directory / filename - def writegear_options(self, fps: float) -> dict[str, Any]: - """Return compression parameters for WriteGear.""" + def writegear_options(self, fps: float | None) -> dict[str, Any]: + """Return FFmpeg/WriteGear compression parameters. + + The default settings prioritize compatibility and compression quality. If + ``fast_encoding`` is enabled, additional low-latency encoder options are + added for codecs that are known to support them. + + Args: + fps: Desired input frame rate. If missing or non-positive, falls back + to 30 FPS. + + Returns: + Dictionary of WriteGear/FFmpeg options. + """ + try: + fps_value = float(fps or 0.0) + except Exception: + fps_value = 0.0 + if fps_value <= 0.0: + fps_value = 30.0 - fps_value = float(fps) if fps else 30.0 codec_value = (self.codec or "libx264").strip() or "libx264" crf_value = int(self.crf) if self.crf is not None else 23 - return { + + opts: dict[str, Any] = { "-input_framerate": f"{fps_value:.6f}", "-vcodec": codec_value, "-crf": str(crf_value), } + if self.fast_encoding: + if codec_value in {"libx264", "libx265"}: + opts.update( + { + "-preset": "ultrafast", + "-tune": "zerolatency", + } + ) + + return opts + class ApplicationSettings(BaseModel): # optional: add a semantic version for migrations diff --git a/dlclivegui/utils/stats.py b/dlclivegui/utils/stats.py index 3a00c02c..1edbf789 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 d1bec8452c1513278b8e6803d3f11ab9dd60922d Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 26 Jun 2026 15:26:34 -0500 Subject: [PATCH 06/14] 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 | 50 +++++++++++++++- tests/utils/test_stats.py | 60 ++++++++++++------- 8 files changed, 210 insertions(+), 40 deletions(-) diff --git a/dlclivegui/cameras/backends/basler_backend.py b/dlclivegui/cameras/backends/basler_backend.py index 6f84c404..1565b323 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 798b2b80..011aa72c 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 7d12a70a..49cd1c66 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 3af35308..511d4455 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 b3654a23..f97c43a5 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 c0b074c2..9abb34b8 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 9f82017e..63b387bf 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,6 +1,12 @@ import pytest -from dlclivegui.config import ApplicationSettings, CameraSettings, CameraTriggerSettings, MultiCameraSettings +from dlclivegui.config import ( + ApplicationSettings, + CameraSettings, + CameraTriggerSettings, + MultiCameraSettings, + RecordingSettings, +) @pytest.mark.unit @@ -41,3 +47,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 1fa12400..bd207cf9 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 a02ded317b6d13ab0d5b2110f982e309cbe38510 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 29 Jun 2026 16:23:21 +0200 Subject: [PATCH 07/14] 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 ddc13738..db06e954 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -812,6 +812,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 @@ -840,7 +841,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"): @@ -1213,6 +1215,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: @@ -1466,17 +1471,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 fcf36fdd..a0c5677f 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 7eba56ae..318dc49c 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 2336eebe913eae245bf4940b65c3545456b4249a Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 29 Jun 2026 16:24:09 +0200 Subject: [PATCH 08/14] 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 db06e954..02eccd55 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -802,16 +802,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 6e915522ef45bd1f859bdd5a68b1dcc777ca4bb6 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 29 Jun 2026 16:33:20 +0200 Subject: [PATCH 09/14] 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 c0befcb7..53629bbf 100644 --- a/dlclivegui/config.py +++ b/dlclivegui/config.py @@ -25,11 +25,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 02eccd55..bf6fa6a5 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -1418,8 +1418,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 {} @@ -1460,7 +1459,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 6c0afda3..1f8941cf 100644 --- a/dlclivegui/services/video_recorder.py +++ b/dlclivegui/services/video_recorder.py @@ -61,9 +61,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 bd207cf9..bc1ae31f 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 9e6862fbc12ec3bad42585ca669ed4abb7bd5d0d Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 29 Jun 2026 16:33:53 +0200 Subject: [PATCH 10/14] 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 | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/dlclivegui/services/video_recorder.py b/dlclivegui/services/video_recorder.py index 1f8941cf..ec1a14ba 100644 --- a/dlclivegui/services/video_recorder.py +++ b/dlclivegui/services/video_recorder.py @@ -185,16 +185,17 @@ def start(self) -> None: "logging": False, } + codec_value = (self._codec or "libx264").strip() or "libx264" + writer_kwargs: dict[str, Any] = { + "compression_mode": True, + "logging": False, + "-input_framerate": fps_value, + "-vcodec": codec_value, + "-crf": int(self._crf), + } + if self._writer_options is not None: writer_kwargs.update(self._writer_options) - else: - writer_kwargs.update( - { - "-input_framerate": fps_value, - "-vcodec": codec_value, - "-crf": int(self._crf), - } - ) # if not self._convert_grayscale_to_rgb: # writer_kwargs.update( @@ -389,7 +390,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 6733d631b49b416fd92788f0e135422cb1208e00 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 29 Jun 2026 16:39:49 +0200 Subject: [PATCH 11/14] Update video_recorder.py --- dlclivegui/services/video_recorder.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/dlclivegui/services/video_recorder.py b/dlclivegui/services/video_recorder.py index ec1a14ba..9cc75efb 100644 --- a/dlclivegui/services/video_recorder.py +++ b/dlclivegui/services/video_recorder.py @@ -179,12 +179,6 @@ def start(self) -> None: self._writer_options, ) - codec_value = (self._codec or "libx264").strip() or "libx264" - writer_kwargs: dict[str, Any] = { - "compression_mode": True, - "logging": False, - } - codec_value = (self._codec or "libx264").strip() or "libx264" writer_kwargs: dict[str, Any] = { "compression_mode": True, From faba338a2281e8046f9730d84cac8695f541aeef Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 30 Jun 2026 14:33:22 +0200 Subject: [PATCH 12/14] 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 bf6fa6a5..f7c86018 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -137,6 +137,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" @@ -1422,6 +1423,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 @@ -1487,6 +1489,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) @@ -1501,6 +1504,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: @@ -1509,6 +1513,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: @@ -1669,6 +1674,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 @@ -1952,6 +1958,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 @@ -1963,6 +1970,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 00000000..9ef4c4c3 --- /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 a5c6d0f025422b9665f99fee6104dab99e2cfa95 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 30 Jun 2026 16:15:53 +0200 Subject: [PATCH 13/14] 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 f7c86018..831d37ce 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -1964,7 +1964,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 f4f056e01240ba71619bdc94e5c1ea3a3ac3772b Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 30 Jun 2026 16:15:59 +0200 Subject: [PATCH 14/14] 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 831d37ce..315bf521 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -800,6 +800,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 @@ -1484,6 +1486,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.""" @@ -1501,6 +1504,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."""