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
207 changes: 194 additions & 13 deletions dlclivegui/cameras/backends/gentl_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ------------------------------------------------------------------
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down
12 changes: 10 additions & 2 deletions dlclivegui/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
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 @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
Loading