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 diff --git a/dlclivegui/cameras/backends/basler_backend.py b/dlclivegui/cameras/backends/basler_backend.py index 0f54e97..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): @@ -627,7 +629,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 +653,7 @@ def open(self) -> None: ) # ---------------------------- - # Persist stable identity into namespace (migration-safe) + # Persist stable identity into namespace # ---------------------------- try: serial = device.GetSerialNumber() @@ -947,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/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/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, 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}" ) 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", ] 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" + )