Skip to content
Draft
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -113,3 +113,8 @@ venv.bak/
!dlclivegui/config.py
# uv package files
uv.lock

# profiling
profile*.svg
scalene*.json
scalene*.html
9 changes: 6 additions & 3 deletions dlclivegui/cameras/backends/basler_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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",
Expand All @@ -650,7 +653,7 @@ def open(self) -> None:
)

# ----------------------------
# Persist stable identity into namespace (migration-safe)
# Persist stable identity into namespace
# ----------------------------
try:
serial = device.GetSerialNumber()
Expand Down Expand Up @@ -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 = (
Expand Down
44 changes: 37 additions & 7 deletions dlclivegui/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
C-Achard marked this conversation as resolved.
Outdated


class CameraSettings(BaseModel):
Expand Down Expand Up @@ -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."""
Expand All @@ -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
Expand Down
66 changes: 57 additions & 9 deletions dlclivegui/gui/main_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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.

Comment thread
C-Achard marked this conversation as resolved.
Expand Down Expand Up @@ -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():
Comment thread
C-Achard marked this conversation as resolved.
Outdated
# # 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.
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
27 changes: 24 additions & 3 deletions dlclivegui/gui/recording_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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()
Expand Down Expand Up @@ -213,19 +218,27 @@ 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": [],
}
for rec in self._recorders.values():
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)

Expand All @@ -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']}"
)
18 changes: 17 additions & 1 deletion dlclivegui/services/multi_camera_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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
Expand Down
Loading