Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
12 changes: 10 additions & 2 deletions dlclivegui/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,16 @@
TriggerStrobePolarity = Literal["ActiveHigh", "ActiveLow"]
TriggerStrobeOperation = Literal["Exposure", "FixedDuration"]

SINGLE_CAMERA_WORKER_DO_LOG_TIMING = False
MULTI_CAMERA_WORKER_DO_LOG_TIMING = True
# Global settings
## GUI
GUI_MAX_DISPLAY_FPS: float = 30.0


## Debug
### Timing logs
SINGLE_CAMERA_WORKER_DO_LOG_TIMING: bool = True
MULTI_CAMERA_WORKER_DO_LOG_TIMING: bool = True
# MAIN_WINDOW_DO_LOG_TIMING: bool = False
Comment on lines +25 to +29

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving open as reminder



class CameraSettings(BaseModel):
Expand Down
15 changes: 10 additions & 5 deletions dlclivegui/gui/main_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -772,7 +772,8 @@ def _connect_signals(self) -> None:
self.bbox_color_combo.currentIndexChanged.connect(self._on_bbox_color_changed)

# Multi-camera controller signals (used for both single and multi-camera modes)
self.multi_camera_controller.frame_ready.connect(self._on_multi_frame_ready)
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.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 @@ -1374,13 +1375,12 @@ def _render_overlays_for_recording(self, cam_id, frame):
)
return output

def _on_multi_frame_ready(self, frame_data: MultiFrameData) -> None:
def _on_multi_frame_processing_ready(self, frame_data: MultiFrameData) -> None:
"""Handle frames from multiple cameras.

Priority order for performance:
Priority:
1. DLC processing (highest priority - enqueue immediately, only for DLC camera)
2. Recording (queued writes, non-blocking)
3. Display (lowest priority - tiled and updated on separate timer)
"""
self._multi_camera_frames = frame_data.frames
src_id = frame_data.source_camera_id
Expand Down Expand Up @@ -1437,7 +1437,12 @@ def _on_multi_frame_ready(self, frame_data: MultiFrameData) -> None:
ts = frame_data.timestamps.get(src_id, time.time())
self._rec_manager.write_frame(src_id, frame, ts)

# PRIORITY 3: Mark display dirty (tiling done in display timer)
def _on_multi_frame_display_ready(self, frame_data: MultiFrameData) -> None:
"""Throttled UI/display path.

Called at GUI_MAX_DISPLAY_FPS, not at camera capture FPS for performance reasons.
"""
self._multi_camera_frames = frame_data.frames
self._display_dirty = True

def _on_multi_camera_started(self) -> None:
Expand Down
38 changes: 35 additions & 3 deletions dlclivegui/services/multi_camera_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import copy
import logging
import time
from dataclasses import dataclass
from functools import partial
from threading import Event, Lock
Expand All @@ -19,6 +20,7 @@

# from dlclivegui.config import CameraSettings
from dlclivegui.config import (
GUI_MAX_DISPLAY_FPS,
MULTI_CAMERA_WORKER_DO_LOG_TIMING,
SINGLE_CAMERA_WORKER_DO_LOG_TIMING,
CameraSettings,
Expand Down Expand Up @@ -224,7 +226,8 @@ class MultiCameraController(QObject):
"""Controller for managing multiple cameras simultaneously."""

# Signals
frame_ready = Signal(object) # MultiFrameData
frame_ready = Signal(object) # MultiFrameData (full cam FPS; recording and inference only)
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
camera_error = Signal(str, str) # camera_id, error_message
Expand All @@ -249,6 +252,9 @@ def __init__(self):
self._failed_cameras: dict[str, str] = {} # camera_id -> error message
self._expected_cameras: int = 0 # Number of cameras we're trying to start

# GUI display max FPS (for throttling display updates when many cameras are active)
self._gui_display_max_fps: float = GUI_MAX_DISPLAY_FPS
self._gui_display_last_emit: float = 0.0
# Performance logs
self._timing_per_cam: dict[str, WorkerTimingStats] = {}

Expand All @@ -261,8 +267,6 @@ def get_active_count(self) -> int:
return len(self._started_cameras)

def _timing_for_camera(self, camera_id: str) -> WorkerTimingStats:
if not MULTI_CAMERA_WORKER_DO_LOG_TIMING:
return WorkerTimingStats(camera_id, enabled=False)
timing = self._timing_per_cam.get(camera_id)
if timing is None:
timing = WorkerTimingStats(
Expand All @@ -274,6 +278,24 @@ def _timing_for_camera(self, camera_id: str) -> WorkerTimingStats:
self._timing_per_cam[camera_id] = timing
return timing

def _should_emit_display_ready(self) -> bool:
"""Return True when the UI/display path should be updated.

This only throttles display_ready. It must not throttle frame_ready,
because frame_ready is used for full-rate consumers such as recording.
"""
if self._gui_display_max_fps <= 0:
return True

now = time.perf_counter()
min_interval = 1.0 / max(self._gui_display_max_fps, 1e-9)

if now - self._gui_display_last_emit < min_interval:
return False

self._gui_display_last_emit = now
return True

def start(self, camera_settings: list[CameraSettings]) -> None:
"""Start multiple cameras."""
if self._running:
Expand Down Expand Up @@ -452,6 +474,11 @@ def stop(self, wait: bool = True) -> None:
self.all_stopped.emit()
return

self._timing_per_cam.clear()
self._gui_display_last_emit = 0.0
self._workers.clear()
self._threads.clear()
self._settings.clear()
self._started_cameras.clear()
self._failed_cameras.clear()
self._display_ids.clear()
Expand Down Expand Up @@ -517,6 +544,11 @@ def _on_frame_captured(self, camera_id: str, frame: np.ndarray, timestamp: float
with timing.measure("Multi.emit.frame_ready"):
self.frame_ready.emit(frame_data)

# GUI-only path: throttled display updates
if self._should_emit_display_ready():
with timing.measure("Multi.emit.display_ready"):
self.display_ready.emit(frame_data)

timing.note_frame()
timing.maybe_log()

Expand Down
6 changes: 3 additions & 3 deletions tests/gui/test_pose_overlay.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ 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_ready
# Build minimal frame_data to call _on_multi_frame_processing_ready
from dlclivegui.services.multi_camera_controller import MultiFrameData

frame_data = MultiFrameData(
Expand All @@ -76,15 +76,15 @@ def test_record_overlay_toggle_affects_frames_sent_to_recorder(window, recording

# 1) toggle OFF: should record raw
window.record_with_overlays_checkbox.setChecked(False)
window._on_multi_frame_ready(frame_data)
window._on_multi_frame_processing_ready(frame_data)

assert cam_id in recording_frame_spy
recorded_off = recording_frame_spy[cam_id]
assert np.array_equal(recorded_off, raw)

# 2) toggle ON: should record overlay frame (different)
window.record_with_overlays_checkbox.setChecked(True)
window._on_multi_frame_ready(frame_data)
window._on_multi_frame_processing_ready(frame_data)

recorded_on = recording_frame_spy[cam_id]
assert not np.array_equal(recorded_on, raw)
Expand Down