From af770434d4fecdd6a5667bd703a0bf0efd30253e 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 0f54e97..e2ea513 100644 --- a/dlclivegui/cameras/backends/basler_backend.py +++ b/dlclivegui/cameras/backends/basler_backend.py @@ -627,7 +627,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", @@ -650,7 +651,7 @@ def open(self) -> None: ) # ---------------------------- - # Persist stable identity into namespace (migration-safe) + # Persist stable identity into namespace # ---------------------------- try: serial = device.GetSerialNumber() From a5d6dccb0afa3fc13fcb84fad88683a8c8484909 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 7c5b18d..1782ab3 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 5420f585014fbb23eeb39878e64aab864161a7f4 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 265d953..da48c78 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 5ffb1b0ba4f488acace2b0ddfa46514babe67d51 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 | 18 ++++- dlclivegui/services/video_recorder.py | 71 +++++++++++++++++-- 4 files changed, 163 insertions(+), 19 deletions(-) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 9609b5a..dfa64f6 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 d12b19e..f3509ac 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 5ccf33c..fe5b669 100644 --- a/dlclivegui/services/multi_camera_controller.py +++ b/dlclivegui/services/multi_camera_controller.py @@ -297,7 +297,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 @@ -318,6 +319,7 @@ def __init__(self): self._timestamps: dict[str, float] = {} self._frame_lock = Lock() self._running = False + self._recording_frame_emission_enabled: bool = False self._started_cameras: set = set() self._camera_display_order: list[str] = [] self._display_ids: dict[str, str] = {} # camera_id -> display_id (for labeling) @@ -350,6 +352,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. @@ -416,6 +426,7 @@ def start(self, camera_settings: list[CameraSettings]) -> None: seen[key] = camera_id self._running = True + self._recording_frame_emission_enabled = False self._frames.clear() self._timestamps.clear() self._started_cameras.clear() @@ -481,6 +492,7 @@ def stop(self, wait: bool = True) -> None: return self._running = False + self._recording_frame_emission_enabled = False # Signal all workers to stop for worker in self._workers.values(): @@ -573,6 +585,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 76bfc1d..6c0afda 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 befe8819b5c9104233a0d8170ac1d59dd8feb1b3 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 6b3afac..c0befcb 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 3a00c02..1edbf78 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 faabf1a1aa9af4390d077b752d300a4fae6c724d 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 | 4 +- 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, 208 insertions(+), 41 deletions(-) diff --git a/dlclivegui/cameras/backends/basler_backend.py b/dlclivegui/cameras/backends/basler_backend.py index e2ea513..b1aff59 100644 --- a/dlclivegui/cameras/backends/basler_backend.py +++ b/dlclivegui/cameras/backends/basler_backend.py @@ -30,6 +30,8 @@ except Exception: # pragma: no cover - optional dependency pylon = None # type: ignore +DEBUG_TRIGGER_LOGS = False + @register_backend("basler") class BaslerCameraBackend(CameraBackend): @@ -948,7 +950,7 @@ 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): + if not LOG.isEnabledFor(logging.DEBUG) or not DEBUG_TRIGGER_LOGS: return names = ( diff --git a/tests/cameras/backends/conftest.py b/tests/cameras/backends/conftest.py index dfec64f..5bbcac3 100644 --- a/tests/cameras/backends/conftest.py +++ b/tests/cameras/backends/conftest.py @@ -389,6 +389,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 7d12a70..49cd1c6 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 3af3530..511d445 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 b3654a2..f97c43a 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 855b01a..747b5da 100644 --- a/tests/services/test_multicam_controller.py +++ b/tests/services/test_multicam_controller.py @@ -500,3 +500,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 9f82017..63b387b 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 1fa1240..bd207cf 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 ff51b1ab73bd22cb4cf100912f03ede29faddacd 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 dfa64f6..c041088 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 fcf36fd..a0c5677 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 7eba56a..318dc49 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 7bad67b4b39904ad769c0d74045664345edf8565 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 c041088..e4b56aa 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 124d7cbe924815106d4c42a327e4fd9a3ed581bc 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 c0befcb..53629bb 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 e4b56aa..46a5dcf 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 6c0afda..1f8941c 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 bd207cf..bc1ae31 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 df1e3ad4ba93eb0bd9504646f61daaab52de2e61 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 1f8941c..ec1a14b 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 c8e7d2f6668047da8277e07700be73fe8f8d997f 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 ec1a14b..9cc75ef 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 46e229c6fd62e8b1c572f35b17321c104c6eb499 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 46a5dcf..2d53870 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() + 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 0000000..9ef4c4c --- /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 aa534c65099cbeb342a9e4fc8e4f2bb5d9e57115 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 2d53870..1edecac 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 d5782e631f6c4ff5476716b43bc5778b03d7028c 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 1edecac..894fc4d 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."""