diff --git a/dlclivegui/cameras/backends/gentl_backend.py b/dlclivegui/cameras/backends/gentl_backend.py index 0992db0..e24ab63 100644 --- a/dlclivegui/cameras/backends/gentl_backend.py +++ b/dlclivegui/cameras/backends/gentl_backend.py @@ -192,6 +192,45 @@ def static_capabilities(cls) -> dict[str, SupportLevel]: "hardware_trigger": SupportLevel.BEST_EFFORT, } + def _debug_frame_rate_nodes(self, node_map, *, context: str = "") -> None: + names = ( + "AcquisitionFrameRateEnable", + "AcquisitionFrameRateControlEnable", + "AcquisitionFrameRate", + "AcquisitionFrameRateAbs", + "AcquisitionResultingFrameRate", + "ResultingFrameRate", + "AcquisitionFrameRateResulting", + "DeviceFrameRate", + "ExposureAuto", + "ExposureTime", + "ExposureTimeAbs", + "DeviceLinkThroughputLimit", + "DeviceLinkThroughputLimitMode", + "PayloadSize", + "Width", + "Height", + "PixelFormat", + ) + + label = f"GenTL FPS debug {context}".strip() + + for name in names: + node = self._node(node_map, name) + if node is None: + continue + + value = self._node_value(node_map, name, None) + + extras = [] + for attr in ("min", "max", "inc"): + try: + extras.append(f"{attr}={getattr(node, attr)}") + except Exception: + pass + + LOG.info("%s: %s=%r %s", label, name, value, " ".join(extras)) + # ------------------------------------------------------------------ # Discovery # ------------------------------------------------------------------ @@ -471,6 +510,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, @@ -1013,6 +1061,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) -> 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: + return fvalue + + return None + + @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: @@ -1373,21 +1463,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: @@ -1397,20 +1514,84 @@ 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", + ) + if exposure is not None: + self._actual_exposure = exposure + + gain = self._node_float( + node_map, + "Gain", + "GainRaw", + ) + 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") + 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 diff --git a/dlclivegui/config.py b/dlclivegui/config.py index c7a9f43..fd61b98 100644 --- a/dlclivegui/config.py +++ b/dlclivegui/config.py @@ -15,8 +15,16 @@ TriggerRole = Literal["off", "external", "master", "follower"] TriggerActivation = Literal["RisingEdge", "FallingEdge", "AnyEdge", "LevelHigh", "LevelLow"] -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 class CameraSettings(BaseModel): diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index c16964b..63b0696 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -769,7 +769,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) @@ -1371,13 +1372,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 @@ -1434,7 +1434,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: diff --git a/dlclivegui/services/multi_camera_controller.py b/dlclivegui/services/multi_camera_controller.py index 014cccc..f47e3a0 100644 --- a/dlclivegui/services/multi_camera_controller.py +++ b/dlclivegui/services/multi_camera_controller.py @@ -4,6 +4,7 @@ import copy import logging +import time from dataclasses import dataclass from threading import Event, Lock @@ -17,7 +18,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__) @@ -226,7 +232,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 @@ -250,6 +257,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] = {} @@ -262,8 +272,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( @@ -275,6 +283,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; accepts dataclasses, pydantic models, or dicts.""" if self._running: @@ -378,6 +404,8 @@ def stop(self, wait: bool = True) -> None: thread.quit() thread.wait(5000) + self._timing_per_cam.clear() + self._gui_display_last_emit = 0.0 self._workers.clear() self._threads.clear() self._settings.clear() @@ -389,51 +417,61 @@ def stop(self, wait: bool = True) -> None: def _on_frame_captured(self, camera_id: str, frame: np.ndarray, timestamp: float) -> None: """Handle a frame from one camera.""" - # Apply rotation if configured timing = self._timing_for_camera(camera_id) + frame_data: MultiFrameData | None = None with timing.measure("Multi.slot.total"): settings = self._settings.get(camera_id) - with timing.measure("Multi.slot.apply_transforms"): + + with timing.measure("Multi.apply_transforms"): if settings and settings.rotation: frame = MultiCameraController.apply_rotation(frame, settings.rotation) - # Apply cropping if configured if settings: crop_region = settings.get_crop_region() if crop_region: frame = MultiCameraController.apply_crop(frame, crop_region) - with timing.measure("Multi.update_latest"): - with self._frame_lock: + + with self._frame_lock: + with timing.measure("Multi.store_latest"): self._frames[camera_id] = frame self._timestamps[camera_id] = timestamp - # Emit frame data without tiling (tiling done in GUI for performance) - if self._frames: - ordered_frames: dict[str, np.ndarray] = {} - ordered_timestamps: dict[str, float] = {} - - for cam_id in self._camera_display_order: - if cam_id in self._frames: - ordered_frames[cam_id] = self._frames[cam_id] - if cam_id in self._timestamps: - ordered_timestamps[cam_id] = self._timestamps[cam_id] - - # Any unexpected/legacy IDs, appended deterministically. - for cam_id in self._frames: - if cam_id not in ordered_frames: - ordered_frames[cam_id] = self._frames[cam_id] - for cam_id in self._timestamps: - if cam_id not in ordered_timestamps: - ordered_timestamps[cam_id] = self._timestamps[cam_id] - - frame_data = MultiFrameData( - frames=ordered_frames, - timestamps=ordered_timestamps, - source_camera_id=camera_id, - tiled_frame=None, - ) + with timing.measure("Multi.build_ordered"): + ordered_frames: dict[str, np.ndarray] = {} + ordered_timestamps: dict[str, float] = {} + + for cam_id in self._camera_display_order: + if cam_id in self._frames: + ordered_frames[cam_id] = self._frames[cam_id] + if cam_id in self._timestamps: + ordered_timestamps[cam_id] = self._timestamps[cam_id] + + # Any unexpected/legacy IDs, appended deterministically. + for cam_id in self._frames: + if cam_id not in ordered_frames: + ordered_frames[cam_id] = self._frames[cam_id] + for cam_id in self._timestamps: + if cam_id not in ordered_timestamps: + ordered_timestamps[cam_id] = self._timestamps[cam_id] + + with timing.measure("Multi.construct_frame_data"): + frame_data = MultiFrameData( + frames=ordered_frames, + timestamps=ordered_timestamps, + source_camera_id=camera_id, + tiled_frame=None, + ) + + if frame_data is not None: + 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() diff --git a/tests/gui/test_pose_overlay.py b/tests/gui/test_pose_overlay.py index 369baf8..3af3530 100644 --- a/tests/gui/test_pose_overlay.py +++ b/tests/gui/test_pose_overlay.py @@ -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( @@ -76,7 +76,7 @@ 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] @@ -84,7 +84,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_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)