Skip to content

Commit 758e6c8

Browse files
committed
Merge branch 'cy/limit-display-fps' into cy/basler-trigger-config
2 parents 89a98a2 + fb1b2ed commit 758e6c8

5 files changed

Lines changed: 219 additions & 27 deletions

File tree

dlclivegui/cameras/backends/gentl_backend.py

Lines changed: 157 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -558,6 +558,15 @@ def open(self) -> None:
558558

559559
self._acquirer.start()
560560

561+
try:
562+
self._read_telemetry(node_map)
563+
self._debug_frame_rate_nodes(node_map, context="after starting acquisition")
564+
except Exception:
565+
LOG.warning(
566+
"Failed to read telemetry after starting acquisition; some 'actual' values may be missing.",
567+
exc_info=True,
568+
)
569+
561570
LOG.debug(
562571
"Opened GenTL camera index=%s serial=%s label=%s",
563572
selected_index,
@@ -1126,6 +1135,48 @@ def _node_symbolics(node) -> list[str]:
11261135
except Exception:
11271136
return []
11281137

1138+
@staticmethod
1139+
def _node_value(node_map, name: str, default=None):
1140+
"""Best-effort read of a GenICam node value."""
1141+
try:
1142+
node = getattr(node_map, name)
1143+
except Exception:
1144+
return default
1145+
1146+
try:
1147+
return node.value
1148+
except Exception:
1149+
return default
1150+
1151+
@classmethod
1152+
def _node_float(cls, node_map, *names: str, allow_zero: bool = False) -> float | None:
1153+
"""Return the first positive float value from a list of GenICam node names."""
1154+
for name in names:
1155+
value = cls._node_value(node_map, name, None)
1156+
try:
1157+
fvalue = float(value)
1158+
except Exception:
1159+
continue
1160+
1161+
if fvalue > 0 or (allow_zero and fvalue == 0):
1162+
return fvalue
1163+
1164+
return None
1165+
1166+
@classmethod
1167+
def _node_str(cls, node_map, *names: str) -> str | None:
1168+
"""Return the first non-empty string value from a list of GenICam node names."""
1169+
for name in names:
1170+
value = cls._node_value(node_map, name, None)
1171+
if value is None:
1172+
continue
1173+
1174+
text = str(value).strip()
1175+
if text:
1176+
return text
1177+
1178+
return None
1179+
11291180
def _set_enum_node(self, node_map, name: str, value: str, *, strict: bool = False) -> bool:
11301181
node = self._node(node_map, name)
11311182
if node is None:
@@ -1605,21 +1656,48 @@ def _configure_frame_rate(self, node_map) -> None:
16051656
return
16061657

16071658
target = float(self.settings.fps)
1659+
LOG.info("Configuring GenTL frame rate: requested %.3f FPS", target)
1660+
16081661
for attr in ("AcquisitionFrameRateEnable", "AcquisitionFrameRateControlEnable"):
16091662
try:
1610-
getattr(node_map, attr).value = True
1663+
node = getattr(node_map, attr)
1664+
before = getattr(node, "value", None)
1665+
node.value = True
1666+
after = getattr(node, "value", None)
1667+
LOG.info("Enabled GenTL %s: before=%r after=%r", attr, before, after)
16111668
break
16121669
except Exception:
16131670
pass
16141671

1615-
for attr in ("AcquisitionFrameRate", "ResultingFrameRate", "AcquisitionFrameRateAbs"):
1672+
for attr in ("AcquisitionFrameRate", "AcquisitionFrameRateAbs"):
16161673
try:
1617-
getattr(node_map, attr).value = target
1674+
node = getattr(node_map, attr)
1675+
before = getattr(node, "value", None)
1676+
node.value = target
1677+
after = getattr(node, "value", None)
1678+
1679+
LOG.info(
1680+
"Set GenTL %s: before=%r requested=%.3f after=%r",
1681+
attr,
1682+
before,
1683+
target,
1684+
after,
1685+
)
1686+
1687+
try:
1688+
accepted = float(after)
1689+
if accepted > 0:
1690+
self._actual_fps = accepted
1691+
except Exception:
1692+
pass
1693+
16181694
return
1695+
16191696
except AttributeError:
16201697
continue
16211698
except Exception as e:
16221699
LOG.warning("Failed to set frame rate via %s: %s", attr, e)
1700+
16231701
LOG.warning("Could not set frame rate to %s FPS", target)
16241702

16251703
def _read_telemetry(self, node_map) -> None:
@@ -1629,20 +1707,86 @@ def _read_telemetry(self, node_map) -> None:
16291707
except Exception:
16301708
pass
16311709

1632-
try:
1633-
self._actual_fps = float(node_map.ResultingFrameRate.value)
1634-
except Exception:
1635-
self._actual_fps = None
1710+
# Prefer true/resulting frame-rate readback nodes.
1711+
resulting_fps = self._node_float(
1712+
node_map,
1713+
"AcquisitionResultingFrameRate",
1714+
"ResultingFrameRate",
1715+
"AcquisitionFrameRateResulting",
1716+
"DeviceFrameRate",
1717+
)
16361718

1637-
try:
1638-
self._actual_exposure = float(node_map.ExposureTime.value)
1639-
except Exception:
1640-
self._actual_exposure = None
1719+
# Fallback to requested/accepted frame-rate nodes only if no resulting node exists.
1720+
requested_fps = self._node_float(
1721+
node_map,
1722+
"AcquisitionFrameRate",
1723+
"AcquisitionFrameRateAbs",
1724+
)
1725+
1726+
if resulting_fps is not None:
1727+
self._actual_fps = resulting_fps
1728+
elif requested_fps is not None:
1729+
self._actual_fps = requested_fps
16411730

1731+
exposure = self._node_float(
1732+
node_map,
1733+
"ExposureTime",
1734+
"ExposureTimeAbs",
1735+
"Exposure",
1736+
allow_zero=True,
1737+
)
1738+
if exposure is not None:
1739+
self._actual_exposure = exposure
1740+
1741+
gain = self._node_float(
1742+
node_map,
1743+
"Gain",
1744+
"GainRaw",
1745+
allow_zero=True,
1746+
)
1747+
if gain is not None:
1748+
self._actual_gain = gain
1749+
1750+
# Persist useful telemetry into properties["gentl"] for GUI/debugging.
16421751
try:
1643-
self._actual_gain = float(node_map.Gain.value)
1752+
ns = self._ensure_settings_ns()
1753+
1754+
if self._actual_width and self._actual_height:
1755+
ns["actual_resolution"] = [int(self._actual_width), int(self._actual_height)]
1756+
1757+
if self._actual_fps is not None:
1758+
ns["actual_fps"] = float(self._actual_fps)
1759+
1760+
if resulting_fps is not None:
1761+
ns["actual_resulting_frame_rate"] = float(resulting_fps)
1762+
1763+
if requested_fps is not None:
1764+
ns["actual_acquisition_frame_rate"] = float(requested_fps)
1765+
1766+
if self._actual_exposure is not None:
1767+
ns["actual_exposure"] = float(self._actual_exposure)
1768+
1769+
if self._actual_gain is not None:
1770+
ns["actual_gain"] = float(self._actual_gain)
1771+
1772+
exposure_auto = self._node_str(node_map, "ExposureAuto")
1773+
if exposure_auto is not None:
1774+
ns["actual_exposure_auto"] = exposure_auto
1775+
1776+
throughput = self._node_float(node_map, "DeviceLinkThroughputLimit", allow_zero=True)
1777+
if throughput is not None:
1778+
ns["actual_device_link_throughput_limit"] = float(throughput)
1779+
1780+
throughput_mode = self._node_str(node_map, "DeviceLinkThroughputLimitMode")
1781+
if throughput_mode is not None:
1782+
ns["actual_device_link_throughput_limit_mode"] = throughput_mode
1783+
1784+
pixel_format = self._node_str(node_map, "PixelFormat")
1785+
if pixel_format is not None:
1786+
ns["actual_pixel_format"] = pixel_format
1787+
16441788
except Exception:
1645-
self._actual_gain = None
1789+
pass
16461790

16471791
# ------------------------------------------------------------------
16481792
# Frame conversion / local helpers

dlclivegui/config.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,16 @@
1717
TriggerStrobePolarity = Literal["ActiveHigh", "ActiveLow"]
1818
TriggerStrobeOperation = Literal["Exposure", "FixedDuration"]
1919

20-
SINGLE_CAMERA_WORKER_DO_LOG_TIMING = False
21-
MULTI_CAMERA_WORKER_DO_LOG_TIMING = True
20+
# Global settings
21+
## GUI
22+
GUI_MAX_DISPLAY_FPS: float = 30.0
23+
24+
25+
## Debug
26+
### Timing logs
27+
SINGLE_CAMERA_WORKER_DO_LOG_TIMING: bool = True
28+
MULTI_CAMERA_WORKER_DO_LOG_TIMING: bool = True
29+
# MAIN_WINDOW_DO_LOG_TIMING: bool = False
2230

2331

2432
class CameraSettings(BaseModel):

dlclivegui/gui/main_window.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -768,7 +768,8 @@ def _connect_signals(self) -> None:
768768
self.bbox_color_combo.currentIndexChanged.connect(self._on_bbox_color_changed)
769769

770770
# Multi-camera controller signals (used for both single and multi-camera modes)
771-
self.multi_camera_controller.frame_ready.connect(self._on_multi_frame_ready)
771+
self.multi_camera_controller.frame_ready.connect(self._on_multi_frame_processing_ready)
772+
self.multi_camera_controller.display_ready.connect(self._on_multi_frame_display_ready)
772773
self.multi_camera_controller.all_started.connect(self._on_multi_camera_started)
773774
self.multi_camera_controller.all_stopped.connect(self._on_multi_camera_stopped)
774775
self.multi_camera_controller.camera_error.connect(self._on_multi_camera_error)
@@ -1370,13 +1371,12 @@ def _render_overlays_for_recording(self, cam_id, frame):
13701371
)
13711372
return output
13721373

