Skip to content
Draft
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
170 changes: 157 additions & 13 deletions dlclivegui/cameras/backends/gentl_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -558,6 +558,15 @@ def open(self) -> None:

self._acquirer.start()

try:
self._read_telemetry(node_map)
self._debug_frame_rate_nodes(node_map, context="after starting acquisition")
except Exception:
LOG.warning(
"Failed to read telemetry after starting acquisition; some 'actual' values may be missing.",
exc_info=True,
)

LOG.debug(
"Opened GenTL camera index=%s serial=%s label=%s",
selected_index,
Expand Down Expand Up @@ -1126,6 +1135,48 @@ def _node_symbolics(node) -> list[str]:
except Exception:
return []

@staticmethod
def _node_value(node_map, name: str, default=None):
"""Best-effort read of a GenICam node value."""
try:
node = getattr(node_map, name)
except Exception:
return default

try:
return node.value
except Exception:
return default

@classmethod
def _node_float(cls, node_map, *names: str, allow_zero: bool = False) -> float | None:
"""Return the first positive float value from a list of GenICam node names."""
for name in names:
value = cls._node_value(node_map, name, None)
try:
fvalue = float(value)
except Exception:
continue

if fvalue > 0 or (allow_zero and fvalue == 0):
return fvalue

return None
Comment thread
C-Achard marked this conversation as resolved.

@classmethod
def _node_str(cls, node_map, *names: str) -> str | None:
"""Return the first non-empty string value from a list of GenICam node names."""
for name in names:
value = cls._node_value(node_map, name, None)
if value is None:
continue

text = str(value).strip()
if text:
return text

return None

def _set_enum_node(self, node_map, name: str, value: str, *, strict: bool = False) -> bool:
node = self._node(node_map, name)
if node is None:
Expand Down Expand Up @@ -1605,21 +1656,48 @@ def _configure_frame_rate(self, node_map) -> None:
return

target = float(self.settings.fps)
LOG.info("Configuring GenTL frame rate: requested %.3f FPS", target)

for attr in ("AcquisitionFrameRateEnable", "AcquisitionFrameRateControlEnable"):
try:
getattr(node_map, attr).value = True
node = getattr(node_map, attr)
before = getattr(node, "value", None)
node.value = True
after = getattr(node, "value", None)
LOG.info("Enabled GenTL %s: before=%r after=%r", attr, before, after)
break
except Exception:
pass

for attr in ("AcquisitionFrameRate", "ResultingFrameRate", "AcquisitionFrameRateAbs"):
for attr in ("AcquisitionFrameRate", "AcquisitionFrameRateAbs"):
try:
getattr(node_map, attr).value = target
node = getattr(node_map, attr)
before = getattr(node, "value", None)
node.value = target
after = getattr(node, "value", None)

LOG.info(
"Set GenTL %s: before=%r requested=%.3f after=%r",
attr,
before,
target,
after,
)

try:
accepted = float(after)
if accepted > 0:
self._actual_fps = accepted
except Exception:
pass

return

except AttributeError:
continue
except Exception as e:
LOG.warning("Failed to set frame rate via %s: %s", attr, e)

LOG.warning("Could not set frame rate to %s FPS", target)

def _read_telemetry(self, node_map) -> None:
Expand All @@ -1629,20 +1707,86 @@ def _read_telemetry(self, node_map) -> None:
except Exception:
pass

try:
self._actual_fps = float(node_map.ResultingFrameRate.value)
except Exception:
self._actual_fps = None
# Prefer true/resulting frame-rate readback nodes.
resulting_fps = self._node_float(
node_map,
"AcquisitionResultingFrameRate",
"ResultingFrameRate",
"AcquisitionFrameRateResulting",
"DeviceFrameRate",
)

try:
self._actual_exposure = float(node_map.ExposureTime.value)
except Exception:
self._actual_exposure = None
# Fallback to requested/accepted frame-rate nodes only if no resulting node exists.
requested_fps = self._node_float(
node_map,
"AcquisitionFrameRate",
"AcquisitionFrameRateAbs",
)

if resulting_fps is not None:
self._actual_fps = resulting_fps
elif requested_fps is not None:
self._actual_fps = requested_fps

exposure = self._node_float(
node_map,
"ExposureTime",
"ExposureTimeAbs",
"Exposure",
allow_zero=True,
)
if exposure is not None:
self._actual_exposure = exposure

gain = self._node_float(
node_map,
"Gain",
"GainRaw",
allow_zero=True,
)
Comment thread
C-Achard marked this conversation as resolved.
if gain is not None:
self._actual_gain = gain

# Persist useful telemetry into properties["gentl"] for GUI/debugging.
try:
self._actual_gain = float(node_map.Gain.value)
ns = self._ensure_settings_ns()

if self._actual_width and self._actual_height:
ns["actual_resolution"] = [int(self._actual_width), int(self._actual_height)]

if self._actual_fps is not None:
ns["actual_fps"] = float(self._actual_fps)

if resulting_fps is not None:
ns["actual_resulting_frame_rate"] = float(resulting_fps)

if requested_fps is not None:
ns["actual_acquisition_frame_rate"] = float(requested_fps)

if self._actual_exposure is not None:
ns["actual_exposure"] = float(self._actual_exposure)

if self._actual_gain is not None:
ns["actual_gain"] = float(self._actual_gain)

exposure_auto = self._node_str(node_map, "ExposureAuto")
if exposure_auto is not None:
ns["actual_exposure_auto"] = exposure_auto

throughput = self._node_float(node_map, "DeviceLinkThroughputLimit", allow_zero=True)
if throughput is not None:
ns["actual_device_link_throughput_limit"] = float(throughput)

throughput_mode = self._node_str(node_map, "DeviceLinkThroughputLimitMode")
if throughput_mode is not None:
ns["actual_device_link_throughput_limit_mode"] = throughput_mode

pixel_format = self._node_str(node_map, "PixelFormat")
if pixel_format is not None:
ns["actual_pixel_format"] = pixel_format

except Exception:
self._actual_gain = None
pass

# ------------------------------------------------------------------
# Frame conversion / local helpers
Expand Down
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
44 changes: 40 additions & 4 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 @@ -18,7 +19,12 @@
from dlclivegui.cameras.factory import camera_identity_key

# from dlclivegui.config import CameraSettings
from dlclivegui.config import MULTI_CAMERA_WORKER_DO_LOG_TIMING, SINGLE_CAMERA_WORKER_DO_LOG_TIMING, CameraSettings
from dlclivegui.config import (
GUI_MAX_DISPLAY_FPS,
MULTI_CAMERA_WORKER_DO_LOG_TIMING,
SINGLE_CAMERA_WORKER_DO_LOG_TIMING,
CameraSettings,
)
from dlclivegui.utils.stats import WorkerTimingStats

LOGGER = logging.getLogger(__name__)
Expand Down Expand Up @@ -235,7 +241,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 @@ -260,6 +267,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 @@ -272,8 +282,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 @@ -285,6 +293,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 @@ -456,6 +482,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._camera_display_order.clear()
Expand Down Expand Up @@ -521,6 +552,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
Loading