1373-
def _on_multi_frame_ready(self, frame_data: MultiFrameData) -> None:
1374+
def _on_multi_frame_processing_ready(self, frame_data: MultiFrameData) -> None:
13741375
"""Handle frames from multiple cameras.
13751376
1376-
Priority order for performance:
1377+
Priority:
13771378
1. DLC processing (highest priority - enqueue immediately, only for DLC camera)
13781379
2. Recording (queued writes, non-blocking)
1379-
3. Display (lowest priority - tiled and updated on separate timer)
13801380
"""
13811381
self._multi_camera_frames = frame_data.frames
13821382
src_id = frame_data.source_camera_id
@@ -1433,7 +1433,12 @@ def _on_multi_frame_ready(self, frame_data: MultiFrameData) -> None:
14331433
ts = frame_data.timestamps.get(src_id, time.time())
14341434
self._rec_manager.write_frame(src_id, frame, ts)
14351435

1436-
# PRIORITY 3: Mark display dirty (tiling done in display timer)
1436+
def _on_multi_frame_display_ready(self, frame_data: MultiFrameData) -> None:
1437+
"""Throttled UI/display path.
1438+
1439+
Called at GUI_MAX_DISPLAY_FPS, not at camera capture FPS for performance reasons.
1440+
"""
1441+
self._multi_camera_frames = frame_data.frames
14371442
self._display_dirty = True
14381443

14391444
def _on_multi_camera_started(self) -> None:

dlclivegui/services/multi_camera_controller.py

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,12 @@
1919
from dlclivegui.cameras.factory import camera_identity_key
2020

2121
# from dlclivegui.config import CameraSettings
22-
from dlclivegui.config import MULTI_CAMERA_WORKER_DO_LOG_TIMING, SINGLE_CAMERA_WORKER_DO_LOG_TIMING, CameraSettings
22+
from dlclivegui.config import (
23+
GUI_MAX_DISPLAY_FPS,
24+
MULTI_CAMERA_WORKER_DO_LOG_TIMING,
25+
SINGLE_CAMERA_WORKER_DO_LOG_TIMING,
26+
CameraSettings,
27+
)
2328
from dlclivegui.utils.stats import WorkerTimingStats
2429

2530
LOGGER = logging.getLogger(__name__)
@@ -266,7 +271,8 @@ class MultiCameraController(QObject):
266271
"""Controller for managing multiple cameras simultaneously."""
267272

268273
# Signals
269-
frame_ready = Signal(object) # MultiFrameData
274+
frame_ready = Signal(object) # MultiFrameData (full cam FPS; recording and inference only)
275+
display_ready = Signal(object) # MultiFrameData for GUI display (throttled to GUI_MAX_DISPLAY_FPS)
270276
camera_started = Signal(str, object) # camera_id, settings
271277
camera_stopped = Signal(str) # camera_id
272278
camera_error = Signal(str, str) # camera_id, error_message
@@ -291,6 +297,9 @@ def __init__(self):
291297
self._failed_cameras: dict[str, str] = {} # camera_id -> error message
292298
self._expected_cameras: int = 0 # Number of cameras we're trying to start
293299

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

@@ -303,8 +312,6 @@ def get_active_count(self) -> int:
303312
return len(self._started_cameras)
304313

305314
def _timing_for_camera(self, camera_id: str) -> WorkerTimingStats:
306-
if not MULTI_CAMERA_WORKER_DO_LOG_TIMING:
307-
return WorkerTimingStats(camera_id, enabled=False)
308315
timing = self._timing_per_cam.get(camera_id)
309316
if timing is None:
310317
timing = WorkerTimingStats(
@@ -316,6 +323,24 @@ def _timing_for_camera(self, camera_id: str) -> WorkerTimingStats:
316323
self._timing_per_cam[camera_id] = timing
317324
return timing
318325

326+
def _should_emit_display_ready(self) -> bool:
327+
"""Return True when the UI/display path should be updated.
328+
329+
This only throttles display_ready. It must not throttle frame_ready,
330+
because frame_ready is used for full-rate consumers such as recording.
331+
"""
332+
if self._gui_display_max_fps <= 0:
333+
return True
334+
335+
now = time.perf_counter()
336+
min_interval = 1.0 / max(self._gui_display_max_fps, 1e-9)
337+
338+
if now - self._gui_display_last_emit < min_interval:
339+
return False
340+
341+
self._gui_display_last_emit = now
342+
return True
343+
319344
def start(self, camera_settings: list[CameraSettings]) -> None:
320345
"""Start multiple cameras."""
321346
if self._running:
@@ -487,6 +512,11 @@ def stop(self, wait: bool = True) -> None:
487512
self.all_stopped.emit()
488513
return
489514

515+
self._timing_per_cam.clear()
516+
self._gui_display_last_emit = 0.0
517+
self._workers.clear()
518+
self._threads.clear()
519+
self._settings.clear()
490520
self._started_cameras.clear()
491521
self._failed_cameras.clear()
492522
self._camera_display_order.clear()
@@ -551,6 +581,11 @@ def _on_frame_captured(self, camera_id: str, frame: np.ndarray, timestamp: float
551581
with timing.measure("Multi.emit.frame_ready"):
552582
self.frame_ready.emit(frame_data)
553583

584+
# GUI-only path: throttled display updates
585+
if self._should_emit_display_ready():
586+
with timing.measure("Multi.emit.display_ready"):
587+
self.display_ready.emit(frame_data)
588+
554589
timing.note_frame()
555590
timing.maybe_log()
556591

tests/gui/test_pose_overlay.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ def test_record_overlay_toggle_affects_frames_sent_to_recorder(window, recording
6565
# Provide a frame
6666
raw = np.zeros((100, 100, 3), dtype=np.uint8)
6767

68-
# Build minimal frame_data to call _on_multi_frame_ready
68+
# Build minimal frame_data to call _on_multi_frame_processing_ready
6969
from dlclivegui.services.multi_camera_controller import MultiFrameData
7070

7171
frame_data = MultiFrameData(
@@ -76,15 +76,15 @@ def test_record_overlay_toggle_affects_frames_sent_to_recorder(window, recording
7676

7777
# 1) toggle OFF: should record raw
7878
window.record_with_overlays_checkbox.setChecked(False)
79-
window._on_multi_frame_ready(frame_data)
79+
window._on_multi_frame_processing_ready(frame_data)
8080

8181
assert cam_id in recording_frame_spy
8282
recorded_off = recording_frame_spy[cam_id]
8383
assert np.array_equal(recorded_off, raw)
8484

8585
# 2) toggle ON: should record overlay frame (different)
8686
window.record_with_overlays_checkbox.setChecked(True)
87-
window._on_multi_frame_ready(frame_data)
87+
window._on_multi_frame_processing_ready(frame_data)
8888

8989
recorded_on = recording_frame_spy[cam_id]
9090
assert not np.array_equal(recorded_on, raw)

0 commit comments

Comments
 (0)