diff --git a/.gitignore b/.gitignore index 7c5b18d80..1782ab32c 100644 --- a/.gitignore +++ b/.gitignore @@ -113,3 +113,8 @@ venv.bak/ !dlclivegui/config.py # uv package files uv.lock + +# profiling +profile*.svg +scalene*.json +scalene*.html diff --git a/dlclivegui/cameras/backends/aravis_backend.py b/dlclivegui/cameras/backends/aravis_backend.py index b437c3c3f..d1dd24ca1 100644 --- a/dlclivegui/cameras/backends/aravis_backend.py +++ b/dlclivegui/cameras/backends/aravis_backend.py @@ -11,7 +11,7 @@ import numpy as np from ...config import CameraSettings -from ..base import CameraBackend, SupportLevel, register_backend +from ..base import CameraBackend, CapturedFrame, SupportLevel, register_backend from ..factory import DetectedCamera LOG = logging.getLogger(__name__) @@ -52,6 +52,8 @@ def __init__(self, settings): self._actual_width: int | None = None self._actual_height: int | None = None self._actual_fps: float | None = None + self._camera_pixel_format: str | None = None + self._actual_output_format: str | None = None self._camera = None self._stream = None @@ -69,6 +71,16 @@ def actual_fps(self) -> float | None: """Return the actual frame rate of the camera after opening.""" return self._actual_fps + @property + def actual_pixel_format(self) -> str | None: + """Camera/native pixel format requested/reported for Aravis.""" + return self._camera_pixel_format or self._pixel_format + + @property + def actual_output_format(self) -> str | None: + """Current Aravis backend emits BGR uint8 frames.""" + return self._actual_output_format or "BGR8" + @classmethod def is_available(cls) -> bool: """Check if Aravis is available on this system.""" @@ -360,7 +372,7 @@ def open(self) -> None: self._camera.start_acquisition() - def read(self) -> tuple[np.ndarray, float]: + def read(self) -> CapturedFrame: """Read a frame from the camera.""" if self._camera is None or self._stream is None: raise RuntimeError("Aravis camera not initialized") @@ -418,7 +430,7 @@ def read(self) -> tuple[np.ndarray, float]: # Always push buffer back to stream self._stream.push_buffer(buffer) - return frame, timestamp + return CapturedFrame(frame=frame, software_timestamp=timestamp, timestamp_metadata=None) def stop(self) -> None: """Stop camera acquisition.""" @@ -615,10 +627,12 @@ def _configure_pixel_format(self) -> None: if self._pixel_format in format_map: self._camera.set_pixel_format(format_map[self._pixel_format]) + self._camera_pixel_format = self._pixel_format LOG.info(f"Pixel format set to '{self._pixel_format}'") else: # Try setting as string self._camera.set_pixel_format_from_string(self._pixel_format) + self._camera_pixel_format = self._pixel_format LOG.info(f"Pixel format set to '{self._pixel_format}' (from string)") except Exception as e: LOG.warning(f"Failed to set pixel format '{self._pixel_format}': {e}") diff --git a/dlclivegui/cameras/backends/basler_backend.py b/dlclivegui/cameras/backends/basler_backend.py index 8e7b0e19b..ea0e85179 100644 --- a/dlclivegui/cameras/backends/basler_backend.py +++ b/dlclivegui/cameras/backends/basler_backend.py @@ -7,16 +7,30 @@ import time from typing import ClassVar -import numpy as np - -from ..base import CameraBackend, SupportLevel, register_backend +from ...config import BASLER_DO_LOG_TIMING, CameraTriggerSettings +from ...utils.stats import WorkerTimingStats +from ...utils.timestamps import FrameTimestampMetadata +from ..base import CameraBackend, CapturedFrame, SupportLevel, register_backend LOG = logging.getLogger(__name__) + +# NOTE @C-Achard: This could be added in settings eventually +# Forces pypylon to create N emulation virtual cameras, +# mostly for testing. This should not be enabled for release. +ENABLE_PYLON_EMU = True +if ENABLE_PYLON_EMU: + import os + + os.environ["PYLON_CAMEMU"] = "4" + try: # pragma: no cover - optional dependency - from pypylon import pylon + from pypylon import genicam, pylon except Exception: # pragma: no cover - optional dependency - pylon = None # type: ignore + genicam = None # type: ignore[assignment] + pylon = None # type: ignore[assignment] + +DEBUG_TRIGGER_LOGS = False @register_backend("basler") @@ -25,14 +39,56 @@ class BaslerCameraBackend(CameraBackend): OPTIONS_KEY: ClassVar[str] = "basler" + # Keep RetrieveResult calls short enough that controller shutdown can stop + # worker threads promptly while waiting for external hardware triggers. + _MAX_HARDWARE_TRIGGER_RETRIEVE_TIMEOUT_MS: ClassVar[int] = 1000 + def __init__(self, settings): super().__init__(settings) self._props: dict = settings.properties if isinstance(settings.properties, dict) else {} + self._preserve_mono: bool = bool( + getattr(settings, "preserve_mono", False) or self.ns.get("preserve_mono", False) + ) + self._camera_pixel_format: str | None = None + self._logged_first_frame: bool = False # Optional fast-start hint for probe workers # (may skip StartGrabbing and converter setup for faster capability probing; not suitable for normal capture) self._fast_start: bool = bool(self.ns.get("fast_start", False)) + self._retrieve_timeout_ms: int = 100 # default; may be overridden by trigger settings + self._timestamp_tick_frequency_hz: float | None = None + self._timestamp_tick_frequency_source: str | None = None + + # ---- Trigger settings ---- + raw_trigger = self.ns.get("trigger", self._props.get("trigger")) + raw_trigger_strict = isinstance(raw_trigger, dict) and bool(raw_trigger.get("strict", False)) + + try: + self._trigger = CameraTriggerSettings.from_any(raw_trigger) + except Exception as exc: + if raw_trigger_strict: + raise ValueError(f"Strict mode failure - Invalid Basler trigger configuration: {exc}") from exc + + LOG.warning( + "Invalid Basler trigger config; falling back to trigger role=off: %s. " + "Enable strict mode to force this to raise.", + exc, + ) + self._trigger = CameraTriggerSettings() + + trigger_timeout = self._positive_float(self._trigger_attr(self._trigger, "timeout", None)) + if trigger_timeout is not None: + # pypylon RetrieveResult timeout is milliseconds. + self._retrieve_timeout_ms = max(1, int(float(trigger_timeout) * 1000.0)) + else: + self._retrieve_timeout_ms = 100 + + if self.waits_for_hardware_trigger: + self._retrieve_timeout_ms = min( + self._retrieve_timeout_ms, + self._MAX_HARDWARE_TRIGGER_RETRIEVE_TIMEOUT_MS, + ) # Stable identity (serial-based). Prefer new namespace; fall back to legacy keys read-only. self._device_id: str | None = None @@ -62,6 +118,16 @@ def __init__(self, settings): self._actual_exposure: float | None = None self._actual_gain: float | None = None + # ---- Timing stats for logging (optional) ---- + msg = self._device_id or f"index:{getattr(settings, 'index', '?')}" + timing_id = f"Basler {msg}" + self._timing = WorkerTimingStats( + timing_id, + logger=LOG, + log_interval=1.0, + enabled=BASLER_DO_LOG_TIMING, + ) + @property def actual_resolution(self) -> tuple[int, int] | None: if self._actual_width and self._actual_height: @@ -80,6 +146,24 @@ def actual_exposure(self) -> float | None: def actual_gain(self) -> float | None: return self._actual_gain + @property + def actual_pixel_format(self) -> str | None: + """Camera/native pixel format reported by Basler, e.g. 'Mono8'.""" + return self._camera_pixel_format + + @property + def actual_output_format(self) -> str | None: + """Backend output frame format emitted to the app, e.g. 'Mono8' or 'BGR8'.""" + if not self._camera_pixel_format: + return None + return "Mono8" if self._should_output_mono() else "BGR8" + + @property + def recommended_preserve_mono(self) -> bool | None: + if not self._camera_pixel_format: + return None + return self._is_camera_mono() + @classmethod def is_available(cls) -> bool: return pylon is not None @@ -95,6 +179,9 @@ def static_capabilities(cls) -> dict[str, SupportLevel]: "set_gain": SupportLevel.SUPPORTED, "device_discovery": SupportLevel.BEST_EFFORT, "stable_identity": SupportLevel.SUPPORTED, + "hardware_trigger": SupportLevel.BEST_EFFORT, + "preserve_mono": SupportLevel.SUPPORTED, + "hardware_frame_timestamps": SupportLevel.BEST_EFFORT, } ) return caps @@ -121,6 +208,17 @@ def _ensure_mutable_ns(self) -> dict: self.settings.properties[self.OPTIONS_KEY] = ns return ns + def _read_camera_pixel_format(self) -> str: + pixel_format = self._feature_value(self._feature("PixelFormat"), "") + self._camera_pixel_format = str(pixel_format or "") + return self._camera_pixel_format + + def _is_camera_mono(self) -> bool: + return bool(self._camera_pixel_format and self._camera_pixel_format.startswith("Mono")) + + def _should_output_mono(self) -> bool: + return bool(self._preserve_mono and self._is_camera_mono()) + @classmethod def _enumerate_devices_cls(cls): """Enumerate DeviceInfo entries (unit-testable via monkeypatch).""" @@ -305,6 +403,16 @@ def sanitize_for_probe(cls, settings): return dc + @staticmethod + def _is_pylon_timeout_exception(exc: BaseException) -> bool: + """Return whether an exception is PyPylon's SDK timeout exception.""" + timeout_type = getattr(genicam, "TimeoutException", None) + + if not isinstance(timeout_type, type): + return False + + return isinstance(exc, timeout_type) + @staticmethod def _positive_float(value) -> float | None: """Return float(value) if > 0 else None.""" @@ -314,6 +422,118 @@ def _positive_float(value) -> float | None: except Exception: return None + def trigger_once(self) -> None: + if self._camera is None: + raise RuntimeError("Basler camera not opened") + + # pypylon commonly exposes ExecuteSoftwareTrigger on InstantCamera. + method = getattr(self._camera, "ExecuteSoftwareTrigger", None) + if method is not None: + method() + return + + command = self._feature("TriggerSoftware") + if command is not None: + try: + command.Execute() + return + except Exception as exc: + raise RuntimeError(f"Failed to execute Basler software trigger: {exc}") from exc + + raise RuntimeError("Basler software trigger command is not available") + + def _configure_frame_rate(self) -> None: + if self._camera is None: + return + + fps = self._positive_float(getattr(self.settings, "fps", 0.0)) + if fps is None: + LOG.debug("[Basler] FPS: auto/free-run, not forcing AcquisitionFrameRate") + return + + enable = self._feature("AcquisitionFrameRateEnable") + rate = self._feature("AcquisitionFrameRate") + + try: + if enable is not None: + enable.SetValue(True) + + if rate is None: + LOG.warning("[Basler] AcquisitionFrameRate node not available; cannot set FPS=%s", fps) + return + + try: + min_v = rate.GetMin() + max_v = rate.GetMax() + LOG.debug("[Basler] AcquisitionFrameRate range: min=%s max=%s requested=%s", min_v, max_v, fps) + except Exception: + pass + + rate.SetValue(float(fps)) + + except Exception as exc: + LOG.warning("[Basler] Failed to set AcquisitionFrameRate=%s: %s", fps, exc, exc_info=True) + + # Readbacks + readbacks = {} + for name in ( + "AcquisitionFrameRateEnable", + "AcquisitionFrameRate", + "ResultingFrameRate", + "ResultingAcquisitionFrameRate", + "AcquisitionResultingFrameRate", + "BslResultingAcquisitionFrameRate", + "ExposureAuto", + "ExposureTime", + "ExposureTimeAbs", + "Width", + "Height", + "PixelFormat", + "TestImageSelector", + "ImageFileMode", + ): + feature = self._feature(name) + if feature is not None: + readbacks[name] = self._feature_value(feature, None) + + LOG.debug("[Basler] Readback requested=%s values=%s", fps, readbacks) + + try: + self._actual_fps = float(readbacks.get("AcquisitionFrameRate")) + except Exception: + self._actual_fps = None + + def _configure_converter(self) -> None: + """Configure pypylon image converter. + + Default behavior remains BGR8 for compatibility. + + If preserve_mono=True and the camera PixelFormat is Mono*, + return Mono8 frames as 2D arrays to avoid 3x BGR expansion. + """ + if self._camera is None: + return + + camera_pixel_format = self._camera_pixel_format or self._read_camera_pixel_format() + + self._converter = pylon.ImageFormatConverter() + self._converter.OutputBitAlignment = pylon.OutputBitAlignment_MsbAligned + + if self._should_output_mono(): + self._converter.OutputPixelFormat = pylon.PixelType_Mono8 + LOG.debug( + "[Basler] Converter configured for Mono8 output (camera PixelFormat=%s preserve_mono=%s)", + camera_pixel_format, + self._preserve_mono, + ) + else: + self._converter.OutputPixelFormat = pylon.PixelType_BGR8packed + LOG.debug( + "[Basler] Converter configured for BGR8 output (camera PixelFormat=%s preserve_mono=%s)", + camera_pixel_format, + self._preserve_mono, + ) + def open(self) -> None: if pylon is None: raise RuntimeError("pypylon is required for the Basler backend but is not installed") @@ -335,8 +555,11 @@ def open(self) -> None: try: if hasattr(self._camera, "ExposureAuto"): self._camera.ExposureAuto.SetValue("Off") - self._camera.ExposureTime.SetValue(float(self.settings.exposure)) - LOG.info("[Basler] Exposure set to %s us (auto off)", self.settings.exposure) + if hasattr(self._camera, "ExposureTime"): + self._camera.ExposureTime.SetValue(float(self.settings.exposure)) + if hasattr(self._camera, "ExposureTimeAbs"): + self._camera.ExposureTimeAbs.SetValue(float(self.settings.exposure)) + LOG.debug("[Basler] Exposure set to %s us (auto off)", self.settings.exposure) except Exception as exc: LOG.warning("[Basler] Failed to set exposure: %s", exc) @@ -346,7 +569,7 @@ def open(self) -> None: if hasattr(self._camera, "GainAuto"): self._camera.GainAuto.SetValue("Off") self._camera.Gain.SetValue(float(self.settings.gain)) - LOG.info("[Basler] Gain set to %s dB (auto off)", self.settings.gain) + LOG.debug("[Basler] Gain set to %s dB (auto off)", self.settings.gain) except Exception as exc: LOG.warning("[Basler] Failed to set gain: %s", exc) @@ -360,19 +583,20 @@ def open(self) -> None: # ---------------------------- # Frame rate (0.0 = Auto → do not set) # ---------------------------- - fps = self._positive_float(getattr(self.settings, "fps", 0.0)) + self._configure_frame_rate() - if fps is not None: - try: - # Some models require enable flag to be writable - if hasattr(self._camera, "AcquisitionFrameRateEnable"): - try: - self._camera.AcquisitionFrameRateEnable.SetValue(True) - except Exception: - pass - self._camera.AcquisitionFrameRate.SetValue(fps) - except Exception: - LOG.debug("Frame rate not writable or not supported", exc_info=True) + # ---------------------------- + # Trigger configuration + # ---------------------------- + self._debug_trigger_nodes(context="before configuration") + self._configure_trigger() + self._debug_trigger_nodes(context="after configuration") + + try: + ns = self._ensure_mutable_ns() + ns["trigger_actual"] = self._trigger_to_dict(self._trigger) + except Exception: + pass # ---------------------------- # Read back actual values (telemetry for GUI / probe) @@ -398,6 +622,8 @@ def open(self) -> None: except Exception: self._actual_gain = None + self._read_camera_pixel_format() + # ---------------------------- # Start acquisition (skip for fast probe) # ---------------------------- @@ -410,9 +636,7 @@ def open(self) -> None: pass # Converter BEFORE StartGrabbing - self._converter = pylon.ImageFormatConverter() - self._converter.OutputPixelFormat = pylon.PixelType_BGR8packed - self._converter.OutputBitAlignment = pylon.OutputBitAlignment_MsbAligned + self._configure_converter() # Force stream configuration reset try: @@ -422,9 +646,10 @@ def open(self) -> None: pass self._camera.StartGrabbing( - pylon.GrabStrategy_LatestImageOnly, + # pylon.GrabStrategy_LatestImageOnly, + pylon.GrabStrategy_OneByOne, ) - LOG.info( + LOG.debug( "[Basler] grabbing=%s max_buffers=%s", self._camera.IsGrabbing(), self._camera.MaxNumBuffer.GetValue() if hasattr(self._camera, "MaxNumBuffer") else "N/A", @@ -432,7 +657,7 @@ def open(self) -> None: else: LOG.debug("Fast-start probe: skipping StartGrabbing and converter") - LOG.info( + LOG.debug( "[Basler] open device_id=%s index=%s fast_start=%s requested=(%sx%s @ %s fps exp=%s gain=%s)", getattr(self, "_device_id", None), getattr(self.settings, "index", None), @@ -443,9 +668,29 @@ def open(self) -> None: getattr(self.settings, "exposure", None), getattr(self.settings, "gain", None), ) - # ---------------------------- - # Persist stable identity into namespace (migration-safe) - # ---------------------------- + + # Get hardware tick frequency for timestamp conversion + try: + node = getattr(self._camera, "GevTimestampTickFrequency", None) + if node is not None and node.IsReadable(): + self._timestamp_tick_frequency_hz = float(node.GetValue()) + self._timestamp_tick_frequency_source = "GevTimestampTickFrequency" + LOG.info( + "[Basler] timestamp tick frequency: %.3f Hz from GevTimestampTickFrequency", + self._timestamp_tick_frequency_hz, + ) + except Exception: + LOG.debug("[Basler] Could not read GevTimestampTickFrequency", exc_info=True) + + if not self._timestamp_tick_frequency_hz or self._timestamp_tick_frequency_hz <= 0: + self._timestamp_tick_frequency_hz = 1_000_000_000.0 + self._timestamp_tick_frequency_source = "assumed_default_1ghz" + LOG.info( + "[Basler] timestamp tick frequency unavailable; assuming %.3f Hz", + self._timestamp_tick_frequency_hz, + ) + + # Persist stable identity into namespace try: serial = device.GetSerialNumber() if serial: @@ -458,31 +703,118 @@ def open(self) -> None: except Exception: pass - def read(self) -> tuple[np.ndarray, float]: + def _make_timestamp_metadata(self, grab_result) -> FrameTimestampMetadata | None: + try: + ticks = int(grab_result.GetTimeStamp()) + except Exception: + return None + + if ticks == 0: + # Basler returns 0 if the timestamp is not available (e.g. for some GigE cameras) + return None + + freq = getattr(self, "_timestamp_tick_frequency_hz", None) + seconds = ticks / freq if freq and freq > 0 else None + + return FrameTimestampMetadata( + source="grab_result.GetTimeStamp", + backend="basler", + default_reported="seconds" if seconds is not None else "raw_value", + seconds=seconds, + wall_clock_time=None, + raw_value=ticks, + raw_unit="ticks", + tick_frequency_hz=freq, + timebase="Basler camera timestamp counter", + kind="camera_clock", + extra={ + "tick_frequency_source": self._timestamp_tick_frequency_source, + }, + ) + + def read(self) -> CapturedFrame: if self._camera is None: raise RuntimeError("Basler camera not opened") if self._converter is None: raise RuntimeError("Basler camera opened in fast-start probe mode; cannot read frames") + + grab_result = None + try: - grab_result = self._camera.RetrieveResult(100, pylon.TimeoutHandling_ThrowException) + with self._timing.measure("Basler.retrieve"): + grab_result = self._camera.RetrieveResult( + int(getattr(self, "_retrieve_timeout_ms", 100)), + pylon.TimeoutHandling_ThrowException, + ) + + with self._timing.measure("Basler.check_result"): + if not grab_result.GrabSucceeded(): + grab_result.Release() + grab_result = None + self._timing.note_error() + self._timing.maybe_log() + raise RuntimeError("Basler camera did not return an image") + + with self._timing.measure("Basler.convert"): + image = self._converter.Convert(grab_result) + + with self._timing.measure("Basler.get_array"): + frame = image.GetArray() + + with self._timing.measure("Basler.timestamp"): + software_timestamp = time.time() + timestamp_metadata = self._make_timestamp_metadata(grab_result) + + if not self._logged_first_frame: + self._logged_first_frame = True + LOG.debug( + "[Basler] first frame device_id=%s shape=%s dtype=%s nbytes=%.2f MB " + "camera_pixel_format=%s output_format=%s preserve_mono=%s", + self._device_id, + frame.shape, + frame.dtype, + frame.nbytes / (1024 * 1024), + self._camera_pixel_format, + self.actual_output_format, + self._preserve_mono, + ) + + with self._timing.measure("Basler.release"): + grab_result.Release() + grab_result = None + + if self._actual_width is None or self._actual_height is None: + h, w = frame.shape[:2] + self._actual_width = int(w) + self._actual_height = int(h) + + self._timing.note_frame() + self._timing.maybe_log() + + return CapturedFrame( + frame=frame, + software_timestamp=software_timestamp, + timestamp_metadata=timestamp_metadata, + ) + except Exception as exc: - raise RuntimeError("Failed to retrieve image from Basler camera.") from exc - if not grab_result.GrabSucceeded(): - grab_result.Release() - raise RuntimeError("Basler camera did not return an image") - image = self._converter.Convert(grab_result) - frame = image.GetArray() - grab_result.Release() + if grab_result is not None: + try: + grab_result.Release() + except Exception: + pass - if self._actual_width is None or self._actual_height is None: - h, w = frame.shape[:2] - self._actual_width = int(w) - self._actual_height = int(h) + if self.waits_for_hardware_trigger and self._is_pylon_timeout_exception(exc): + self._timing.note_timeout() + self._timing.maybe_log() + raise TimeoutError(f"Basler timeout while waiting for hardware trigger: {exc}") from exc - return frame, time.time() + self._timing.note_error() + self._timing.maybe_log() + raise RuntimeError("Failed to retrieve image from Basler camera.") from exc def close(self) -> None: - LOG.info( + LOG.debug( "[Basler] close called camera_exists=%s grabbing=%s open=%s", self._camera is not None, bool(self._camera and self._camera.IsGrabbing()), @@ -494,7 +826,13 @@ def close(self) -> None: self._camera.StopGrabbing() except Exception: pass + if self._camera.IsOpen(): + try: + self._restore_trigger_idle() + except Exception: + pass + self._camera.Close() self._camera = None self._converter = None @@ -571,6 +909,369 @@ def _snap_to_node(value: int, node) -> int: return int(v) + @property + def waits_for_hardware_trigger(self) -> bool: + role = str(self._trigger_attr(getattr(self, "_trigger", None), "role", "off") or "off").lower() + return role in {"external", "follower"} + + @staticmethod + def _trigger_attr(trigger, name: str, default=None): + if isinstance(trigger, dict): + return trigger.get(name, default) + return getattr(trigger, name, default) + + @staticmethod + def _trigger_to_dict(trigger) -> dict: + if trigger is None: + return {} + if isinstance(trigger, dict): + return dict(trigger) + if hasattr(trigger, "model_dump"): + try: + return trigger.model_dump(exclude_none=True) + except Exception: + pass + return {} + + def _feature(self, name: str): + if self._camera is None: + return None + try: + return getattr(self._camera, name) + except Exception: + return None + + @staticmethod + def _feature_value(feature, default=None): + if feature is None: + return default + try: + return feature.GetValue() + except Exception: + return default + + @staticmethod + def _feature_symbolics(feature) -> list[str]: + if feature is None: + return [] + + for method_name in ("GetSymbolics", "GetEntries"): + try: + method = getattr(feature, method_name, None) + if method is None: + continue + + values = method() + out = [] + + for value in values: + try: + if hasattr(value, "GetSymbolic"): + out.append(str(value.GetSymbolic())) + else: + out.append(str(value)) + except Exception: + continue + + return [v for v in out if v] + except Exception: + continue + + return [] + + def _set_enum_feature(self, name: str, value: str, *, strict: bool = False) -> bool: + feature = self._feature(name) + + if feature is None: + if strict: + raise RuntimeError(f"Basler feature '{name}' is not available") + LOG.debug("Basler feature '%s' is not available; skipping", name) + return False + + symbolics = self._feature_symbolics(feature) + if symbolics and value not in symbolics: + if strict: + raise RuntimeError(f"Basler feature '{name}' does not support '{value}'. Available: {symbolics}") + LOG.warning("Basler feature '%s' does not support '%s'. Available: %s", name, value, symbolics) + return False + + try: + feature.SetValue(value) + return True + except Exception as exc: + if strict: + raise RuntimeError(f"Failed to set Basler feature '{name}' to '{value}': {exc}") from exc + LOG.warning("Failed to set Basler feature '%s' to '%s': %s", name, value, exc) + return False + + def _set_numeric_feature(self, name: str, value, *, strict: bool = False) -> bool: + feature = self._feature(name) + + if feature is None: + if strict: + raise RuntimeError(f"Basler feature '{name}' is not available") + LOG.debug("Basler feature '%s' is not available; skipping", name) + return False + + try: + feature.SetValue(value) + return True + except Exception as exc: + if strict: + raise RuntimeError(f"Failed to set Basler feature '{name}' to '{value}': {exc}") from exc + LOG.warning("Failed to set Basler feature '%s' to '%s': %s", name, value, exc) + return False + + def _debug_trigger_nodes(self, *, context: str = "") -> None: + if not LOG.isEnabledFor(logging.DEBUG) or not DEBUG_TRIGGER_LOGS: + return + + names = ( + "TriggerSelector", + "TriggerMode", + "TriggerSource", + "TriggerActivation", + "TriggerDelay", + "TriggerDelayAbs", + "AcquisitionMode", + "LineSelector", + "LineMode", + "LineSource", + "LineInverter", + ) + + label = f"Basler trigger debug {context}".strip() + + for name in names: + feature = self._feature(name) + if feature is None: + continue + + value = self._feature_value(feature, None) + symbolics = self._feature_symbolics(feature) + + extras = [] + if symbolics: + extras.append(f"symbolics={symbolics}") + + for method_name in ("IsReadable", "IsWritable"): + try: + method = getattr(feature, method_name, None) + if method is not None: + extras.append(f"{method_name}={method()}") + except Exception: + pass + + LOG.debug("%s: %s=%r %s", label, name, value, " ".join(extras)) + + def _resolve_trigger_source(self, requested: str, *, strict: bool) -> tuple[str, bool]: + requested = str(requested or "auto").strip() + feature = self._feature("TriggerSource") + available = self._feature_symbolics(feature) + + if not available: + if strict: + raise RuntimeError("Basler feature 'TriggerSource' is not available or has no symbolics") + LOG.warning("Basler feature 'TriggerSource' is not available; disabling trigger input.") + return requested, False + + if requested in available: + return requested, True + + if requested.lower() == "auto": + for candidate in ("Line1", "Line2", "Line3", "Line4", "Line0", "Action1"): + if candidate in available: + LOG.info("Basler TriggerSource auto-selected '%s'. Available: %s", candidate, available) + return candidate, True + + LOG.warning("Could not auto-select a Basler TriggerSource. Available: %s", available) + return requested, False + + if strict: + raise RuntimeError(f"Basler feature 'TriggerSource' does not support '{requested}'. Available: {available}") + + LOG.warning("Basler TriggerSource '%s' is not available. Available: %s", requested, available) + return requested, False + + def _configure_trigger(self) -> None: + cfg = getattr(self, "_trigger", CameraTriggerSettings()) + self._trigger = cfg + role = str(self._trigger_attr(cfg, "role", "off") or "off").strip().lower() + strict = bool(self._trigger_attr(cfg, "strict", False)) + + if role in {"off", "disabled"}: + self._configure_trigger_off(strict=strict) + return + + if role in {"external", "follower"}: + self._configure_trigger_input(cfg, strict=strict) + return + + if role == "software": + # TODO: @C-Achard 2026-07-16 - software mode not implemented yet + self._configure_trigger_software(cfg, strict=strict) + return + + if role == "master": + self._configure_trigger_master(cfg, strict=strict) + return + + if strict: + raise RuntimeError(f"Unsupported Basler trigger role: {role!r}") + + LOG.warning("Unsupported Basler trigger role '%s'; disabling trigger.", role) + self._configure_trigger_off(strict=False) + + def _configure_trigger_off(self, *, strict: bool = False) -> None: + # Select FrameStart first when possible so TriggerMode=Off applies to + # the frame-start trigger path. + self._set_enum_feature("TriggerSelector", "FrameStart", strict=False) + self._set_enum_feature("TriggerMode", "Off", strict=strict) + + def _configure_trigger_input(self, cfg, *, strict: bool = False) -> None: + role = str(self._trigger_attr(cfg, "role", "external") or "external").strip().lower() + selector = str(self._trigger_attr(cfg, "selector", "FrameStart") or "FrameStart") + activation = str(self._trigger_attr(cfg, "activation", "RisingEdge") or "RisingEdge") + source = str(self._trigger_attr(cfg, "source", "auto") or "auto").strip() + delay = self._trigger_attr(cfg, "delay", None) + + # Disable trigger while changing trigger-related parameters. + self._set_enum_feature("TriggerMode", "Off", strict=False) + + selector_ok = self._set_enum_feature("TriggerSelector", selector, strict=strict) + + resolved_source, source_supported = self._resolve_trigger_source(source, strict=strict) + source_ok = False + if source_supported: + source_ok = self._set_enum_feature("TriggerSource", resolved_source, strict=strict) + + activation_ok = self._set_enum_feature("TriggerActivation", activation, strict=False) + + if delay is not None: + delay_value = float(delay) + if not self._set_numeric_feature("TriggerDelay", delay_value, strict=False): + self._set_numeric_feature("TriggerDelayAbs", delay_value, strict=False) + + self._set_enum_feature("AcquisitionMode", "Continuous", strict=False) + + if not selector_ok: + LOG.warning("Could not apply Basler TriggerSelector=%s; disabling trigger.", selector) + self._configure_trigger_off(strict=False) + self._trigger = CameraTriggerSettings() + return + + if not source_ok: + LOG.warning( + "Could not apply Basler TriggerSource=%s resolved=%s; disabling trigger.", + source, + resolved_source, + ) + self._configure_trigger_off(strict=False) + self._trigger = CameraTriggerSettings() + return + + if not self._set_enum_feature("TriggerMode", "On", strict=strict): + LOG.warning("Could not enable Basler TriggerMode=On; disabling trigger.") + self._configure_trigger_off(strict=False) + self._trigger = CameraTriggerSettings() + return + + LOG.debug( + "Basler trigger input configured: role=%s selector=%s source=%s activation=%s " + "selector_ok=%s source_ok=%s activation_ok=%s", + role, + selector, + resolved_source, + activation, + selector_ok, + source_ok, + activation_ok, + ) + + def _configure_trigger_software(self, cfg, *, strict: bool = False) -> None: + selector = str(self._trigger_attr(cfg, "selector", "FrameStart") or "FrameStart") + delay = self._trigger_attr(cfg, "delay", None) + + self._set_enum_feature("TriggerMode", "Off", strict=False) + + selector_ok = self._set_enum_feature("TriggerSelector", selector, strict=strict) + source_ok = self._set_enum_feature("TriggerSource", "Software", strict=strict) + + if delay is not None: + delay_value = float(delay) + if not self._set_numeric_feature("TriggerDelay", delay_value, strict=False): + self._set_numeric_feature("TriggerDelayAbs", delay_value, strict=False) + + self._set_enum_feature("AcquisitionMode", "Continuous", strict=False) + + if not selector_ok or not source_ok: + LOG.warning( + "Could not configure Basler software trigger selector_ok=%s source_ok=%s; disabling trigger.", + selector_ok, + source_ok, + ) + self._configure_trigger_off(strict=False) + self._trigger = CameraTriggerSettings() + return + + if not self._set_enum_feature("TriggerMode", "On", strict=strict): + LOG.warning("Could not enable Basler software TriggerMode=On; disabling trigger.") + self._configure_trigger_off(strict=False) + self._trigger = CameraTriggerSettings() + return + + LOG.info("Basler software trigger configured: selector=%s source=Software", selector) + + def _configure_trigger_master(self, cfg, *, strict: bool = False) -> None: + output_line = str(self._trigger_attr(cfg, "output_line", "Line2") or "Line2") + output_source = str(self._trigger_attr(cfg, "output_source", "ExposureActive") or "ExposureActive") + + # Master camera should acquire freely. + self._configure_trigger_off(strict=False) + + selected = self._set_enum_feature("LineSelector", output_line, strict=strict) + if not selected: + msg = f"Could not select Basler output line '{output_line}'" + if strict: + raise RuntimeError(msg) + LOG.warning("%s; skipping master output configuration.", msg) + return + + mode_ok = self._set_enum_feature("LineMode", "Output", strict=strict) + source_ok = self._set_enum_feature("LineSource", output_source, strict=strict) + + if mode_ok and source_ok: + LOG.debug( + "Basler trigger master configured via Line*: output_line=%s output_source=%s", + output_line, + output_source, + ) + return + + msg = ( + "Could not configure Basler trigger master output completely " + f"(LineMode ok={mode_ok}, LineSource ok={source_ok})." + ) + + if strict: + raise RuntimeError(msg) + + LOG.warning(msg) + + def _restore_trigger_idle(self) -> None: + role = str(self._trigger_attr(getattr(self, "_trigger", None), "role", "off") or "off").lower() + + try: + if role in {"external", "follower", "software"}: + self._set_enum_feature("TriggerMode", "Off", strict=False) + + elif role == "master": + self._set_enum_feature("LineSource", "Off", strict=False) + self._set_enum_feature("LineMode", "Input", strict=False) + + except Exception: + LOG.debug("Best-effort Basler trigger restore failed", exc_info=True) + def _configure_resolution(self) -> None: """ Apply width/height only if explicitly requested (GUI or override). diff --git a/dlclivegui/cameras/backends/gentl_backend.py b/dlclivegui/cameras/backends/gentl_backend.py index 5c71cd287..1c04f7b2e 100644 --- a/dlclivegui/cameras/backends/gentl_backend.py +++ b/dlclivegui/cameras/backends/gentl_backend.py @@ -7,12 +7,13 @@ import threading import time from pathlib import Path -from typing import ClassVar +from typing import Any, ClassVar import cv2 import numpy as np -from ..base import CameraBackend, SupportLevel, register_backend +from ...config import CameraTriggerSettings +from ..base import CameraBackend, CapturedFrame, SupportLevel, register_backend from ..factory import DetectedCamera from .utils import gentl_discovery as cti_finder @@ -75,6 +76,11 @@ class GenTLCameraBackend(CameraBackend): _CTI_FILES_SOURCE_AUTO: ClassVar[str] = "auto" _CTI_FILES_SOURCE_USER: ClassVar[str] = "user" + # Keep individual Harvester.fetch() calls short enough that controller + # shutdown can stop worker threads promptly. Hardware-trigger waits are + # handled by repeated polling in SingleCameraWorker. + _MAX_HARDWARE_TRIGGER_FETCH_TIMEOUT: ClassVar[float] = 1.0 + def __init__(self, settings): super().__init__(settings) @@ -84,6 +90,8 @@ def __init__(self, settings): ns = {} self._fast_start: bool = bool(ns.get("fast_start", False)) + self._preserve_mono: bool = bool(getattr(settings, "preserve_mono", False) or ns.get("preserve_mono", False)) + self._logged_first_frame: bool = False raw_device_id = ns.get("device_id") or props.get("device_id") legacy_serial = ns.get("serial_number") or ns.get("serial") or props.get("serial_number") or props.get("serial") @@ -93,6 +101,9 @@ def __init__(self, settings): self._pixel_format: str = ns.get("pixel_format") or props.get("pixel_format", "auto") self._pixel_format = str(self._pixel_format).strip() + self._camera_pixel_format: str | None = None + self._actual_output_format: str | None = None + self._rotate: int = int(ns.get("rotate", props.get("rotate", 0))) % 360 self._crop: tuple[int, int, int, int] | None = self._parse_crop(ns.get("crop", props.get("crop"))) @@ -105,6 +116,28 @@ def __init__(self, settings): self._gain = self._positive_float(ns.get("gain", props.get("gain"))) self._timeout: float = float(ns.get("timeout", props.get("timeout", 2.0))) + raw_trigger = ns.get("trigger", props.get("trigger")) + raw_trigger_strict = isinstance(raw_trigger, dict) and bool(raw_trigger.get("strict", False)) + + try: + self._trigger = CameraTriggerSettings.from_any(raw_trigger) + except Exception as exc: + if raw_trigger_strict: + raise ValueError(f"Strict mode failure - Invalid GenTL trigger configuration: {exc}") from exc + + LOG.warning( + "Invalid GenTL trigger config; falling back to trigger role=off: %s. " + "Enable strict mode to force this to raise.", + exc, + ) + self._trigger = CameraTriggerSettings() + + self._base_timeout = float(ns.get("timeout", props.get("timeout", 2.0))) + self._timeout = self._base_timeout + trigger_timeout = self._positive_float(self._trigger_attr(self._trigger, "timeout", None)) + self._trigger_requested_timeout: float | None = trigger_timeout + self._apply_effective_fetch_timeout() + self._requested_resolution: tuple[int, int] | None = self._get_requested_resolution_or_none() self._actual_width: int | None = None @@ -141,6 +174,26 @@ def actual_exposure(self) -> float | None: def actual_gain(self) -> float | None: return self._actual_gain + @property + def actual_pixel_format(self) -> str | None: + """Camera/native pixel format selected on the GenICam PixelFormat node.""" + return self._camera_pixel_format or (self._pixel_format if self._pixel_format != "auto" else None) + + @property + def recommended_preserve_mono(self) -> bool | None: + if not self._camera_pixel_format: + return None + return self._is_camera_mono() + + @property + def actual_output_format(self) -> str | None: + """Backend output frame format emitted to the app, e.g. 'Mono8' or 'BGR8'.""" + if self._actual_output_format: + return self._actual_output_format + if not self._camera_pixel_format: + return None + return "Mono8" if self._should_output_mono() else "BGR8" + @classmethod def is_available(cls) -> bool: return Harvester is not None @@ -154,8 +207,93 @@ def static_capabilities(cls) -> dict[str, SupportLevel]: "set_gain": SupportLevel.SUPPORTED, "device_discovery": SupportLevel.SUPPORTED, "stable_identity": SupportLevel.SUPPORTED, + "hardware_trigger": SupportLevel.BEST_EFFORT, + "preserve_mono": SupportLevel.SUPPORTED, } + def _debug_trigger_nodes(self, node_map, *, context: str = "") -> None: + names = ( + "TriggerMode", + "TriggerSelector", + "TriggerSource", + "TriggerActivation", + "AcquisitionMode", + # Generic line nodes, if available. + "LineSelector", + "LineMode", + "LineSource", + # TIS 37U / DMK 37BUX287 strobe/output nodes. + "GPIn", + "GPOut", + "StrobeEnable", + "StrobePolarity", + "StrobeOperation", + "StrobeDuration", + "StrobeDelay", + ) + + label = f"GenTL trigger 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 = [] + + symbolics = self._node_symbolics(node) + if symbolics: + extras.append(f"symbolics={symbolics}") + + for attr in ("access_mode", "is_writable", "is_readable"): + try: + extras.append(f"{attr}={getattr(node, attr)}") + except Exception: + pass + + LOG.debug("%s: %s=%r %s", label, name, value, " ".join(extras)) + + 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.debug("%s: %s=%r %s", label, name, value, " ".join(extras)) + # ------------------------------------------------------------------ # Discovery # ------------------------------------------------------------------ @@ -423,11 +561,26 @@ def open(self) -> None: self._device_label = self._resolve_device_label(node_map) self._configure_pixel_format(node_map) - self._configure_trigger(node_map) self._configure_resolution(node_map) self._configure_exposure(node_map) self._configure_gain(node_map) self._configure_frame_rate(node_map) + + ns = self._ensure_settings_ns() + requested_trigger = self._trigger_to_dict(self._trigger) + + self._configure_trigger(node_map) + self._apply_effective_fetch_timeout() + self._debug_trigger_nodes(node_map, context="after configuration before acquisition") + + actual_trigger = self._trigger_to_dict(self._trigger) + actual_trigger["fetch_timeout"] = self._timeout + if self._trigger_requested_timeout is not None: + actual_trigger["requested_timeout"] = float(self._trigger_requested_timeout) + + ns["trigger"] = requested_trigger + ns["trigger_actual"] = actual_trigger + self._read_telemetry(node_map) self._persist_device_metadata(selected_info, selected_serial) @@ -437,6 +590,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, @@ -452,7 +614,34 @@ def open(self) -> None: f"Failed to open GenTL camera.\n\nLoaded CTIs: {loaded}\nFailed CTIs: {failed}\nReason: {exc}" ) from exc - def read(self) -> tuple[np.ndarray, float]: + @property + def waits_for_hardware_trigger(self) -> bool: + role = str(self._trigger_attr(getattr(self, "_trigger", None), "role", "off") or "off").lower() + return role in {"external", "follower"} + + def _is_camera_mono(self) -> bool: + fmt = str(self._camera_pixel_format or self._pixel_format or "").strip() + return fmt.startswith("Mono") + + def _should_output_mono(self) -> bool: + return bool(self._preserve_mono and self._is_camera_mono()) + + @staticmethod + def _output_format_for_frame(frame: np.ndarray) -> str: + if frame.ndim == 2: + if frame.dtype == np.uint8: + return "Mono8" + return f"Mono{frame.dtype}" + if frame.ndim == 3: + channels = frame.shape[2] + if channels == 3 and frame.dtype == np.uint8: + return "BGR8" + if channels == 4 and frame.dtype == np.uint8: + return "BGRA8" + return f"{channels}ch-{frame.dtype}" + return str(frame.dtype) + + def read(self) -> CapturedFrame: if self._acquirer is None: raise RuntimeError("GenTL image acquirer not initialised") @@ -473,6 +662,8 @@ def read(self) -> tuple[np.ndarray, float]: except ValueError: frame = array.copy() except HarvesterTimeoutError as exc: + if self.waits_for_hardware_trigger: + raise TimeoutError(str(exc) + " (GenTL timeout; waiting for hardware trigger?)") from exc raise TimeoutError(str(exc) + " (GenTL timeout)") from exc frame = self._convert_frame(frame) @@ -488,8 +679,32 @@ def read(self) -> tuple[np.ndarray, float]: self._read_telemetry(self._acquirer.remote_device.node_map) except Exception: pass + self._actual_output_format = self._output_format_for_frame(frame) + try: + ns = self._ensure_settings_ns() + ns["actual_output_format"] = self._actual_output_format + ns["preserve_mono"] = self._preserve_mono + except Exception: + pass + if not self._logged_first_frame: + self._logged_first_frame = True + LOG.info( + "[GenTL] first frame device_id=%s shape=%s dtype=%s nbytes=%.2f MB " + "camera_pixel_format=%s output_format=%s preserve_mono=%s", + self._device_id, + frame.shape, + frame.dtype, + frame.nbytes / (1024 * 1024), + self._camera_pixel_format, + self.actual_output_format, + self._preserve_mono, + ) - return frame, timestamp + return CapturedFrame( + frame=frame, + software_timestamp=timestamp, + timestamp_metadata=None, + ) def stop(self) -> None: if self._acquirer is not None: @@ -505,6 +720,12 @@ def close(self) -> None: except Exception: pass + try: + node_map = self._acquirer.remote_device.node_map + self._call_with_optional_lock(self._restore_trigger_idle, node_map) + except Exception: + pass + try: destroy = getattr(self._acquirer, "destroy", None) if destroy is not None: @@ -952,6 +1173,197 @@ def _create_acquirer(self, serial: str | None, index: int): # ------------------------------------------------------------------ # Camera configuration helpers # ------------------------------------------------------------------ + @staticmethod + def _node(node_map, name: str): + try: + return getattr(node_map, name) + except Exception: + return None + + @staticmethod + def _node_value(node_map, name: str, default=None): + """Best-effort read of a GenICam node value. + + Debug helpers must not make open() fail just because a value cannot be read. + Harvesters-style fake/test nodes usually expose `.value`; some SDK-style + nodes may expose `GetValue()`. + """ + node = GenTLCameraBackend._node(node_map, name) + if node is None: + return default + + try: + return node.value + except Exception: + pass + + try: + getter = getattr(node, "GetValue", None) + if getter is not None: + return getter() + except Exception: + pass + + return default + + @staticmethod + def _node_symbolics(node) -> list[str]: + try: + return list(getattr(node, "symbolics", []) or []) + 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 + + @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: + if strict: + raise RuntimeError(f"GenICam node '{name}' is not available") + LOG.debug("GenICam node '%s' is not available; skipping", name) + return False + + symbolics = self._node_symbolics(node) + if symbolics and value not in symbolics: + if strict: + raise RuntimeError(f"GenICam node '{name}' does not support '{value}'. Available: {symbolics}") + LOG.warning("GenICam node '%s' does not support '%s'. Available: %s", name, value, symbolics) + return False + + try: + node.value = value + return True + except Exception as exc: + if strict: + raise RuntimeError(f"Failed to set GenICam node '{name}' to '{value}': {exc}") from exc + LOG.warning("Failed to set GenICam node '%s' to '%s': %s", name, value, exc) + return False + + @staticmethod + def _trigger_attr(trigger, name: str, default=None): + if isinstance(trigger, dict): + return trigger.get(name, default) + return getattr(trigger, name, default) + + @staticmethod + def _trigger_to_dict(trigger) -> dict[str, Any]: + if trigger is None: + return {} + if isinstance(trigger, dict): + return dict(trigger) + if hasattr(trigger, "to_properties"): + try: + return trigger.to_properties() + except Exception: + pass + if hasattr(trigger, "model_dump"): + try: + return trigger.model_dump(exclude_none=True) + except Exception: + pass + return {} + + def _resolve_trigger_source(self, node_map, requested: str, *, strict: bool) -> tuple[str, bool]: + """Resolve TriggerSource against the camera-supported GenICam enum values. + + Model-level default is "auto"; this backend maps it to the first preferred + source supported by the actual camera. + """ + requested = str(requested or "auto").strip() + node = self._node(node_map, "TriggerSource") + available = self._node_symbolics(node) + + if not available: + if strict: + raise RuntimeError("GenICam node 'TriggerSource' is not available or has no symbolics") + LOG.warning("GenICam node 'TriggerSource' is not available; disabling trigger input.") + return requested, False + + if requested in available: + return requested, True + + if requested.lower() == "auto": + for candidate in ("Line0", "Line1", "Line2", "Any"): + if candidate in available: + LOG.debug( + "GenTL TriggerSource auto-selected '%s'. Available: %s", + candidate, + available, + ) + return candidate, True + + LOG.warning( + "Could not auto-select a GenTL TriggerSource. Available: %s", + available, + ) + return requested, False + + if strict: + raise RuntimeError(f"GenICam node 'TriggerSource' does not support '{requested}'. Available: {available}") + + LOG.warning( + "GenTL TriggerSource '%s' is not available. Available: %s", + requested, + available, + ) + return requested, False + + def _apply_effective_fetch_timeout(self) -> None: + trigger_timeout = self._trigger_requested_timeout + if trigger_timeout is None: + self._timeout = self._base_timeout + return + + role = str(self._trigger_attr(self._trigger, "role", "off") or "off").strip().lower() + + if role in {"external", "follower"}: + self._timeout = min(float(trigger_timeout), self._MAX_HARDWARE_TRIGGER_FETCH_TIMEOUT) + elif role == "master": + self._timeout = float(trigger_timeout) + else: + # Only cap trigger-bound modes + # master/off should not block waiting for a hardware trigger + self._timeout = self._base_timeout def _configure_pixel_format(self, node_map) -> None: try: @@ -1000,19 +1412,286 @@ def _configure_pixel_format(self, node_map) -> None: pixel_format_node.value = selected self._pixel_format = str(pixel_format_node.value) + self._camera_pixel_format = self._pixel_format + try: + ns = self._ensure_settings_ns() + ns["actual_pixel_format"] = self._camera_pixel_format + ns["detected_pixel_format"] = self._camera_pixel_format + ns["actual_output_format"] = self.actual_output_format + ns["preserve_mono"] = self._preserve_mono + except Exception: + pass LOG.debug("GenTL pixel format selected: %s", self._pixel_format) except Exception as e: LOG.warning("Failed to configure pixel format '%s': %s", self._pixel_format, e) + if self._pixel_format and self._pixel_format.lower() != "auto": + self._camera_pixel_format = self._pixel_format def _configure_trigger(self, node_map) -> None: + cfg = self._trigger + role = str(self._trigger_attr(cfg, "role", "off") or "off").strip().lower() + strict = bool(self._trigger_attr(cfg, "strict", False)) + + if role in {"off", "disabled"}: + self._configure_trigger_off(node_map, strict=strict) + return + + if role in {"external", "follower"}: + self._configure_trigger_input(node_map, cfg, strict=strict) + return + + if role == "master": + self._configure_trigger_master(node_map, cfg, strict=strict) + return + + if strict: + raise RuntimeError(f"Unsupported GenTL trigger role: {role!r}") + + LOG.warning("Unsupported GenTL trigger role '%s'; disabling trigger.", role) + self._configure_trigger_off(node_map, strict=False) + + def _configure_trigger_off(self, node_map, *, strict: bool = False) -> None: + self._set_enum_node(node_map, "TriggerMode", "Off", strict=strict) + + def _configure_trigger_input(self, node_map, cfg, *, strict: bool = False) -> None: + role = str(self._trigger_attr(cfg, "role", "external") or "external").strip().lower() + selector = str(self._trigger_attr(cfg, "selector", "FrameStart") or "FrameStart") + activation = str(self._trigger_attr(cfg, "activation", "RisingEdge") or "RisingEdge") + source = str(self._trigger_attr(cfg, "source", "auto") or "auto").strip() + + # Disable trigger while changing trigger-related nodes. + self._set_enum_node(node_map, "TriggerMode", "Off", strict=False) + + selector_ok = self._set_enum_node(node_map, "TriggerSelector", selector, strict=strict) + + resolved_source, source_supported = self._resolve_trigger_source( + node_map, + source, + strict=strict, + ) + + source_ok = False + if source_supported: + source_ok = self._set_enum_node( + node_map, + "TriggerSource", + resolved_source, + strict=strict, + ) + + activation_ok = self._set_enum_node( + node_map, + "TriggerActivation", + activation, + strict=False, + ) + + # TriggerSelector and TriggerSource are required routing nodes. + # If either failed in non-strict mode, do not arm TriggerMode=On. + # Otherwise the camera may wait on a previous/default input line. + if not (selector_ok and source_ok): + LOG.warning( + "Could not apply GenTL trigger input routing " + "(selector_ok=%s, source_ok=%s); disabling trigger. " + "requested role=%s selector=%s source=%s resolved_source=%s activation=%s", + selector_ok, + source_ok, + role, + selector, + source, + resolved_source, + activation, + ) + self._configure_trigger_off(node_map, strict=False) + self._trigger = CameraTriggerSettings() + return + + if not activation_ok: + LOG.warning( + "Could not apply GenTL TriggerActivation=%s; using camera default/current activation.", + activation, + ) + + self._set_enum_node(node_map, "AcquisitionMode", "Continuous", strict=False) + + if not self._set_enum_node(node_map, "TriggerMode", "On", strict=strict): + LOG.warning("Could not enable GenTL TriggerMode=On; disabling trigger.") + self._configure_trigger_off(node_map, strict=False) + self._trigger = CameraTriggerSettings() + return + + LOG.debug( + "GenTL trigger input configured: role=%s selector=%s source_requested=%s " + "source=%s activation=%s selector_ok=%s source_ok=%s activation_ok=%s", + role, + selector, + source, + resolved_source, + activation, + selector_ok, + source_ok, + activation_ok, + ) + + def _configure_trigger_master(self, node_map, cfg, *, strict: bool = False) -> None: + """Configure this camera as a free-running master that emits STROBE_OUT pulses. + + For DMK 37BUX287 / TIS 37U series, the physical output is controlled by + StrobeEnable/StrobePolarity/StrobeOperation rather than SFNC LineSelector/ + LineMode/LineSource nodes. + """ + output_line = str(self._trigger_attr(cfg, "output_line", "Line2") or "Line2") + output_source = str(self._trigger_attr(cfg, "output_source", "ExposureActive") or "ExposureActive") + + # Optional extra fields if present in trigger dict/model. + strobe_polarity = str(self._trigger_attr(cfg, "strobe_polarity", "ActiveHigh") or "ActiveHigh") + strobe_operation = str(self._trigger_attr(cfg, "strobe_operation", "Exposure") or "Exposure") + strobe_duration = self._trigger_attr(cfg, "strobe_duration", None) + strobe_delay = self._trigger_attr(cfg, "strobe_delay", None) + + # Master camera should be free-running. + self._configure_trigger_off(node_map, strict=False) + + # ------------------------------------------------------------------ + # Preferred path for The Imaging Source 37U / DMK 37BUX287: + # StrobeEnable, StrobePolarity, StrobeOperation, StrobeDuration, StrobeDelay + # ------------------------------------------------------------------ + strobe_enable_node = self._node(node_map, "StrobeEnable") + + if strobe_enable_node is not None: + # Disable first while changing parameters. + self._set_enum_node(node_map, "StrobeEnable", "Off", strict=False) + + polarity_ok = self._set_enum_node( + node_map, + "StrobePolarity", + strobe_polarity, + strict=False, + ) + + operation_ok = self._set_enum_node( + node_map, + "StrobeOperation", + strobe_operation, + strict=False, + ) + + if strobe_duration is not None: + try: + node = self._node(node_map, "StrobeDuration") + if node is not None: + node.value = int(strobe_duration) + LOG.debug("Configured GenTL StrobeDuration=%s", int(strobe_duration)) + except Exception as exc: + if strict: + raise RuntimeError(f"Failed to set StrobeDuration={strobe_duration}: {exc}") from exc + LOG.warning("Failed to set StrobeDuration=%s: %s", strobe_duration, exc) + + if strobe_delay is not None: + try: + node = self._node(node_map, "StrobeDelay") + if node is not None: + node.value = int(strobe_delay) + LOG.debug("Configured GenTL StrobeDelay=%s", int(strobe_delay)) + except Exception as exc: + if strict: + raise RuntimeError(f"Failed to set StrobeDelay={strobe_delay}: {exc}") from exc + LOG.warning("Failed to set StrobeDelay=%s: %s", strobe_delay, exc) + + enable_ok = self._set_enum_node( + node_map, + "StrobeEnable", + "On", + strict=strict, + ) + + if enable_ok: + LOG.debug( + "GenTL trigger master configured via Strobe*: " + "StrobeEnable=On StrobePolarity=%s polarity_ok=%s " + "StrobeOperation=%s operation_ok=%s", + strobe_polarity, + polarity_ok, + strobe_operation, + operation_ok, + ) + return + + if strict: + raise RuntimeError("Could not enable GenTL StrobeEnable=On") + + LOG.warning( + "StrobeEnable node exists but could not be enabled; falling back to generic Line* output configuration." + ) + + # ------------------------------------------------------------------ + # Generic SFNC fallback for cameras that expose LineSelector/LineMode/LineSource. + # ------------------------------------------------------------------ + line_selector = self._node(node_map, "LineSelector") + if line_selector is not None: + line_selected = self._set_enum_node( + node_map, + "LineSelector", + output_line, + strict=strict, + ) + + if not line_selected: + LOG.warning( + "Could not select GenTL output line '%s'; skipping Line* output configuration.", + output_line, + ) + else: + mode_ok = self._set_enum_node(node_map, "LineMode", "Output", strict=strict) + source_ok = self._set_enum_node(node_map, "LineSource", output_source, strict=strict) + + if mode_ok and source_ok: + LOG.debug( + "GenTL trigger master configured via Line*: output_line=%s output_source=%s", + output_line, + output_source, + ) + return + + LOG.warning( + "GenTL Line* trigger output configuration incomplete (LineMode ok=%s, LineSource ok=%s).", + mode_ok, + source_ok, + ) + + msg = ( + "Could not configure GenTL trigger master output. " + "No supported Strobe* or Line* output path was successfully configured." + ) + + if strict: + raise RuntimeError(msg) + + LOG.warning(msg) + + def _restore_trigger_idle(self, node_map) -> None: + """Best-effort restore to a safe non-triggering state after acquisition stops. + + Important: + - This should be called after acquirer.stop(), not while acquisition is active. + - It is intentionally non-strict because shutdown should not fail if a node + is missing or read-only. + """ + role = str(self._trigger_attr(getattr(self, "_trigger", None), "role", "off") or "off").lower() + try: - trigger_mode = getattr(node_map, "TriggerMode", None) - if trigger_mode is not None and "Off" in getattr(trigger_mode, "symbolics", []): - trigger_mode.value = "Off" - except Exception as e: - LOG.warning("Failed to disable trigger mode: %s", e) + if role in {"external", "follower"}: + self._set_enum_node(node_map, "TriggerMode", "Off", strict=False) + + elif role == "master": + # Stop driving output if the camera exposes these nodes. + self._set_enum_node(node_map, "LineSource", "Off", strict=False) + self._set_enum_node(node_map, "LineMode", "Input", strict=False) + + except Exception: + LOG.debug("Best-effort GenTL trigger restore failed", exc_info=True) def _configure_resolution(self, node_map) -> None: if self._requested_resolution is None: @@ -1088,21 +1767,48 @@ def _configure_frame_rate(self, node_map) -> None: return target = float(self.settings.fps) + LOG.debug("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.debug("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.debug( + "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: @@ -1112,20 +1818,92 @@ 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, + ) + 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: + self._camera_pixel_format = pixel_format + ns["actual_pixel_format"] = pixel_format + ns["detected_pixel_format"] = pixel_format + + output_format = self.actual_output_format + if output_format is not None: + ns["actual_output_format"] = output_format + except Exception: - self._actual_gain = None + pass # ------------------------------------------------------------------ # Frame conversion / local helpers @@ -1148,6 +1926,9 @@ def _convert_frame(self, frame: np.ndarray) -> np.ndarray: frame = cv2.cvtColor(frame, cv2.COLOR_BayerGR2BGR) elif fmt == "BayerBG8": frame = cv2.cvtColor(frame, cv2.COLOR_BayerBG2BGR) + elif self._should_output_mono(): + # Keep Mono* cameras as 2D uint8 frames when explicitly requested. + pass else: frame = cv2.cvtColor(frame, cv2.COLOR_GRAY2BGR) diff --git a/dlclivegui/cameras/backends/opencv_backend.py b/dlclivegui/cameras/backends/opencv_backend.py index 74fdede98..38613fb2c 100644 --- a/dlclivegui/cameras/backends/opencv_backend.py +++ b/dlclivegui/cameras/backends/opencv_backend.py @@ -10,10 +10,9 @@ from typing import TYPE_CHECKING, Literal import cv2 -import numpy as np from pydantic import BaseModel, Field, model_validator -from ..base import CameraBackend, SupportLevel, register_backend +from ..base import CameraBackend, CapturedFrame, SupportLevel, register_backend from ..factory import DetectedCamera from .utils.opencv_discovery import ( ModeRequest, @@ -199,21 +198,45 @@ def open(self) -> None: self._configure_capture() - def read(self) -> tuple[np.ndarray | None, float]: - """Robust frame read: return (None, ts) on transient failures; never raises.""" + def read(self) -> CapturedFrame: + """Robust frame read: return CapturedFrame(frame=None, ...) on transient failures; never raises.""" if self._capture is None: logger.warning("OpenCVCameraBackend.read() called before open()") - return None, time.time() + return CapturedFrame( + frame=None, + software_timestamp=time.time(), + timestamp_metadata=None, + ) + try: if not self._capture.grab(): - return None, time.time() + return CapturedFrame( + frame=None, + software_timestamp=time.time(), + timestamp_metadata=None, + ) + success, frame = self._capture.retrieve() if not success or frame is None or frame.size == 0: - return None, time.time() - return frame, time.time() + return CapturedFrame( + frame=None, + software_timestamp=time.time(), + timestamp_metadata=None, + ) + + return CapturedFrame( + frame=frame, + software_timestamp=time.time(), + timestamp_metadata=None, + ) + except Exception as exc: - logger.debug(f"OpenCV read transient error: {exc}") - return None, time.time() + logger.debug("OpenCV read transient error: %s", exc) + return CapturedFrame( + frame=None, + software_timestamp=time.time(), + timestamp_metadata=None, + ) def close(self) -> None: self._release_capture() @@ -222,15 +245,26 @@ def stop(self) -> None: self._release_capture() def device_name(self) -> str: - base_name = "OpenCV" + ns = self.parse_options(self.settings) + + if ns.device_name: + return ns.device_name + + name = str(getattr(self.settings, "name", "") or "").strip() + if name: + return name + + api_name = "" if self._capture and hasattr(self._capture, "getBackendName"): try: - backend_name = self._capture.getBackendName() + api_name = self._capture.getBackendName() except Exception: - backend_name = "" - if backend_name: - base_name = backend_name - return f"{base_name} camera #{self.settings.index}" + api_name = "" + + if api_name: + return f"OpenCV {api_name} camera #{self.settings.index}" + + return f"OpenCV camera #{self.settings.index}" @property def actual_fps(self) -> float | None: @@ -254,6 +288,16 @@ def actual_gain(self) -> None: """Not supported by OpenCV backend.""" return None + @property + def actual_pixel_format(self) -> str | None: + """OpenCV does not reliably expose native camera pixel format.""" + return None + + @property + def actual_output_format(self) -> str | None: + """OpenCV VideoCapture returns BGR frames in this backend.""" + return "BGR8" + # ---------------------------- # Internal helpers # ---------------------------- diff --git a/dlclivegui/cameras/base.py b/dlclivegui/cameras/base.py index bc91ce9bf..9217ad8e1 100644 --- a/dlclivegui/cameras/base.py +++ b/dlclivegui/cameras/base.py @@ -3,6 +3,7 @@ import logging from abc import ABC, abstractmethod +from dataclasses import dataclass from enum import Enum from typing import TYPE_CHECKING, Any, ClassVar @@ -11,6 +12,7 @@ from ..config import CameraSettings if TYPE_CHECKING: + from ..utils.timestamps import FrameTimestampMetadata from .factory import DetectedCamera _BACKEND_REGISTRY: dict[str, type[CameraBackend]] = {} @@ -68,11 +70,28 @@ class SupportLevel(str, Enum): "set_fps": SupportLevel.UNSUPPORTED, "set_exposure": SupportLevel.UNSUPPORTED, "set_gain": SupportLevel.UNSUPPORTED, + "preserve_mono": SupportLevel.UNSUPPORTED, "device_discovery": SupportLevel.UNSUPPORTED, "stable_identity": SupportLevel.UNSUPPORTED, + "hardware_trigger": SupportLevel.UNSUPPORTED, + "hardware_frame_timestamps": SupportLevel.UNSUPPORTED, } +@dataclass(frozen=True) +class CapturedFrame: + """Frame plus software timestamp and optional backend timestamp metadata.""" + + frame: np.ndarray | None + software_timestamp: float + timestamp_metadata: FrameTimestampMetadata | None = None + + def __iter__(self): + """Backwards-compatible unpacking: frame, software_timestamp = backend.read()""" + yield self.frame + yield self.software_timestamp + + class CameraBackend(ABC): """Abstract base class for camera backends.""" @@ -97,6 +116,19 @@ def static_capabilities(cls) -> dict[str, SupportLevel]: """Return a dict describing supported features for UI purposes.""" return DEFAULT_CAPABILITIES + @property + def actual_pixel_format(self) -> str | None: + return None + + @property + def recommended_preserve_mono(self) -> bool | None: + return None + + @property + def last_frame_timestamp_metadata(self) -> FrameTimestampMetadata | None: + """Return backend-provided timestamp metadata for the last read frame.""" + return None + @classmethod def options_key(cls) -> str: """Return the key used to store this backend's options in CameraSettings.""" @@ -161,7 +193,7 @@ def open(self) -> None: raise NotImplementedError @abstractmethod - def read(self) -> tuple[np.ndarray, float]: + def read(self) -> CapturedFrame: """Read a frame and return the image with a timestamp.""" raise NotImplementedError diff --git a/dlclivegui/config.py b/dlclivegui/config.py index 6d9e1de76..acc2a6d1c 100644 --- a/dlclivegui/config.py +++ b/dlclivegui/config.py @@ -12,6 +12,29 @@ TileLayout = Literal["auto", "2x2", "1x4", "4x1"] Precision = Literal["FP32", "FP16"] ModelType = Literal["pytorch", "tensorflow"] +TriggerRole = Literal["off", "external", "master", "follower"] +TriggerActivation = Literal["RisingEdge", "FallingEdge", "AnyEdge", "LevelHigh", "LevelLow"] +TriggerStrobePolarity = Literal["ActiveHigh", "ActiveLow"] +TriggerStrobeOperation = Literal["Exposure", "FixedDuration"] + +# Global settings +## GUI +GUI_MAX_DISPLAY_FPS: float = 30.0 +## Recording +ALLOWED_VIDEO_CONTAINERS: set[str] = {"mp4", "avi", "mov"} +DEFAULT_RECORDING_CONTAINER: str = "mp4" + + +## Debug +### Timing logs +SINGLE_CAMERA_WORKER_DO_LOG_TIMING: bool = False +MULTI_CAMERA_WORKER_DO_LOG_TIMING: bool = False +REC_DO_LOG_TIMING: bool = False +DLC_DO_LOG_TIMING: bool = False +DLC_LIFECYCLE_EXTRA_LOGS: bool = True +# MAIN_WINDOW_DO_LOG_TIMING: bool = False +#### Backends +BASLER_DO_LOG_TIMING: bool = False class CameraSettings(BaseModel): @@ -27,6 +50,7 @@ class CameraSettings(BaseModel): exposure: int = 0 # 0=auto else µs gain: float = 0.0 # 0.0=auto else value + preserve_mono: bool = False # if True, preserve mono images as mono (not BGR) when reading crop_x0: int = 0 crop_y0: int = 0 @@ -38,6 +62,28 @@ class CameraSettings(BaseModel): enabled: bool = True properties: dict[str, Any] = Field(default_factory=dict) + def pretty(self) -> str: + crop = ( + "none" + if self.get_crop_region() is None + else f"({self.crop_x0}, {self.crop_y0}) -> ({self.crop_x1 or 'edge'}, {self.crop_y1 or 'edge'})" + ) + return ( + f"CameraSettings[\n" + f" name={self.name!r}, index={self.index}, backend={self.backend!r}, enabled={self.enabled}\n" + f" fps={self.fps}, size={self.width or 'auto'}x{self.height or 'auto'}, " + f"exposure={self.exposure or 'auto'}, gain={self.gain or 'auto'}\n" + f" rotation={self.rotation}, crop={crop}\n" + f" preserve_mono={self.preserve_mono}, max_devices={self.max_devices}\n" + f"]" + ) + + def __str__(self) -> str: + return self.pretty() + + def __repr__(self) -> str: + return self.pretty() + @field_validator("fps", mode="before") @classmethod def _coerce_fps(cls, v): @@ -168,6 +214,158 @@ def check_diff(old: CameraSettings, new: CameraSettings) -> dict: pass return out + def backend_options(self, backend: str | None = None) -> dict[str, Any]: + key = backend or self.backend + props = self.properties if isinstance(self.properties, dict) else {} + ns = props.get(str(key).lower(), {}) + return ns if isinstance(ns, dict) else {} + + def get_trigger_settings(self, backend: str | None = None) -> CameraTriggerSettings: + ns = self.backend_options(backend) + return CameraTriggerSettings.from_any(ns.get("trigger")) + + def set_trigger_settings(self, trigger: CameraTriggerSettings, backend: str | None = None) -> None: + key = backend or self.backend + if not isinstance(self.properties, dict): + self.properties = {} + ns = self.properties.setdefault(str(key).lower(), {}) + if not isinstance(ns, dict): + ns = {} + self.properties[str(key).lower()] = ns + ns["trigger"] = trigger.to_properties() + + def with_save_defaults(self) -> CameraSettings: + out = self.model_copy(deep=True) + + backend = (out.backend or "").lower() + if backend != "gentl": + return out + + if not isinstance(out.properties, dict): + out.properties = {} + + ns = out.properties.setdefault("gentl", {}) + if not isinstance(ns, dict): + ns = {} + out.properties["gentl"] = ns + + ns.setdefault("trigger", CameraTriggerSettings().to_properties()) + + return out + + +class CameraTriggerSettings(BaseModel): + """ + Generic hardware-trigger settings. + + Backend-specific code may ignore fields that are unsupported by a given + camera/SDK. + + For GenTL/TIS DMK 37BUX287: + - follower/external maps mainly to TriggerMode, TriggerSelector, + TriggerActivation. TriggerSource may be read-only and is best-effort. + - master output maps primarily to StrobeEnable, StrobePolarity, + StrobeOperation, StrobeDuration, and StrobeDelay. + """ + + role: TriggerRole = "off" + + # Input trigger config: external/follower + selector: str = "FrameStart" + source: str = "auto" + activation: TriggerActivation | str = "RisingEdge" + + # Generic/SFNC output config: master fallback for cameras exposing Line* nodes. + output_line: str = "Line2" + output_source: str = "ExposureActive" + + # Strobe output config: master path for TIS/DMK 37U cameras. + strobe_polarity: TriggerStrobePolarity | str = "ActiveHigh" + strobe_operation: TriggerStrobeOperation | str = "Exposure" + strobe_duration: int | None = None # µs, used when strobe_operation=FixedDuration + strobe_delay: int | None = None # µs + + # Runtime behavior + timeout: float | None = None + strict: bool = False + + @field_validator("role", mode="before") + @classmethod + def _coerce_role(cls, v): + if v is None: + return "off" + + s = str(v).strip().lower() + aliases = { + "": "off", + "none": "off", + "false": "off", + "disabled": "off", + "disable": "off", + "off": "off", + "true": "external", + "on": "external", + "trigger": "external", + "triggered": "external", + "external": "external", + "follower": "follower", + "slave": "follower", + "master": "master", + "main": "master", + } + return aliases.get(s, s) + + @field_validator("timeout", mode="before") + @classmethod + def _coerce_timeout(cls, v): + if v in (None, ""): + return None + try: + fv = float(v) + except Exception: + return None + return fv if fv > 0 else None + + @field_validator("strobe_duration", "strobe_delay", mode="before") + @classmethod + def _coerce_optional_nonnegative_int(cls, v): + if v in (None, ""): + return None + try: + iv = int(float(v)) + except Exception: + return None + return iv if iv >= 0 else None + + @field_validator("source", mode="before") + @classmethod + def _coerce_source(cls, v): + if v is None: + return "auto" + + s = str(v).strip() + if not s: + return "auto" + + aliases = { + "default": "auto", + "automatic": "auto", + "device": "auto", + "camera": "auto", + } + return aliases.get(s.lower(), s) + + @classmethod + def from_any(cls, value) -> CameraTriggerSettings: + if isinstance(value, cls): + return value + if isinstance(value, dict): + return cls(**value) + return cls() + + def to_properties(self) -> dict[str, Any]: + return self.model_dump(exclude_none=True) + class MultiCameraSettings(BaseModel): cameras: list[CameraSettings] = Field(default_factory=list) @@ -206,12 +404,19 @@ def from_dict(cls, data: dict[str, Any]) -> MultiCameraSettings: return cls(cameras=cameras, max_cameras=max_cameras, tile_layout=tile_layout) def to_dict(self) -> dict[str, Any]: + out = self.with_save_defaults() return { - "cameras": [cam.model_dump() for cam in self.cameras], - "max_cameras": self.max_cameras, - "tile_layout": self.tile_layout, + "cameras": [cam.model_dump() for cam in out.cameras], + "max_cameras": out.max_cameras, + "tile_layout": out.tile_layout, } + def with_save_defaults(self) -> MultiCameraSettings: + """Return a copy with save defaults applied to all cameras.""" + out = self.model_copy(deep=True) + out.cameras = [cam.with_save_defaults() for cam in out.cameras] + return out + class DynamicCropModel(BaseModel): enabled: bool = False @@ -312,9 +517,10 @@ class RecordingSettings(BaseModel): enabled: bool = False directory: str = Field(default_factory=lambda: str(Path.home() / "Videos" / "deeplabcut-live")) filename: str = "session.mp4" - container: Literal["mp4", "avi", "mov"] = "mp4" + container: Literal["mp4", "avi", "mov"] = DEFAULT_RECORDING_CONTAINER codec: str = "libx264" crf: int = Field(default=23, ge=0, le=51) + fast_encoding: bool = False def output_path(self) -> Path: """Return the absolute output path for recordings.""" @@ -328,18 +534,47 @@ def output_path(self) -> Path: filename = name.with_suffix(f".{self.container}") return directory / filename - def writegear_options(self, fps: float) -> dict[str, Any]: - """Return compression parameters for WriteGear.""" + def writegear_options(self, fps: float | None) -> dict[str, Any]: + """Return FFmpeg/WriteGear compression parameters. + + The default settings prioritize compatibility and compression quality. If + ``fast_encoding`` is enabled, additional low-latency encoder options are + added for codecs that are known to support them. + + Args: + fps: Desired input frame rate. If missing or non-positive, falls back + to 30 FPS. + + Returns: + Dictionary of WriteGear/FFmpeg options. + """ + try: + fps_value = float(fps or 0.0) + except Exception: + fps_value = 0.0 + if fps_value <= 0.0: + fps_value = 30.0 - fps_value = float(fps) if fps else 30.0 codec_value = (self.codec or "libx264").strip() or "libx264" crf_value = int(self.crf) if self.crf is not None else 23 - return { - "-input_framerate": f"{fps_value:.6f}", + + opts: dict[str, Any] = { + "-input_framerate": float(fps_value), "-vcodec": codec_value, "-crf": str(crf_value), } + if self.fast_encoding: + if codec_value in {"libx264", "libx265"}: + opts.update( + { + "-preset": "ultrafast", + "-tune": "zerolatency", + } + ) + + return opts + class ApplicationSettings(BaseModel): # optional: add a semantic version for migrations @@ -377,10 +612,13 @@ def from_dict(cls, data: dict[str, Any]) -> ApplicationSettings: ) def to_dict(self) -> dict[str, Any]: + camera = self.camera.with_save_defaults() + multi_camera = self.multi_camera.with_save_defaults() + return { "version": self.version, - "camera": self.camera.model_dump(), - "multi_camera": self.multi_camera.to_dict(), + "camera": camera.model_dump(), + "multi_camera": multi_camera.to_dict(), "dlc": self.dlc.model_dump(), "recording": self.recording.model_dump(), "bbox": self.bbox.model_dump(), diff --git a/dlclivegui/gui/camera_config/camera_config_dialog.py b/dlclivegui/gui/camera_config/camera_config_dialog.py index 4c94fb701..617f80166 100644 --- a/dlclivegui/gui/camera_config/camera_config_dialog.py +++ b/dlclivegui/gui/camera_config/camera_config_dialog.py @@ -18,9 +18,10 @@ ) from ...cameras.factory import CameraFactory, DetectedCamera, apply_detected_identity, camera_identity_key -from ...config import CameraSettings, MultiCameraSettings +from ...config import CameraSettings, CameraTriggerSettings, MultiCameraSettings from .loaders import CameraLoadWorker, CameraProbeWorker, CameraScanState, DetectCamerasWorker from .preview import PreviewSession, PreviewState, apply_crop, apply_rotation, resize_to_fit, to_display_pixmap +from .trigger_config_dialog import TriggerConfigDialog from .ui_blocks import setup_camera_config_dialog_ui LOGGER = logging.getLogger(__name__) @@ -328,6 +329,7 @@ def _connect_signals(self) -> None: self.active_cameras_list.currentRowChanged.connect(self._on_active_camera_selected) self.available_cameras_list.currentRowChanged.connect(self._on_available_camera_selected) self.available_cameras_list.itemDoubleClicked.connect(self._on_available_camera_double_clicked) + self.trigger_settings_btn.clicked.connect(self._open_trigger_settings_dialog) self.apply_settings_btn.clicked.connect(self._apply_camera_settings) self.reset_settings_btn.clicked.connect(self._reset_selected_camera) self.preview_btn.clicked.connect(self._toggle_preview) @@ -357,6 +359,7 @@ def _mark_dirty(*_args): self.cam_rotation.currentIndexChanged.connect(lambda *_: _mark_dirty()) self.cam_enabled_checkbox.stateChanged.connect(lambda *_: _mark_dirty()) + self.cam_preserve_mono_checkbox.stateChanged.connect(lambda *_: _mark_dirty()) # ------------------------------- # UI state updates @@ -421,6 +424,8 @@ def _set_detected_labels(self, cam: CameraSettings) -> None: det_res = ns.get("detected_resolution") det_fps = ns.get("detected_fps") + det_output_format = ns.get("detected_output_format") + det_pixel_format = ns.get("detected_pixel_format") if isinstance(det_res, (list, tuple)) and len(det_res) == 2: try: @@ -436,6 +441,18 @@ def _set_detected_labels(self, cam: CameraSettings) -> None: else: self.detected_fps_label.setText("—") + self.detected_output_format_label.setText(str(det_output_format) if det_output_format else "—") + + tooltip_parts = [] + if det_output_format: + tooltip_parts.append(f"Backend output: {det_output_format}") + if det_pixel_format: + tooltip_parts.append(f"Camera PixelFormat: {det_pixel_format}") + + self.detected_output_format_label.setToolTip( + "\n".join(tooltip_parts) if tooltip_parts else "Backend-reported output frame format emitted to the app." + ) + def _refresh_camera_labels(self) -> None: cam_list = getattr(self, "active_cameras_list", None) if not cam_list: @@ -451,11 +468,25 @@ def _refresh_camera_labels(self) -> None: finally: cam_list.blockSignals(False) + def _trigger_role_for_label(self, cam: CameraSettings) -> str: + backend = (cam.backend or "").lower() + props = cam.properties if isinstance(cam.properties, dict) else {} + ns = props.get(backend, {}) if isinstance(props.get(backend), dict) else {} + trigger = ns.get("trigger", {}) + if not isinstance(trigger, dict): + return "off" + return str(trigger.get("role", "off") or "off").lower() + def _format_camera_label(self, cam: CameraSettings, index: int = -1) -> str: status = "✓" if cam.enabled else "○" this_id = f"{(cam.backend or '').lower()}:{cam.index}" dlc_indicator = " [DLC]" if this_id == self._dlc_camera_id and cam.enabled else "" - return f"{status} {cam.name} [{cam.backend}:{cam.index}]{dlc_indicator}" + mono_indicator = " [Mono]" if getattr(cam, "preserve_mono", False) else "" + + trigger_role = self._trigger_role_for_label(cam) + trigger_indicator = "" if trigger_role in {"off", "disabled"} else f" [{trigger_role}]" + + return f"{status} {cam.name} [{cam.backend}:{cam.index}]{trigger_indicator}{dlc_indicator}{mono_indicator}" def _selected_detected_camera(self) -> DetectedCamera | None: row = self.available_cameras_list.currentRow() @@ -514,6 +545,12 @@ def apply(widget, feature: str, label: str, *, allow_best_effort: bool = True): apply(self.cam_exposure, "set_exposure", "Exposure") apply(self.cam_gain, "set_gain", "Gain") + # Output format / preserve mono + apply(self.cam_preserve_mono_checkbox, "preserve_mono", "Preserve mono output") + + # Hardware trigger / sync + apply(self.trigger_settings_btn, "hardware_trigger", "Hardware trigger") + def _set_preview_button_loading(self, loading: bool) -> None: if loading: self.preview_btn.setText("Cancel Loading") @@ -800,6 +837,21 @@ def _on_active_camera_selected(self, row: int) -> None: self._load_camera_to_form(cam) self._start_probe_for_camera(cam, apply_to_requested=False) + def _ensure_default_trigger_config(self, cam: CameraSettings) -> None: + backend = (cam.backend or "").lower() + if backend not in {"gentl", "basler"}: + return + + if not isinstance(cam.properties, dict): + cam.properties = {} + + ns = cam.properties.setdefault(backend, {}) + if not isinstance(ns, dict): + ns = {} + cam.properties[backend] = ns + + ns.setdefault("trigger", CameraTriggerSettings().model_dump(exclude_none=True)) + def _add_selected_camera(self) -> None: if not self._commit_pending_edits(reason="before adding a new camera"): return @@ -850,6 +902,7 @@ def _add_selected_camera(self) -> None: properties={}, ) apply_detected_identity(new_cam, detected, backend) + self._ensure_default_trigger_config(new_cam) self._working_settings.cameras.append(new_cam) new_index = len(self._working_settings.cameras) - 1 new_item = QListWidgetItem(self._format_camera_label(new_cam, new_index)) @@ -925,6 +978,7 @@ def _build_model_from_form(self, base: CameraSettings) -> CameraSettings: "crop_y0": int(self.cam_crop_y0.value()), "crop_x1": int(self.cam_crop_x1.value()), "crop_y1": int(self.cam_crop_y1.value()), + "preserve_mono": bool(self.cam_preserve_mono_checkbox.isChecked()), } ) # Validate and coerce; if invalid, Pydantic will raise @@ -943,6 +997,7 @@ def _load_camera_to_form(self, cam: CameraSettings) -> None: self.cam_crop_y0, self.cam_crop_x1, self.cam_crop_y1, + self.cam_preserve_mono_checkbox, ] for widget in block: if hasattr(widget, "blockSignals"): @@ -957,6 +1012,7 @@ def _load_camera_to_form(self, cam: CameraSettings) -> None: self.cam_index_label.setText(str(cam.index)) self.cam_backend_label.setText(cam.backend) self._update_controls_for_backend(cam.backend) + self.cam_preserve_mono_checkbox.setChecked(bool(getattr(cam, "preserve_mono", False))) self.cam_width.setValue(cam.width) self.cam_height.setValue(cam.height) self.cam_fps.setValue(cam.fps) @@ -969,6 +1025,7 @@ def _load_camera_to_form(self, cam: CameraSettings) -> None: self.cam_crop_y0.setValue(cam.crop_y0) self.cam_crop_x1.setValue(cam.crop_x1) self.cam_crop_y1.setValue(cam.crop_y1) + self._ensure_default_trigger_config(cam) self.apply_settings_btn.setEnabled(True) self._set_detected_labels(cam) finally: @@ -991,6 +1048,7 @@ def _write_form_to_cam(self, cam: CameraSettings) -> None: cam.crop_y0 = int(self.cam_crop_y0.value()) cam.crop_x1 = int(self.cam_crop_x1.value()) cam.crop_y1 = int(self.cam_crop_y1.value()) + cam.preserve_mono = bool(self.cam_preserve_mono_checkbox.isChecked()) def _commit_pending_edits(self, *, reason: str = "") -> bool: """ @@ -1029,6 +1087,39 @@ def _enabled_count_with(self, row: int, new_enabled: bool) -> int: count += 1 return count + def _open_trigger_settings_dialog(self) -> None: + """Open per-camera hardware trigger settings dialog.""" + if self._current_edit_index is None: + return + + row = self._current_edit_index + if row < 0 or row >= len(self._working_settings.cameras): + return + + # Commit normal camera edits first so we do not lose pending UI changes. + if not self._commit_pending_edits(reason="before opening trigger settings"): + return + + cam = self._working_settings.cameras[row] + + dlg = TriggerConfigDialog(cam, self) + if dlg.exec() != QDialog.Accepted: + return + + updated = dlg.camera_settings + + self._working_settings.cameras[row] = updated + self._update_active_list_item(row, updated) + self._load_camera_to_form(updated) + + # Trigger changes require reopening the camera preview/backend. + if self._is_preview_live(): + self._append_status("[Trigger] Restarting preview to apply trigger settings.") + self._request_preview_restart(updated, reason="trigger-settings") + + self.apply_settings_btn.setEnabled(False) + self._set_apply_dirty(False) + def _apply_camera_settings(self) -> bool: try: for sb in ( @@ -1085,9 +1176,7 @@ def _apply_camera_settings(self) -> bool: old_settings = current_model restart = False - should_consider_restart = self._preview.state == PreviewState.ACTIVE and isinstance( - old_settings, CameraSettings - ) + should_consider_restart = self._is_preview_live() and isinstance(old_settings, CameraSettings) if should_consider_restart: restart = self._should_restart_preview(old_settings, new_model) @@ -1099,7 +1188,7 @@ def _apply_camera_settings(self) -> bool: new_model.index, ) - if self._preview.state == PreviewState.ACTIVE and restart: + if self._is_preview_live() and restart: self._append_status("[Apply] Restarting preview to apply camera settings changes.") self._request_preview_restart(new_model, reason="apply-settings") @@ -1120,6 +1209,8 @@ def _clear_settings_form(self) -> None: self.cam_backend_label.setText("") self.detected_resolution_label.setText("—") self.detected_fps_label.setText("—") + self.detected_output_format_label.setText("—") + self.detected_output_format_label.setToolTip("Backend-reported output frame format emitted to the app.") self.cam_width.setValue(0) self.cam_height.setValue(0) self.cam_fps.setValue(0.0) @@ -1130,6 +1221,7 @@ def _clear_settings_form(self) -> None: self.cam_crop_y0.setValue(0) self.cam_crop_x1.setValue(0) self.cam_crop_y1.setValue(0) + self.cam_preserve_mono_checkbox.setChecked(False) self.apply_settings_btn.setEnabled(False) self.reset_settings_btn.setEnabled(False) @@ -1226,6 +1318,8 @@ def _reset_selected_camera(self, *, clear_backend_cache: bool = False) -> None: else: ns.pop("detected_resolution", None) ns.pop("detected_fps", None) + ns.pop("detected_pixel_format", None) + ns.pop("detected_output_format", None) ns.pop("last_applied_resolution", None) # Update UI immediately to show "Auto" while probing @@ -1297,12 +1391,16 @@ def _start_probe_for_camera(self, cam: CameraSettings, *, apply_to_requested: bo ns = props.get(backend, {}) if isinstance(props.get(backend, None), dict) else {} if not apply_to_requested: det_res = ns.get("detected_resolution") + det_output = ns.get("detected_output_format") + has_res = False if isinstance(det_res, (list, tuple)) and len(det_res) == 2: try: - if int(det_res[0]) > 0 and int(det_res[1]) > 0: - return + has_res = int(det_res[0]) > 0 and int(det_res[1]) > 0 except Exception: - pass + has_res = False + + if has_res and det_output: + return # Start probe worker (settings will be opened in GUI thread for safety) self._probe_worker = CameraProbeWorker(cam, self) @@ -1328,6 +1426,9 @@ def _on_probe_success(self, payload) -> None: actual_res = getattr(be, "actual_resolution", None) actual_fps = getattr(be, "actual_fps", None) + actual_pixel_format = getattr(be, "actual_pixel_format", None) + actual_output_format = getattr(be, "actual_output_format", None) + recommended_preserve_mono = getattr(be, "recommended_preserve_mono", None) try: be.close() @@ -1350,12 +1451,33 @@ def _on_probe_success(self, payload) -> None: # Store regardless of "set_*" support. This is just "what device reports". if actual_res and isinstance(actual_res, (list, tuple)) and len(actual_res) == 2: ns["detected_resolution"] = [int(actual_res[0]), int(actual_res[1])] - elif actual_res and isinstance(actual_res, tuple) and len(actual_res) == 2: - ns["detected_resolution"] = [int(actual_res[0]), int(actual_res[1])] if isinstance(actual_fps, (int, float)) and float(actual_fps) > 0: ns["detected_fps"] = float(actual_fps) - self._append_status(f"[Probe] actual_res={actual_res}, actual_fps={actual_fps}") + + if actual_pixel_format: + ns["detected_pixel_format"] = str(actual_pixel_format) + self._append_status(f"[Probe] PixelFormat={actual_pixel_format}") + + if actual_output_format: + ns["detected_output_format"] = str(actual_output_format) + self._append_status(f"[Probe] OutputFormat={actual_output_format}") + + if recommended_preserve_mono is not None: + ns["recommended_preserve_mono"] = bool(recommended_preserve_mono) + + # ---- Generic capability-driven recommendation ---- + caps = CameraFactory.backend_capabilities(backend) + preserve_mono_cap = caps.get("preserve_mono") + preserve_mono_supported = preserve_mono_cap is not None and preserve_mono_cap.value != "unsupported" + + if preserve_mono_supported and recommended_preserve_mono is True: + if not bool(getattr(c, "preserve_mono", False)): + c.preserve_mono = True + self._append_status("[Probe] Mono pixel format detected; enabled Preserve mono frames.") + + if actual_pixel_format and str(actual_pixel_format).startswith("Mono"): + ns["detected_output_format"] = "Mono8" # ---- Apply detected -> requested (Reset behavior) ---- if self._probe_apply_to_requested and self._probe_target_row == i: @@ -1384,7 +1506,9 @@ def _on_probe_success(self, payload) -> None: # Always refresh detected labels if currently selected if self._current_edit_index == i: + self._load_camera_to_form(c) self._set_detected_labels(c) + break except Exception as exc: @@ -1597,6 +1721,13 @@ def _bump_epoch(self) -> int: self._preview.epoch += 1 return self._preview.epoch + def _trigger_dict_for_cam(self, cam: CameraSettings) -> dict: + backend = (cam.backend or "").lower() + props = cam.properties if isinstance(cam.properties, dict) else {} + ns = props.get(backend, {}) if isinstance(props.get(backend), dict) else {} + trigger = ns.get("trigger", {}) + return trigger if isinstance(trigger, dict) else {} + def _should_restart_preview(self, old: CameraSettings, new: CameraSettings) -> bool: """ Fast UX policy: @@ -1605,13 +1736,16 @@ def _should_restart_preview(self, old: CameraSettings, new: CameraSettings) -> b Backend-agnostic for now (no OpenCV special casing). """ # Restart on these changes - for key in ("width", "height", "fps", "exposure", "gain"): + for key in ("width", "height", "fps", "exposure", "gain", "preserve_mono"): try: if getattr(old, key, None) != getattr(new, key, None): return True except Exception: return True # safest: restart + if self._trigger_dict_for_cam(old) != self._trigger_dict_for_cam(new): + return True + # No restart needed if only rotation/crop/enabled changed return False @@ -1756,9 +1890,10 @@ def _on_loader_finished(self, e: int) -> None: self._preview.restart_scheduled = False self._preview.loader = None - if pending and self._preview.state == PreviewState.IDLE: + if pending and self._preview.state in (PreviewState.IDLE, PreviewState.ACTIVE): LOGGER.debug("[Loader] finished with pending restart for backend=%s idx=%s", pending.backend, pending.index) self._begin_preview_load(pending, reason="pending-restart-after-finish") + return # UI sync is already handled in _begin_preview_load self._sync_preview_ui() diff --git a/dlclivegui/gui/camera_config/preview.py b/dlclivegui/gui/camera_config/preview.py index bbd1aef0d..ef2c7570b 100644 --- a/dlclivegui/gui/camera_config/preview.py +++ b/dlclivegui/gui/camera_config/preview.py @@ -7,6 +7,7 @@ from PySide6.QtCore import QTimer +from ...services.camera_controller import SingleCameraWorker from ...services.multi_camera_controller import MultiCameraController if TYPE_CHECKING: @@ -56,7 +57,7 @@ class PreviewSession: def apply_rotation(frame, rotation): - return MultiCameraController.apply_rotation(frame, rotation) + return SingleCameraWorker.apply_rotation(frame, rotation) def apply_crop(frame, x0, y0, x1, y1): @@ -66,7 +67,7 @@ def apply_crop(frame, x0, y0, x1, y1): x1 = max(x0, min(x1, w)) y1 = max(y0, min(y1, h)) - return MultiCameraController.apply_crop(frame, (x0, y0, x1, y1)) + return SingleCameraWorker.apply_crop(frame, (x0, y0, x1, y1)) def resize_to_fit(frame, max_w=400, max_h=300): diff --git a/dlclivegui/gui/camera_config/trigger_config_dialog.py b/dlclivegui/gui/camera_config/trigger_config_dialog.py new file mode 100644 index 000000000..d8337e396 --- /dev/null +++ b/dlclivegui/gui/camera_config/trigger_config_dialog.py @@ -0,0 +1,468 @@ +# dlclivegui/gui/camera_config/trigger_config_dialog.py +from __future__ import annotations + +from dataclasses import dataclass + +from PySide6.QtWidgets import ( + QCheckBox, + QComboBox, + QDialog, + QDialogButtonBox, + QDoubleSpinBox, + QFormLayout, + QGroupBox, + QLabel, + QLineEdit, + QMessageBox, + QSpinBox, + QVBoxLayout, + QWidget, +) + +from ...config import CameraSettings, CameraTriggerSettings + + +def _backend_namespace(cam: CameraSettings) -> dict: + backend = (cam.backend or "").lower() + if not isinstance(cam.properties, dict): + cam.properties = {} + ns = cam.properties.setdefault(backend, {}) + if not isinstance(ns, dict): + ns = {} + cam.properties[backend] = ns + return ns + + +@dataclass(frozen=True) +class TriggerUiProfile: + supports_input: bool = True + supports_master: bool = False + supports_software: bool = False + + show_strobe_fields: bool = False + show_line_output_fields: bool = False + + source_suggestions: tuple[str, ...] = ("auto",) + default_source: str = "auto" + + default_output_line: str = "Line2" + default_output_source: str = "ExposureActive" + + help_text: str = "" + + +def trigger_ui_profile_for_backend(backend: str) -> TriggerUiProfile: + """Return GUI-only trigger presentation profile for a backend. + + This intentionally does not perform backend/runtime validation. + Backends still own actual GenICam/pypylon/Harvesters configuration. + """ + backend = (backend or "").lower() + + if backend == "gentl": + return TriggerUiProfile( + supports_input=True, + supports_master=True, + supports_software=False, + show_strobe_fields=True, + show_line_output_fields=True, + source_suggestions=("auto", "Line0", "Line1", "Line2", "Any", "Software"), + default_source="auto", + default_output_line="Line2", + default_output_source="ExposureActive", + help_text=( + "GenTL trigger support is best-effort and depends on the camera's GenICam nodes. " + "Some cameras expose generic Line* output nodes; TIS/DMK 37U cameras may expose Strobe* nodes." + ), + ) + + if backend == "basler": + return TriggerUiProfile( + supports_input=True, + supports_master=True, + supports_software=False, # enable later when controller supports trigger_once() + show_strobe_fields=False, + show_line_output_fields=True, + source_suggestions=("auto", "Line1", "Line2", "Line3", "Line4", "Software"), + default_source="auto", + default_output_line="Line2", + default_output_source="ExposureActive", + help_text=( + "Basler trigger support uses pylon camera features when available. " + "The available trigger sources and output lines depend on the camera model." + ), + ) + + return TriggerUiProfile( + supports_input=False, + supports_master=False, + supports_software=False, + show_strobe_fields=False, + show_line_output_fields=False, + source_suggestions=("auto",), + help_text="This backend does not expose trigger configuration.", + ) + + +class TriggerConfigDialog(QDialog): + """Dialog for editing per-camera trigger settings. + + The dialog is backend-aware only for presentation. + Actual trigger configuration remains backend-owned. + """ + + def __init__(self, cam: CameraSettings, parent: QWidget | None = None): + super().__init__(parent) + self.setWindowTitle("Configure trigger mode") + self.setMinimumWidth(460) + + self._cam = cam.model_copy(deep=True) + self._backend = (self._cam.backend or "").lower() + self._profile = trigger_ui_profile_for_backend(self._backend) + + ns = _backend_namespace(self._cam) + try: + self._trigger = CameraTriggerSettings.from_any(ns.get("trigger")) + except Exception: + self._trigger = CameraTriggerSettings() + + self._setup_ui() + self._load_from_trigger(self._trigger) + self._sync_role_ui() + + @property + def camera_settings(self) -> CameraSettings: + return self._cam + + def _setup_ui(self) -> None: + root = QVBoxLayout(self) + + info_text = ( + "Configure per-camera trigger settings.\n" + "External/follower mode arms the camera and waits for trigger pulses on a selected input source.\n" + "Master mode configures an output signal if the backend/camera exposes compatible output-line features.\n" + "Some fields are backend- or camera-model-specific and may be ignored unless strict mode is enabled.\n" + "In strict mode, unsupported trigger nodes fail camera open." + ) + if self._profile.help_text: + info_text += f"\n\n{self._profile.help_text}" + + self.info_label = QLabel(info_text) + self.info_label.setWordWrap(True) + root.addWidget(self.info_label) + + group_title = f"Trigger Settings ({self._backend or 'unknown'})" + group = QGroupBox(group_title) + self.form = QFormLayout(group) + + # ---------------------------- + # Role + # ---------------------------- + self.role_combo = QComboBox() + self.role_combo.addItem("Off / Free-run", "off") + + if self._profile.supports_input: + self.role_combo.addItem("External trigger", "external") + self.role_combo.addItem("Follower", "follower") + + if self._profile.supports_master: + self.role_combo.addItem("Master output", "master") + + if self._profile.supports_software: + self.role_combo.addItem("Software trigger", "software") + + self.form.addRow("Role:", self.role_combo) + + # ---------------------------- + # Input trigger fields + # ---------------------------- + self.selector_edit = QLineEdit() + self.selector_edit.setPlaceholderText("FrameStart") + self.selector_edit.setToolTip("TriggerSelector value. Most area-scan cameras use FrameStart.") + self.form.addRow("Trigger selector:", self.selector_edit) + + self.source_combo = QComboBox() + self.source_combo.setEditable(True) + for value in self._profile.source_suggestions: + self.source_combo.addItem(value, value) + self.source_combo.setToolTip( + "TriggerSource value. Suggestions are backend defaults only; " + "the backend validates the actual camera-supported values when opening." + ) + if self.source_combo.lineEdit() is not None: + self.source_combo.lineEdit().setPlaceholderText("auto, Line1, Software, ...") + self.form.addRow("Trigger source:", self.source_combo) + + self.activation_combo = QComboBox() + for value in ("RisingEdge", "FallingEdge", "AnyEdge", "LevelHigh", "LevelLow"): + self.activation_combo.addItem(value, value) + self.activation_combo.setToolTip( + "TriggerActivation value. Some software/internal trigger sources may ignore this." + ) + self.form.addRow("Activation:", self.activation_combo) + + # ---------------------------- + # Generic output line fields + # ---------------------------- + self.output_line_edit = QLineEdit() + self.output_line_edit.setPlaceholderText(self._profile.default_output_line) + self.output_line_edit.setToolTip( + "Generic LineSelector value for cameras exposing LineSelector/LineSource. " + "Ignored if the backend/camera does not support generic line output." + ) + self.form.addRow("Output line:", self.output_line_edit) + + self.output_source_edit = QLineEdit() + self.output_source_edit.setPlaceholderText(self._profile.default_output_source) + self.output_source_edit.setToolTip( + "Generic LineSource value for cameras exposing LineSource, e.g. ExposureActive." + ) + self.form.addRow("Output source:", self.output_source_edit) + + # ---------------------------- + # Strobe fields, mainly useful for specific GenTL/TIS devices + # ---------------------------- + self.strobe_polarity_combo = QComboBox() + self.strobe_polarity_combo.addItem("Active high", "ActiveHigh") + self.strobe_polarity_combo.addItem("Active low", "ActiveLow") + self.strobe_polarity_combo.setToolTip( + "Strobe output polarity. Only used by backends/cameras exposing compatible Strobe* nodes." + ) + self.form.addRow("Strobe polarity:", self.strobe_polarity_combo) + + self.strobe_operation_combo = QComboBox() + self.strobe_operation_combo.addItem("Exposure duration", "Exposure") + self.strobe_operation_combo.addItem("Fixed duration", "FixedDuration") + self.strobe_operation_combo.setToolTip( + "Strobe operation. Only used by backends/cameras exposing compatible Strobe* nodes." + ) + self.form.addRow("Strobe operation:", self.strobe_operation_combo) + + self.strobe_duration_spin = QSpinBox() + self.strobe_duration_spin.setRange(0, 32767) + self.strobe_duration_spin.setSingleStep(100) + self.strobe_duration_spin.setSuffix(" µs") + self.strobe_duration_spin.setSpecialValueText("Default") + self.strobe_duration_spin.setToolTip( + "Used only when strobe operation is FixedDuration. 0 means backend/device default." + ) + self.form.addRow("Strobe duration:", self.strobe_duration_spin) + + self.strobe_delay_spin = QSpinBox() + self.strobe_delay_spin.setRange(0, 32767) + self.strobe_delay_spin.setSingleStep(100) + self.strobe_delay_spin.setSuffix(" µs") + self.strobe_delay_spin.setSpecialValueText("Default") + self.strobe_delay_spin.setToolTip("Delay before strobe output. 0 means no explicit delay/device default.") + self.form.addRow("Strobe delay:", self.strobe_delay_spin) + + # ---------------------------- + # Common options + # ---------------------------- + self.timeout_spin = QDoubleSpinBox() + self.timeout_spin.setRange(0.0, 3600.0) + self.timeout_spin.setDecimals(3) + self.timeout_spin.setSingleStep(0.1) + self.timeout_spin.setSpecialValueText("Default") + self.timeout_spin.setToolTip( + "Read/fetch timeout in seconds. The backend may cap individual waits to keep preview shutdown responsive." + ) + self.form.addRow("Read timeout:", self.timeout_spin) + + self.strict_checkbox = QCheckBox("Strict mode") + self.strict_checkbox.setToolTip("If enabled, missing/unsupported trigger features fail camera open.") + self.form.addRow(self.strict_checkbox) + + root.addWidget(group) + + buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + buttons.accepted.connect(self._accept) + buttons.rejected.connect(self.reject) + root.addWidget(buttons) + + self.role_combo.currentIndexChanged.connect(self._sync_role_ui) + self.strobe_operation_combo.currentIndexChanged.connect(self._sync_role_ui) + + # Hide backend-irrelevant rows immediately. + self._apply_profile_visibility() + + # ------------------------------------------------------------------ + # UI helpers + # ------------------------------------------------------------------ + + def _set_form_row_visible(self, widget: QWidget, visible: bool) -> None: + """Hide/show a QFormLayout field and its label.""" + widget.setVisible(visible) + try: + label = self.form.labelForField(widget) + if label is not None: + label.setVisible(visible) + except Exception: + pass + + def _set_combo_text(self, combo: QComboBox, text: str) -> None: + text = str(text or "") + idx = combo.findText(text) + if idx >= 0: + combo.setCurrentIndex(idx) + else: + combo.setCurrentText(text) + + def _combo_text(self, combo: QComboBox, fallback: str) -> str: + text = str(combo.currentText() or "").strip() + return text or fallback + + def _apply_profile_visibility(self) -> None: + """Apply static backend-profile visibility. + + Role-specific enablement is handled separately by _sync_role_ui(). + """ + # Input trigger fields are only meaningful for input/software roles. + self._set_form_row_visible(self.selector_edit, self._profile.supports_input or self._profile.supports_software) + self._set_form_row_visible(self.source_combo, self._profile.supports_input or self._profile.supports_software) + self._set_form_row_visible( + self.activation_combo, + self._profile.supports_input, + ) + + # Output fields depend on backend presentation profile. + self._set_form_row_visible(self.output_line_edit, self._profile.show_line_output_fields) + self._set_form_row_visible(self.output_source_edit, self._profile.show_line_output_fields) + + # Strobe fields should not appear for Basler. + self._set_form_row_visible(self.strobe_polarity_combo, self._profile.show_strobe_fields) + self._set_form_row_visible(self.strobe_operation_combo, self._profile.show_strobe_fields) + self._set_form_row_visible(self.strobe_duration_spin, self._profile.show_strobe_fields) + self._set_form_row_visible(self.strobe_delay_spin, self._profile.show_strobe_fields) + + # ------------------------------------------------------------------ + # Model <-> UI + # ------------------------------------------------------------------ + + def _load_from_trigger(self, trigger: CameraTriggerSettings) -> None: + role = str(getattr(trigger, "role", "off") or "off").lower() + idx = self.role_combo.findData(role) + self.role_combo.setCurrentIndex(idx if idx >= 0 else 0) + + self.selector_edit.setText(str(getattr(trigger, "selector", "FrameStart") or "FrameStart")) + + source = str(getattr(trigger, "source", self._profile.default_source) or self._profile.default_source) + self._set_combo_text(self.source_combo, source) + + activation = str(getattr(trigger, "activation", "RisingEdge") or "RisingEdge") + idx = self.activation_combo.findData(activation) + self.activation_combo.setCurrentIndex(idx if idx >= 0 else 0) + + output_line = str( + getattr(trigger, "output_line", self._profile.default_output_line) or self._profile.default_output_line + ) + self.output_line_edit.setText(output_line) + + output_source = str( + getattr(trigger, "output_source", self._profile.default_output_source) + or self._profile.default_output_source + ) + self.output_source_edit.setText(output_source) + + strobe_polarity = str(getattr(trigger, "strobe_polarity", "ActiveHigh") or "ActiveHigh") + idx = self.strobe_polarity_combo.findData(strobe_polarity) + self.strobe_polarity_combo.setCurrentIndex(idx if idx >= 0 else 0) + + strobe_operation = str(getattr(trigger, "strobe_operation", "Exposure") or "Exposure") + idx = self.strobe_operation_combo.findData(strobe_operation) + self.strobe_operation_combo.setCurrentIndex(idx if idx >= 0 else 0) + + strobe_duration = getattr(trigger, "strobe_duration", None) + self.strobe_duration_spin.setValue(int(strobe_duration) if strobe_duration is not None else 0) + + strobe_delay = getattr(trigger, "strobe_delay", None) + self.strobe_delay_spin.setValue(int(strobe_delay) if strobe_delay is not None else 0) + + timeout = getattr(trigger, "timeout", None) + self.timeout_spin.setValue(float(timeout) if timeout else 0.0) + + self.strict_checkbox.setChecked(bool(getattr(trigger, "strict", False))) + + def _sync_role_ui(self) -> None: + role = str(self.role_combo.currentData() or "off") + + input_enabled = role in {"external", "follower", "software"} + hw_input_enabled = role in {"external", "follower"} + output_enabled = role == "master" + + # Input fields. + self.selector_edit.setEnabled(input_enabled) + self.source_combo.setEnabled(input_enabled) + self.activation_combo.setEnabled(hw_input_enabled) + + # Generic Line* output fields. + line_output_active = output_enabled and self._profile.show_line_output_fields + self.output_line_edit.setEnabled(line_output_active) + self.output_source_edit.setEnabled(line_output_active) + + # Strobe fields. + strobe_active = output_enabled and self._profile.show_strobe_fields + self.strobe_polarity_combo.setEnabled(strobe_active) + self.strobe_operation_combo.setEnabled(strobe_active) + + fixed_duration = ( + strobe_active and str(self.strobe_operation_combo.currentData() or "Exposure") == "FixedDuration" + ) + self.strobe_duration_spin.setEnabled(fixed_duration) + self.strobe_delay_spin.setEnabled(strobe_active) + + # Timeout is useful for trigger-waiting modes. Keep it available for + # software too if software support is later enabled. + self.timeout_spin.setEnabled(role in {"external", "follower", "software"}) + + def _accept(self) -> None: + role = str(self.role_combo.currentData() or "off") + + payload = { + "role": role, + "selector": self.selector_edit.text().strip() or "FrameStart", + "source": self._combo_text(self.source_combo, self._profile.default_source), + "activation": str(self.activation_combo.currentData() or "RisingEdge"), + "output_line": self.output_line_edit.text().strip() or self._profile.default_output_line, + "output_source": self.output_source_edit.text().strip() or self._profile.default_output_source, + "strict": bool(self.strict_checkbox.isChecked()), + } + + timeout = float(self.timeout_spin.value()) + if role in {"external", "follower", "software"} and timeout > 0: + payload["timeout"] = timeout + elif role == "off": + payload["timeout"] = None + + # Only include strobe-specific settings for profiles that expose them. + # This avoids cluttering Basler trigger configs with TIS-specific fields. + if self._profile.show_strobe_fields: + payload["strobe_polarity"] = str(self.strobe_polarity_combo.currentData() or "ActiveHigh") + payload["strobe_operation"] = str(self.strobe_operation_combo.currentData() or "Exposure") + + strobe_duration = int(self.strobe_duration_spin.value()) + if role == "master" and strobe_duration > 0: + payload["strobe_duration"] = strobe_duration + + strobe_delay = int(self.strobe_delay_spin.value()) + if role == "master" and strobe_delay > 0: + payload["strobe_delay"] = strobe_delay + + try: + trigger = CameraTriggerSettings.from_any(payload) + except Exception as exc: + QMessageBox.critical(self, "Error", f"Failed to apply trigger settings: {exc}") + return + + ns = _backend_namespace(self._cam) + trigger_props = trigger.to_properties() + + if not self._profile.show_strobe_fields: + trigger_props.pop("strobe_polarity", None) + trigger_props.pop("strobe_operation", None) + trigger_props.pop("strobe_duration", None) + trigger_props.pop("strobe_delay", None) + + ns["trigger"] = trigger_props + + self.accept() diff --git a/dlclivegui/gui/camera_config/ui_blocks.py b/dlclivegui/gui/camera_config/ui_blocks.py index 86e4f19d6..4c912753b 100644 --- a/dlclivegui/gui/camera_config/ui_blocks.py +++ b/dlclivegui/gui/camera_config/ui_blocks.py @@ -276,7 +276,7 @@ def build_settings_group(dlg: CameraConfigDialog) -> QGroupBox: ) dlg.settings_form.addRow(detected_row) - # --- Requested resolution controls (Auto = 0) --- + # --- Requested resolution/output format controls (Auto = 0) --- dlg.cam_width = QSpinBox() dlg.cam_width.setRange(0, 10000) dlg.cam_width.setValue(0) @@ -290,6 +290,24 @@ def build_settings_group(dlg: CameraConfigDialog) -> QGroupBox: res_row = make_two_field_row("W", dlg.cam_width, "H", dlg.cam_height, key_width=30) dlg.settings_form.addRow("Resolution:", res_row) + # --- Output format controls --- + dlg.cam_preserve_mono_checkbox = QCheckBox("Preserve mono output") + dlg.cam_preserve_mono_checkbox.setToolTip( + "For monochrome cameras, keep frames as single-channel Mono8 instead of converting to BGR. " + "This reduces memory bandwidth and recording overhead. Display/overlay/DLC may convert later if needed." + ) + + dlg.detected_output_format_label = QLabel("—") + dlg.detected_output_format_label.setTextInteractionFlags(Qt.TextSelectableByMouse) + dlg.detected_output_format_label.setToolTip( + "Backend-reported output frame format emitted to the app, for example Mono8 or BGR8." + ) + + output_row = make_two_field_row( + None, dlg.cam_preserve_mono_checkbox, "Detected:", dlg.detected_output_format_label, key_width=60, gap=40 + ) + dlg.settings_form.addRow("Output:", output_row) + # --- FPS + Rotation grouped --- dlg.cam_fps = QDoubleSpinBox() dlg.cam_fps.setRange(0.0, 240.0) @@ -354,6 +372,13 @@ def build_settings_group(dlg: CameraConfigDialog) -> QGroupBox: dlg.settings_form.addRow("Crop:", crop_widget) + # --- Trigger settings button --- + dlg.trigger_settings_btn = QPushButton("Trigger Settings…") + dlg.trigger_settings_btn.setIcon(dlg.style().standardIcon(QStyle.StandardPixmap.SP_FileDialogDetailedView)) + dlg.trigger_settings_btn.setEnabled(False) + dlg.trigger_settings_btn.setToolTip("Configure hardware trigger / GPIO sync settings for this camera.") + dlg.settings_form.addRow("Sync:", dlg.trigger_settings_btn) + # Apply/Reset buttons row dlg.apply_settings_btn = QPushButton("Apply Settings") dlg.apply_settings_btn.setIcon(dlg.style().standardIcon(QStyle.StandardPixmap.SP_DialogApplyButton)) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index caf0937e2..c9fb4fc19 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -6,16 +6,13 @@ import json import logging import os +import threading import time from pathlib import Path -# NOTE @C-Achard: his could be added in settings eventually -# Forces pypylon to create 2 emulation virtual cameras, -# mostly for testing. This shold not be enabled for release. -# os.environ["PYLON_CAMEMU"] = "2" import cv2 import numpy as np -from PySide6.QtCore import QRect, QSettings, Qt, QTimer, QUrl +from PySide6.QtCore import QRect, QSettings, Qt, QTimer, QUrl, Signal from PySide6.QtGui import ( QAction, QActionGroup, @@ -52,7 +49,11 @@ from dlclivegui.cameras import CameraFactory from dlclivegui.config import ( + ALLOWED_VIDEO_CONTAINERS, DEFAULT_CONFIG, + GUI_MAX_DISPLAY_FPS, + DEFAULT_RECORDING_CONTAINER, + DLC_DO_LOG_TIMING, ApplicationSettings, BoundingBoxSettings, CameraSettings, @@ -63,23 +64,26 @@ ) from ..processors.processor_utils import ( + create_spec_from_scan, default_processors_dir, instantiate_from_scan, + log_processor_context, + processor_builds_in_worker, scan_processor_folder, scan_processor_package, ) from ..services.dlc_processor import DLCLiveProcessor, PoseResult -from ..services.multi_camera_controller import MultiCameraController, MultiFrameData, get_camera_id +from ..services.multi_camera_controller import MultiCameraController, MultiFrameData, get_camera_id, get_display_id +from ..services.recording_manager import RecordingManager from ..utils.display import BBoxColors, compute_tile_info, create_tiled_frame, draw_bbox, draw_pose from ..utils.settings_store import DLCLiveGUISettingsStore, ModelPathStore -from ..utils.stats import format_dlc_stats +from ..utils.stats import WorkerTimingStats, format_dlc_stats from ..utils.utils import FPSTracker from .camera_config.camera_config_dialog import CameraConfigDialog from .misc import color_dropdowns as color_ui from .misc import layouts as lyts from .misc.drag_spinbox import ScrubSpinBox from .misc.eliding_label import ElidingPathLabel -from .recording_manager import RecordingManager from .theme import LOGO, LOGO_ALPHA, AppStyle, apply_theme logger = logging.getLogger("DLCLiveGUI") @@ -88,6 +92,8 @@ class DLCLiveMainWindow(QMainWindow): """Main application window.""" + _recording_stopped_async = Signal() + def __init__(self, config: ApplicationSettings | None = None): super().__init__() self.setWindowTitle("DeepLabCut Live GUI") @@ -96,13 +102,19 @@ def __init__(self, config: ApplicationSettings | None = None): self._model_path_store = ModelPathStore(self.settings) self._settings_store = DLCLiveGUISettingsStore(self.settings) + last_cfg_path = self._settings_store.get_last_config_path() + last_cfg_file = self._valid_config_file_path(last_cfg_path) if config is None: # 1) snapshot cfg = self._settings_store.load_full_config_snapshot() if cfg is not None: config = cfg - self._config_path = None - logger.info("Loaded configuration from QSettings snapshot.") + self._config_path = last_cfg_file + if self._config_path is not None: + logger.info(f"Loaded configuration from QSettings snapshot; associated file: {self._config_path}") + else: + logger.info("Loaded configuration from QSettings snapshot without associated config file.") + else: # 2) last config file path last_cfg_path = self._settings_store.get_last_config_path() @@ -133,14 +145,20 @@ def __init__(self, config: ApplicationSettings | None = None): self._rec_manager = RecordingManager() self._dlc = DLCLiveProcessor() self.multi_camera_controller = MultiCameraController() + ### Time debug + self._dlc_timing = WorkerTimingStats( + "GUI - DLC Worker", logger=logger, log_interval=2.0, enabled=DLC_DO_LOG_TIMING + ) self._config = config - self._inference_camera_id: str | None = None # Camera ID used for inference + self._inference_camera_id: str | None = self._settings_store.get_inference_camera_id() + self._active_inference_camera_id: str | None = None self._running_cams_ids: set[str] = set() self._current_frame: np.ndarray | None = None self._raw_frame: np.ndarray | None = None self._last_pose: PoseResult | None = None self._dlc_active: bool = False + self._pending_recording_after_preview = False self._active_camera_settings: CameraSettings | None = None self._last_drop_warning = 0.0 self._last_recorder_summary = "Recorder idle" @@ -168,11 +186,15 @@ def __init__(self, config: ApplicationSettings | None = None): # Multi-camera state self._multi_camera_mode = False self._multi_camera_frames: dict[str, np.ndarray] = {} + self._multi_camera_display_ids: dict[str, str] = {} # camera_id -> display_id (for labeling) # DLC pose rendering info for tiled view self._dlc_tile_offset: tuple[int, int] = (0, 0) # (x, y) offset in tiled frame self._dlc_tile_scale: tuple[float, float] = (1.0, 1.0) # (scale_x, scale_y) # Display flag (decoupled from frame capture for performance) self._display_dirty: bool = False + # Recording state + self._recording_stopping = False + self._recording_stopped_async.connect(self._on_recording_stopped_async) self._load_icons() self._preview_pixmap = QPixmap(LOGO_ALPHA) @@ -190,7 +212,7 @@ def __init__(self, config: ApplicationSettings | None = None): # Display timer - decoupled from frame capture for performance self._display_timer = QTimer(self) - self._display_timer.setInterval(33) # ~30 fps display rate + self._display_timer.setInterval(1000 / GUI_MAX_DISPLAY_FPS) # in ms, ~30 fps display rate self._display_timer.timeout.connect(self._update_display_from_pending) self._display_timer.start() @@ -213,6 +235,19 @@ def resizeEvent(self, event): if not self.multi_camera_controller.is_running(): self._show_logo_and_text() + def _valid_config_file_path(self, path: str | None) -> Path | None: + if not path: + return None + + try: + p = Path(path).expanduser() + if p.exists() and p.is_file(): + return p.resolve() + except Exception: + logger.debug("Invalid config file path: %s", path, exc_info=True) + + return None + # ------------------------------------------------------------------ UI def _init_theme_actions(self) -> None: """Set initial checked state for theme actions based on current app stylesheet.""" @@ -259,32 +294,46 @@ def _setup_ui(self) -> None: for lbl in (self.camera_stats_label, self.dlc_stats_label, self.recording_stats_label): lbl.setTextInteractionFlags(Qt.TextSelectableByMouse) - # Controls panel with fixed width to prevent shifting - controls_widget = QWidget() - # controls_widget.setMaximumWidth(500) - controls_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) - controls_layout = QVBoxLayout(controls_widget) - controls_layout.setContentsMargins(5, 5, 5, 5) + # Controls panel content + controls_content_widget = QWidget() + controls_content_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) + + controls_layout = QVBoxLayout(controls_content_widget) + controls_layout.setContentsMargins(5, 5, 5, 0) controls_layout.addWidget(self._build_camera_group()) controls_layout.addWidget(self._build_dlc_group()) controls_layout.addWidget(self._build_recording_group()) controls_layout.addWidget(self._build_viz_group()) - # Preview/Stop buttons at bottom of controls - wrap in widget + # Preview/Stop buttons stay outside the scroll area as a fixed footer button_bar_widget = QWidget() + button_bar_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + button_bar = QHBoxLayout(button_bar_widget) button_bar.setContentsMargins(0, 5, 0, 5) + self.preview_button = QPushButton("Start Preview") self.preview_button.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MediaPlay)) self.preview_button.setMinimumWidth(150) + self.stop_preview_button = QPushButton("Stop Preview") self.stop_preview_button.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MediaStop)) self.stop_preview_button.setEnabled(False) self.stop_preview_button.setMinimumWidth(150) + button_bar.addWidget(self.preview_button) button_bar.addWidget(self.stop_preview_button) - controls_layout.addWidget(button_bar_widget) - controls_layout.addStretch(1) + + controls_widget = lyts.make_scrollable_with_fixed_footer( + controls_content_widget, + button_bar_widget, + object_name="ControlsPanel", + scroll_object_name="ControlsScrollArea", + footer_object_name="ControlsFooter", + margins=(0, 0, 0, 0), + spacing=0, + footer_margins=(5, 0, 5, 0), + ) # Add controls and video panel to main layout ## Dock widget for controls @@ -447,7 +496,7 @@ def _build_dlc_group(self) -> QGroupBox: # Processor selection processor_path_layout = QHBoxLayout() self.processor_folder_edit = QLineEdit() - self.processor_folder_edit.setText(default_processors_dir()) + self.processor_folder_edit.setText(self._settings_store.get_processor_folder(default=default_processors_dir())) processor_path_layout.addWidget(self.processor_folder_edit) self.browse_processor_folder_button = QPushButton("Browse...") @@ -595,7 +644,7 @@ def _build_recording_group(self) -> QGroupBox: self.container_combo.setToolTip("Select the video container/format") self.container_combo.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Preferred) self.container_combo.setEditable(True) - self.container_combo.addItems(["mp4", "avi", "mov"]) + self.container_combo.addItems(sorted(ALLOWED_VIDEO_CONTAINERS)) # Ensure it never becomes unreadable: self.container_combo.setMinimumContentsLength(8) self.container_combo.setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon) @@ -635,13 +684,29 @@ def _build_recording_group(self) -> QGroupBox: form.addRow(grid) - # Record with overlays - self.record_with_overlays_checkbox = QCheckBox("Record video with overlays") - self.record_with_overlays_checkbox.setToolTip( - "Enable to include pose overlays in recorded video (keypoints & bounding boxes)" + # Recording options + # self.record_with_overlays_checkbox = QCheckBox("Record video with overlays") + # self.record_with_overlays_checkbox.setToolTip( + # "Enable to include pose overlays in recorded video (keypoints & bounding boxes)" + # ) + # self.record_with_overlays_checkbox.setChecked(False) + + self.fast_encoding_checkbox = QCheckBox("Use faster encoding parameters") + self.fast_encoding_checkbox.setToolTip( + "Use faster FFmpeg parameters for supported codecs.\n" + "For libx264/libx265 this uses preset=ultrafast and tune=zerolatency.\n" + "This can improve recording throughput but may increase file size." ) - self.record_with_overlays_checkbox.setChecked(False) - form.addRow(self.record_with_overlays_checkbox) + self.fast_encoding_checkbox.setChecked(False) + + recording_options = QWidget() + recording_options_layout = QHBoxLayout(recording_options) + recording_options_layout.setContentsMargins(0, 0, 0, 0) + # recording_options_layout.addWidget(self.record_with_overlays_checkbox) + recording_options_layout.addWidget(self.fast_encoding_checkbox) + recording_options_layout.addStretch(1) + + form.addRow(recording_options) # Wrap recording buttons in a widget to prevent shifting recording_button_widget = QWidget() @@ -772,7 +837,9 @@ 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.recording_frame_ready.connect(self._on_recording_frame_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) @@ -784,19 +851,17 @@ def _connect_signals(self) -> None: self._dlc.initialized.connect(self._on_dlc_initialised) self.dlc_camera_combo.currentIndexChanged.connect(self._on_dlc_camera_changed) self.dlc_camera_combo.currentTextChanged.connect(self.dlc_camera_combo.update_shrink_width) + self.processor_combo.currentIndexChanged.connect(self._on_processor_changed) + self.allow_processor_ctrl_checkbox.stateChanged.connect(self._on_processor_control_changed) # Recording settings ## Session name persistence + preview updates - if hasattr(self, "session_name_edit"): - self.session_name_edit.editingFinished.connect(self._on_session_name_editing_finished) - if hasattr(self, "use_timestamp_checkbox"): - self.use_timestamp_checkbox.stateChanged.connect(self._on_use_timestamp_changed) - if hasattr(self, "output_directory_edit"): - self.output_directory_edit.textChanged.connect(lambda _t: self._update_recording_path_preview()) - if hasattr(self, "filename_edit"): - self.filename_edit.textChanged.connect(lambda _t: self._update_recording_path_preview()) - if hasattr(self, "container_combo"): - self.container_combo.currentTextChanged.connect(lambda _t: self._update_recording_path_preview()) + self.session_name_edit.editingFinished.connect(self._on_session_name_editing_finished) + self.use_timestamp_checkbox.stateChanged.connect(self._on_use_timestamp_changed) + self.output_directory_edit.textChanged.connect(lambda _t: self._update_recording_path_preview()) + self.filename_edit.textChanged.connect(lambda _t: self._update_recording_path_preview()) + self.container_combo.currentTextChanged.connect(self._on_container_changed) + self.fast_encoding_checkbox.stateChanged.connect(self._on_fast_encoding_changed) # ------------------------------------------------------------------ # Config @@ -823,6 +888,11 @@ def _apply_config(self, config: ApplicationSettings) -> None: self.codec_combo.addItem(recording.codec) self.codec_combo.setCurrentIndex(self.codec_combo.count() - 1) self.crf_spin.setValue(int(recording.crf)) + + if hasattr(self, "fast_encoding_checkbox"): + config_fast_encoding = bool(getattr(recording, "fast_encoding", False)) + self.fast_encoding_checkbox.setChecked(self._settings_store.get_fast_encoding(default=config_fast_encoding)) + ## Restore persisted session name if empty if hasattr(self, "session_name_edit"): if not self.session_name_edit.text().strip(): @@ -851,21 +921,36 @@ def _apply_config(self, config: ApplicationSettings) -> None: if hasattr(self, "bbox_color_combo"): color_ui.set_bbox_combo_from_bgr(self.bbox_color_combo, self._bbox_color) + # Processor + ## Allow processor control checkbox state + if hasattr(self, "allow_processor_ctrl_checkbox"): + self.allow_processor_ctrl_checkbox.setChecked( + self._settings_store.get_processor_control_enabled(default=False) + ) + # Update DLC camera list self._refresh_dlc_camera_list() # Update recording path preview self._update_recording_path_preview() - def _current_config(self) -> ApplicationSettings: - # Get the first camera from multi-camera config for backward compatibility - active_cameras = self._config.multi_camera.get_active_cameras() - camera = active_cameras[0] if active_cameras else CameraSettings() + def _current_config(self, *, allow_empty_model_path=False) -> ApplicationSettings: + multi_camera = self._config.multi_camera + active_cameras = multi_camera.get_active_cameras() + camera = ( + active_cameras[0].model_copy(deep=True) + if active_cameras + else ( + multi_camera.cameras[0].model_copy(deep=True) + if multi_camera.cameras + else self._config.camera.model_copy(deep=True) + ) + ) return ApplicationSettings( camera=camera, - multi_camera=self._config.multi_camera, - dlc=self._dlc_settings_from_ui(), + multi_camera=multi_camera, + dlc=self._dlc_settings_from_ui(allow_empty_model_path=allow_empty_model_path), recording=self._recording_settings_from_ui(), bbox=self._bbox_settings_from_ui(), visualization=self._visualization_settings_from_ui(), @@ -877,14 +962,29 @@ def _parse_json(self, value: str) -> dict: return {} return json.loads(text) - def _dlc_settings_from_ui(self) -> DLCProcessorSettings: + def _dlc_settings_from_ui(self, *, allow_empty_model_path=False) -> DLCProcessorSettings: model_path = self.model_path_edit.text().strip() if Path(model_path).exists() and Path(model_path).suffix == ".pb": # IMPORTANT NOTE: DLClive expects a directory for TensorFlow models, # so if user selects a .pb file, we should pass the parent directory to DLCLive model_path = str(Path(model_path).parent) - if model_path == "": + + existing_dlc = ( # explicitly init from default if unset + self._config.dlc.model_copy(deep=True) + if getattr(self._config, "dlc", None) is not None + else DEFAULT_CONFIG.dlc.model_copy(deep=True) + ) + if not model_path: + if allow_empty_model_path: + # Preserve all existing DLC settings and only clear the model path. + return existing_dlc.model_copy( + update={ + "model_path": "", + } + ) + raise ValueError("Model path cannot be empty. Please enter a valid path to a DLCLive model file.") + try: model_bknd = DLCLiveProcessor.get_model_backend(model_path) except Exception as e: @@ -893,25 +993,28 @@ def _dlc_settings_from_ui(self) -> DLCProcessorSettings: "Please ensure the model file is valid and has an appropriate extension " "(.pt, .pth for PyTorch or model directory for TensorFlow)." ) from e - return DLCProcessorSettings( - model_path=model_path, - model_directory=self._config.dlc.model_directory, # Preserve from config - device=self._config.dlc.device, # Preserve from config - dynamic=self._config.dlc.dynamic, # Preserve from config - resize=self._config.dlc.resize, # Preserve from config - precision=self._config.dlc.precision, # Preserve from config - model_type=model_bknd, - # additional_options=self._parse_json(self.additional_options_edit.toPlainText()), + + # Preserve all unchanged DLC settings and only update values derived from the UI. + return existing_dlc.model_copy( + update={ + "model_path": model_path, + "model_type": model_bknd, + } ) def _recording_settings_from_ui(self) -> RecordingSettings: + container = self.container_combo.currentText().strip() or DEFAULT_RECORDING_CONTAINER + filename = self._filename_matching_container(self.filename_edit.text().strip(), container) return RecordingSettings( enabled=True, # Always enabled - recording controlled by button directory=self.output_directory_edit.text().strip(), - filename=self.filename_edit.text().strip() or "session.mp4", - container=self.container_combo.currentText().strip() or "mp4", + filename=filename, + container=container, codec=self.codec_combo.currentText().strip() or "libx264", crf=int(self.crf_spin.value()), + fast_encoding=bool( + getattr(self, "fast_encoding_checkbox", None) and self.fast_encoding_checkbox.isChecked() + ), ) def _bbox_settings_from_ui(self) -> BoundingBoxSettings: @@ -930,10 +1033,36 @@ def _visualization_settings_from_ui(self) -> VisualizationSettings: bbox_color=self._bbox_color, ) + def _suggest_config_dialog_path(self) -> str: + """Return best initial path for load/save config dialogs.""" + if getattr(self, "_config_path", None) is not None: + try: + return str(self._config_path) + except Exception: + pass + + last_cfg = self._settings_store.get_last_config_path() + valid_last = self._valid_config_file_path(last_cfg) + if valid_last is not None: + return str(valid_last) + + if last_cfg: + try: + p = Path(last_cfg).expanduser() + parent = p.parent + if parent.exists() and parent.is_dir(): + return str(parent / (p.name or "config.json")) + except Exception: + logger.debug("Failed to derive config dialog path from %s", last_cfg, exc_info=True) + + return str(Path.home() / "config.json") + # ------------------------------------------------------------------ # Actions def _action_load_config(self) -> None: - file_name, _ = QFileDialog.getOpenFileName(self, "Load configuration", str(Path.home()), "JSON files (*.json)") + file_name, _ = QFileDialog.getOpenFileName( + self, "Load configuration", self._suggest_config_dialog_path(), "JSON files (*.json)" + ) if not file_name: return try: @@ -943,6 +1072,12 @@ def _action_load_config(self) -> None: return self._settings_store.set_last_config_path(file_name) self._settings_store.save_full_config_snapshot(config) + + try: + self.settings.sync() + except Exception: + logger.debug("Failed to sync settings after loading config", exc_info=True) + self._config = config self._config_path = Path(file_name) self._apply_config(config) @@ -954,28 +1089,38 @@ def _action_save_config(self) -> None: if self._config_path is None: self._action_save_config_as() return - self._save_config_to_path(self._config_path) + if self._save_config_to_path(self._config_path): + self._config_path = self._config_path.expanduser() def _action_save_config_as(self) -> None: - file_name, _ = QFileDialog.getSaveFileName(self, "Save configuration", str(Path.home()), "JSON files (*.json)") + file_name, _ = QFileDialog.getSaveFileName( + self, "Save configuration", self._suggest_config_dialog_path(), "JSON files (*.json)" + ) if not file_name: return - path = Path(file_name) + path = Path(file_name).expanduser() if path.suffix.lower() != ".json": path = path.with_suffix(".json") - self._config_path = path - self._save_config_to_path(path) + if self._save_config_to_path(path): + self._config_path = path - def _save_config_to_path(self, path: Path) -> None: + def _save_config_to_path(self, path: Path) -> bool: try: - config = self._current_config() + config = self._current_config(allow_empty_model_path=True) config.save(path) self._settings_store.set_last_config_path(str(path)) self._settings_store.save_full_config_snapshot(config) + + try: + self.settings.sync() + except Exception: + logger.debug("Failed to sync settings after saving config", exc_info=True) + except Exception as exc: # pragma: no cover - GUI interaction self._show_error(str(exc)) - return + return False self.statusBar().showMessage(f"Saved configuration to {path}", 5000) + return True def _action_browse_model(self) -> None: # Prefer persisted last-used directory, then config.dlc.model_directory, then home @@ -1038,10 +1183,11 @@ def _action_browse_directory(self) -> None: def _action_browse_processor_folder(self) -> None: """Browse for processor folder.""" - current_path = self.processor_folder_edit.text() or default_processors_dir() + current_path = self.processor_folder_edit.text().strip() or default_processors_dir() directory = QFileDialog.getExistingDirectory(self, "Select processor folder", current_path) if directory: self.processor_folder_edit.setText(directory) + self._settings_store.set_processor_folder(directory) self._refresh_processors() def _action_open_recording_folder(self) -> None: @@ -1090,15 +1236,46 @@ def _processor_control_enabled(self) -> bool: getattr(self, "allow_processor_ctrl_checkbox", None) and self.allow_processor_ctrl_checkbox.isChecked() ) + def _on_processor_changed(self, _index: int) -> None: + """Persist selected processor key when the user changes the processor combo.""" + processor_key = self.processor_combo.currentData() + self._settings_store.set_processor_key(processor_key) + + if hasattr(self.processor_combo, "update_shrink_width"): + self.processor_combo.update_shrink_width() + + def _on_processor_control_changed(self, _state: int) -> None: + """Persist processor-control checkbox state and refresh dependent UI.""" + enabled = self.allow_processor_ctrl_checkbox.isChecked() + self._settings_store.set_processor_control_enabled(enabled) + + self._update_dlc_controls_enabled() + self._update_processor_status() + def _refresh_processors(self) -> None: + """Scan processors and restore the last selected processor best-effort.""" + previous_key = None + if hasattr(self, "processor_combo"): + previous_key = self.processor_combo.currentData() + + preferred_key = previous_key or self._settings_store.get_processor_key() + + self.processor_combo.blockSignals(True) self.processor_combo.clear() self.processor_combo.addItem("No Processor", None) selected_folder = self.processor_folder_edit.text().strip() - if Path(selected_folder).exists(): - self._scanned_processors = scan_processor_folder(selected_folder) + selected_path = Path(selected_folder).expanduser() if selected_folder else None + + if selected_path is not None and selected_path.is_dir(): + resolved_folder = str(selected_path.resolve()) + self._settings_store.set_processor_folder(resolved_folder) + self._scanned_processors = scan_processor_folder(resolved_folder) + source_text = resolved_folder else: self._scanned_processors = scan_processor_package("dlclivegui.processors") + source_text = "package dlclivegui.processors" + self._processor_keys = list(self._scanned_processors.keys()) for key in self._processor_keys: @@ -1106,38 +1283,72 @@ def _refresh_processors(self) -> None: display_name = f"{info['name']} ({info['file']})" self.processor_combo.addItem(display_name, key) + # Restore selected processor best-effort. + if preferred_key is not None: + idx = self.processor_combo.findData(preferred_key) + if idx >= 0: + self.processor_combo.setCurrentIndex(idx) + else: + self.processor_combo.setCurrentIndex(0) + else: + self.processor_combo.setCurrentIndex(0) + + self.processor_combo.blockSignals(False) self.processor_combo.update_shrink_width() + self.statusBar().showMessage( - f"Found {len(self._processor_keys)} processor(s) in package dlclivegui.processors", 3000 + f"Found {len(self._processor_keys)} processor(s) in {source_text}", + 3000, ) # ------------------------------------------------------------------ # Recording path preview and session name persistence + def _known_recording_extensions(self) -> set[str]: + """Return known recording container extensions without leading dots.""" + known = ALLOWED_VIDEO_CONTAINERS.copy() + if hasattr(self, "container_combo"): + known.update( + self.container_combo.itemText(i).strip().lower().lstrip(".") + for i in range(self.container_combo.count()) + if self.container_combo.itemText(i).strip() + ) + return known + + def _filename_matching_container(self, filename: str, container: str) -> str: + """ + Adjust filename extension to match selected container, but only when + the existing extension is another known recording container. + """ + name = filename.strip() or "recording" + selected_ext = container.strip().lower().lstrip(".") + suffix = Path(name).suffix + + if not suffix or not selected_ext: + return name + + current_ext = suffix.lower().lstrip(".") + if current_ext in self._known_recording_extensions() and current_ext != selected_ext: + return str(Path(name).with_suffix(f".{selected_ext}")) + + return name + + def _on_container_changed(self, text: str) -> None: + """Keep filename extension aligned with selected container when safe.""" + if hasattr(self, "filename_edit"): + current = self.filename_edit.text() + updated = self._filename_matching_container(current, text) + if updated != current: + self.filename_edit.blockSignals(True) + self.filename_edit.setText(updated) + self.filename_edit.blockSignals(False) + + self._update_recording_path_preview() + def _on_session_name_editing_finished(self) -> None: name = self.session_name_edit.text().strip() self._settings_store.set_session_name(name) self._update_recording_path_preview() - # def _update_recording_path_preview(self) -> None: - # """Update the label showing where files will go (best-effort).""" - # if not hasattr(self, "recording_path_preview"): - # return - # out_dir = self.output_directory_edit.text().strip() - # sess = self.session_name_edit.text().strip() if hasattr(self, "session_name_edit") else "" - # base = self.filename_edit.text().strip() - # container = self.container_combo.currentText().strip() if hasattr(self, "container_combo") else "mp4" - # use_ts = self.use_timestamp_checkbox.isChecked() if hasattr(self, "use_timestamp_checkbox") else True - - # # Preview is approximate (since run index/time is decided at start). - # sess_safe = sess.strip() or "session" - # run_hint = "run_" if use_ts else "run_" - # stem_hint = Path(base).stem if base.strip() else "recording" # shows user-provided stem or default - # full_hint = str(Path(out_dir).expanduser() / sess_safe / run_hint / f"{stem_hint}_.{container}") - # self.recording_path_preview.setText(f"{full_hint}") - # self.recording_path_preview.setToolTip( - # f"Click to copy to clipboard :
{full_hint.replace('', '*')}" - # ) - def _update_recording_path_preview(self) -> None: """Update the label showing where files will go (best-effort).""" if not hasattr(self, "recording_path_preview"): @@ -1145,8 +1356,12 @@ def _update_recording_path_preview(self) -> None: out_dir = self.output_directory_edit.text().strip() sess = self.session_name_edit.text().strip() if hasattr(self, "session_name_edit") else "" - base = self.filename_edit.text().strip() - container = self.container_combo.currentText().strip() if hasattr(self, "container_combo") else "mp4" + container = ( + self.container_combo.currentText().strip() + if hasattr(self, "container_combo") + else DEFAULT_RECORDING_CONTAINER + ) + base = self._filename_matching_container(self.filename_edit.text(), container) use_ts = self.use_timestamp_checkbox.isChecked() if hasattr(self, "use_timestamp_checkbox") else True # Preview is approximate (since run index/time is decided at start). @@ -1170,6 +1385,9 @@ def _on_use_timestamp_changed(self, _state: int) -> None: self._settings_store.set_use_timestamp(self.use_timestamp_checkbox.isChecked()) self._update_recording_path_preview() + def _on_fast_encoding_changed(self, _state: int) -> None: + self._settings_store.set_fast_encoding(self.fast_encoding_checkbox.isChecked()) + def _on_colormap_changed(self, _index: int) -> None: self._colormap = color_ui.get_cmap_name_from_combo(self.cmap_combo, fallback=self._colormap) if self._current_frame is not None: @@ -1210,16 +1428,20 @@ def _on_multi_camera_settings_changed(self, settings: MultiCameraSettings) -> No self.statusBar().showMessage(f"Camera configuration updated: {active_count} active camera(s)", 3000) def _update_active_cameras_label(self) -> None: - """Update the label showing active cameras.""" active_cams = self._config.multi_camera.get_active_cameras() + if not active_cams: self.active_cameras_label.setText("No cameras configured") - elif len(active_cams) == 1: + return + + if len(active_cams) == 1: cam = active_cams[0] - self.active_cameras_label.setText(f"{cam.name} [{cam.backend}:{cam.index}] @ {cam.fps:.1f} fps") - else: - cam_names = [f"{c.name}" for c in active_cams] - self.active_cameras_label.setText(f"{len(active_cams)} cameras: {', '.join(cam_names)}") + display_id = get_display_id(cam) + self.active_cameras_label.setText(f"{display_id} [{cam.backend}:{cam.index}]") + return + + cam_names = [get_display_id(c) for c in active_cams] + self.active_cameras_label.setText(f"{len(active_cams)} cameras: {', '.join(cam_names)}") def _validate_configured_cameras(self) -> None: """Validate that configured cameras are available. @@ -1261,66 +1483,111 @@ def _validate_configured_cameras(self) -> None: def _label_for_cam_id(self, cam_id: str) -> str: for cam in self._config.multi_camera.get_active_cameras(): if get_camera_id(cam) == cam_id: - return f"{cam.name} [{cam.backend}:{cam.index}]" - return cam_id + display_id = get_display_id(cam) + return f"{display_id} [{cam.backend}:{cam.index}]" + + display_id = self._multi_camera_display_ids.get(cam_id) + if display_id: + return display_id + + return "Unknown camera" def _refresh_dlc_camera_list_running(self) -> None: - """Populate the inference camera dropdown from currently running cameras.""" + """Populate inference camera dropdown from currently running cameras. + + - Keep the user's preferred camera if it is running + - Otherwise use a temporary runtime fallback + - Never persist fallback choices caused by preview/update events + """ + preferred_id = self._inference_camera_id or self._settings_store.get_inference_camera_id() + self.dlc_camera_combo.blockSignals(True) self.dlc_camera_combo.clear() - for cam_id in sorted(self._running_cams_ids): - self.dlc_camera_combo.addItem(self._label_for_cam_id(cam_id), cam_id) - # Keep current selection if still present, else select first running - if self._inference_camera_id in self._running_cams_ids: - idx = self.dlc_camera_combo.findData(self._inference_camera_id) + for cam in self._config.multi_camera.get_active_cameras(): + cam_id = get_camera_id(cam) + if cam_id in self._running_cams_ids: + self.dlc_camera_combo.addItem(self._label_for_cam_id(cam_id), cam_id) + + selected_id = None + + if preferred_id in self._running_cams_ids: + idx = self.dlc_camera_combo.findData(preferred_id) if idx >= 0: self.dlc_camera_combo.setCurrentIndex(idx) - elif self.dlc_camera_combo.count() > 0: + selected_id = preferred_id + + if selected_id is None and self.dlc_camera_combo.count() > 0: self.dlc_camera_combo.setCurrentIndex(0) - self._inference_camera_id = self.dlc_camera_combo.currentData() + selected_id = self.dlc_camera_combo.currentData() + + self._active_inference_camera_id = selected_id + self.dlc_camera_combo.blockSignals(False) + self.dlc_camera_combo.update_shrink_width() - def _set_dlc_combo_to_id(self, cam_id: str) -> None: - """Update combo selection to a given ID without firing signals.""" + def _set_dlc_combo_to_id(self, cam_id: str) -> bool: + """Update combo selection to a given camera ID without firing signals.""" self.dlc_camera_combo.blockSignals(True) - idx = self.dlc_camera_combo.findData(cam_id) - if idx >= 0: - self.dlc_camera_combo.setCurrentIndex(idx) - self.dlc_camera_combo.blockSignals(False) + try: + idx = self.dlc_camera_combo.findData(cam_id) + if idx >= 0: + self.dlc_camera_combo.setCurrentIndex(idx) + return True + return False + finally: + self.dlc_camera_combo.blockSignals(False) + self.dlc_camera_combo.update_shrink_width() def _refresh_dlc_camera_list(self) -> None: - """Populate the inference camera dropdown from active cameras.""" + """Populate inference camera dropdown from configured active cameras.""" + preferred_id = self._inference_camera_id or self._settings_store.get_inference_camera_id() + self.dlc_camera_combo.blockSignals(True) self.dlc_camera_combo.clear() active_cams = self._config.multi_camera.get_active_cameras() for cam in active_cams: - cam_id = get_camera_id(cam) # e.g., "opencv:0" or "pylon:1" - label = f"{cam.name} [{cam.backend}:{cam.index}]" + cam_id = get_camera_id(cam) + display_id = get_display_id(cam) + label = f"{display_id} [{cam.backend}:{cam.index}]" self.dlc_camera_combo.addItem(label, cam_id) - # Keep previous selection if still present, else default to first - if self._inference_camera_id is not None: - idx = self.dlc_camera_combo.findData(self._inference_camera_id) + selected_id = None + + if preferred_id is not None: + idx = self.dlc_camera_combo.findData(preferred_id) if idx >= 0: self.dlc_camera_combo.setCurrentIndex(idx) - elif self.dlc_camera_combo.count() > 0: - self.dlc_camera_combo.setCurrentIndex(0) - self._inference_camera_id = self.dlc_camera_combo.currentData() - else: - if self.dlc_camera_combo.count() > 0: - self.dlc_camera_combo.setCurrentIndex(0) - self._inference_camera_id = self.dlc_camera_combo.currentData() + selected_id = preferred_id + + if selected_id is None and self.dlc_camera_combo.count() > 0: + self.dlc_camera_combo.setCurrentIndex(0) + selected_id = self.dlc_camera_combo.currentData() + + # First-run convenience only. + # If there is no previous preference, initialize one. + if self._inference_camera_id is None and preferred_id is None: + self._inference_camera_id = selected_id + self._settings_store.set_inference_camera_id(selected_id) + + self._active_inference_camera_id = selected_id self.dlc_camera_combo.blockSignals(False) self.dlc_camera_combo.update_shrink_width() def _on_dlc_camera_changed(self, _index: int) -> None: - """Track user selection of the inference camera.""" - self._inference_camera_id = self.dlc_camera_combo.currentData() + """Track explicit user selection of the inference camera.""" + cam_id = self.dlc_camera_combo.currentData() + + self._inference_camera_id = cam_id + self._active_inference_camera_id = cam_id + + self._settings_store.set_inference_camera_id(cam_id) + self.dlc_camera_combo.update_shrink_width() - # Force redraw so bbox/pose overlays switch to the new tile immediately + + # Force redraw so bbox/pose overlays switch to the new tile immediately. if self._current_frame is not None: self._display_frame(self._current_frame, force=True) @@ -1332,7 +1599,7 @@ def _render_overlays_for_recording(self, cam_id, frame): offset, scale = (0, 0), (1.0, 1.0) # If this is the inference camera, apply pose overlays - if cam_id == self._inference_camera_id and self._last_pose and self._last_pose.pose is not None: + if cam_id == self._active_inference_camera_id and self._last_pose and self._last_pose.pose is not None: output = draw_pose( output, self._last_pose.pose, @@ -1351,15 +1618,35 @@ def _render_overlays_for_recording(self, cam_id, frame): ) return output - def _on_multi_frame_ready(self, frame_data: MultiFrameData) -> None: + def _on_recording_frame_ready( + self, camera_id: str, frame: np.ndarray, timestamp: float, timestamp_metadata: object | None = None + ) -> None: + """Handle full-rate per-camera frames for recording only. + + Intentionally lean: + - no MultiFrameData processing + - no DLC routing + - no display state updates + - no FPS tracker + - optional overlays only if user requested recording overlays + """ + if not self._rec_manager.is_active: + return + + # if self.record_with_overlays_checkbox.isChecked(): + # frame = self._render_overlays_for_recording(camera_id, frame) + + self._rec_manager.write_frame(camera_id, frame, timestamp, timestamp_metadata=timestamp_metadata) + + def _on_multi_frame_processing_ready(self, frame_data: MultiFrameData) -> None: """Handle frames from multiple cameras. - Priority order for performance: - 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) + Priority: + - DLC processing (highest priority - enqueue immediately, only for DLC camera) """ self._multi_camera_frames = frame_data.frames + self._multi_camera_display_ids = frame_data.display_ids or {} + self._try_start_pending_recording() src_id = frame_data.source_camera_id if src_id: self._fps_tracker.note_frame(src_id) # Track FPS @@ -1369,25 +1656,30 @@ def _on_multi_frame_ready(self, frame_data: MultiFrameData) -> None: self._running_cams_ids = new_running self._refresh_dlc_camera_list_running() - # Determine DLC camera (first active camera) - selected_id = self._inference_camera_id - available_ids = sorted(frame_data.frames.keys()) - if selected_id in frame_data.frames: - dlc_cam_id = selected_id + preferred_id = self._inference_camera_id + available_ids = list(frame_data.frames.keys()) + + if preferred_id in frame_data.frames: + dlc_cam_id = preferred_id else: dlc_cam_id = available_ids[0] if available_ids else "" + if dlc_cam_id: - self._inference_camera_id = dlc_cam_id - self._set_dlc_combo_to_id(dlc_cam_id) - self.statusBar().showMessage( - f"DLC inference camera changed to {self._label_for_cam_id(dlc_cam_id)}", 3000 - ) - else: # No more cameras available + if self._active_inference_camera_id != dlc_cam_id: + self._active_inference_camera_id = dlc_cam_id + self._set_dlc_combo_to_id(dlc_cam_id) + self.statusBar().showMessage( + f"Using temporary DLC inference camera: {self._label_for_cam_id(dlc_cam_id)}", + 3000, + ) + else: if self._dlc_active: self._stop_inference(show_message=True) self._display_dirty = True return + self._active_inference_camera_id = dlc_cam_id + # Check if this frame is from the DLC camera is_dlc_camera_frame = frame_data.source_camera_id == dlc_cam_id @@ -1397,24 +1689,23 @@ def _on_multi_frame_ready(self, frame_data: MultiFrameData) -> None: self._raw_frame = frame self._dlc_tile_offset, self._dlc_tile_scale = compute_tile_info(dlc_cam_id, frame, frame_data.frames) - # PRIORITY 1: DLC processing - only enqueue when DLC camera frame arrives! + # PRIORITY: DLC processing - only enqueue when DLC camera frame arrives! if self._dlc_active and is_dlc_camera_frame and dlc_cam_id in frame_data.frames: frame = frame_data.frames[dlc_cam_id] timestamp = frame_data.timestamps.get(dlc_cam_id, time.time()) - self._dlc.enqueue_frame(frame, timestamp) - - # PRIORITY 2: Recording (queued, non-blocking) - if self._rec_manager.is_active and src_id in frame_data.frames: - frame = frame_data.frames[src_id] + with self._dlc_timing.measure("enqueue_frame"): + self._dlc.enqueue_frame(frame, timestamp) - if self.record_with_overlays_checkbox.isChecked(): - # Draw overlays for recording - frame = self._render_overlays_for_recording(src_id, frame) + self._dlc_timing.note_frame() + self._dlc_timing.maybe_log() - ts = frame_data.timestamps.get(src_id, time.time()) - self._rec_manager.write_frame(src_id, frame, ts) + def _on_multi_frame_display_ready(self, frame_data: MultiFrameData) -> None: + """Throttled UI/display path. - # PRIORITY 3: Mark display dirty (tiling done in display timer) + Called at GUI_MAX_DISPLAY_FPS, not at camera capture FPS for performance reasons. + """ + self._multi_camera_frames = frame_data.frames + self._multi_camera_display_ids = frame_data.display_ids or {} self._display_dirty = True def _on_multi_camera_started(self) -> None: @@ -1425,24 +1716,36 @@ def _on_multi_camera_started(self) -> None: self.statusBar().showMessage(f"Multi-camera preview started: {active_count} camera(s)", 5000) self._update_inference_buttons() self._update_camera_controls_enabled() + self._update_dlc_controls_enabled() def _on_multi_camera_stopped(self) -> None: """Handle all cameras stopped event.""" # Stop all multi-camera recorders self._stop_multi_camera_recording() + self._pending_recording_after_preview = False self.preview_button.setEnabled(True) self.stop_preview_button.setEnabled(False) + self._current_frame = None self._multi_camera_frames.clear() + self._multi_camera_display_ids.clear() + self._running_cams_ids.clear() + self._display_dirty = False + self.video_label.setPixmap(QPixmap()) self.video_label.setText("Camera preview not started") self.statusBar().showMessage("Multi-camera preview stopped", 3000) + + self._update_active_cameras_label() + self._refresh_dlc_camera_list() self._update_inference_buttons() self._update_camera_controls_enabled() + self._update_dlc_controls_enabled() def _on_multi_camera_error(self, camera_id: str, message: str) -> None: """Handle error from a camera in multi-camera mode.""" + self._pending_recording_after_preview = False self._show_warning(f"Camera {camera_id} error: {message}\nRecording stopped.") self._refresh_dlc_camera_list_running() if self.dlc_camera_combo.count() <= 1: @@ -1451,6 +1754,7 @@ def _on_multi_camera_error(self, camera_id: str, message: str) -> None: def _on_multi_camera_initialization_failed(self, failures: list) -> None: """Handle complete failure to initialize cameras.""" + self._pending_recording_after_preview = False # Build error message with details for each failed camera error_lines = ["Failed to initialize camera(s):"] for camera_id, error_msg in failures: @@ -1472,11 +1776,13 @@ def _start_multi_camera_recording(self) -> None: session_name = self.session_name_edit.text().strip() if hasattr(self, "session_name_edit") else "" use_ts = self.use_timestamp_checkbox.isChecked() if hasattr(self, "use_timestamp_checkbox") else True + actual_fps_by_camera = self.multi_camera_controller.actual_fps_by_camera_id() run_dir = self._rec_manager.start_all( recording, active_cams, self._multi_camera_frames, + frame_rates=actual_fps_by_camera, session_name=session_name, use_timestamp=use_ts, all_or_nothing=False, @@ -1484,6 +1790,9 @@ def _start_multi_camera_recording(self) -> None: if run_dir is None: self._show_error("Failed to start recording.") return + self._notify_processor_recording_started(run_dir) + self.multi_camera_controller.set_recording_sink(self._rec_manager.write_frame) + self.multi_camera_controller.set_recording_frame_do_emit(True) self._settings_store.set_session_name(session_name) self.start_record_button.setEnabled(False) @@ -1494,7 +1803,155 @@ def _start_multi_camera_recording(self) -> None: def _stop_multi_camera_recording(self) -> None: if not self._rec_manager.is_active: return - self._rec_manager.stop_all() + + if getattr(self, "_recording_stopping", False): + return + + self._recording_stopping = True + + self.start_record_button.setEnabled(False) + self.stop_record_button.setEnabled(False) + self.statusBar().showMessage("Stopping multi-camera recording…", 3000) + + # Stop frame emission immediately so no new frames enter recording pipeline. + try: + self.multi_camera_controller.set_recording_frame_do_emit(False) + self.multi_camera_controller.set_recording_sink(None) + except Exception: + logger.exception("Failed to disable recording frame emission") + + def worker(): + try: + self._rec_manager.stop_all() + finally: + self._recording_stopped_async.emit() + + threading.Thread( + target=worker, + name="StopRecordingWorker", + daemon=True, + ).start() + + def _get_dlc_processor_instance(self): + """Return the active custom DLC processor instance, if available.""" + processor = getattr(self._dlc, "_processor", None) + + if processor is not None: + return processor + + # Fallback: if DLCLive owns it internally. + dlc_obj = getattr(self._dlc, "_dlc", None) + if dlc_obj is not None: + return getattr(dlc_obj, "processor", None) + + return None + + def _save_processor_data_if_available(self) -> None: + """Best-effort generic processor save. + + The GUI intentionally does not pass a path here. This lets custom processors + use their own save_path / filename / internal policy. + + Expected processor contract: + processor.save() -> int | bool | None + + Return values are only logged; failure should not crash the GUI. + """ + processor = self._get_dlc_processor_instance() + + if processor is None: + logger.debug("Processor save skipped: no processor instance available.") + return + + save = getattr(processor, "save", None) + if not callable(save): + logger.debug("Processor save skipped: processor has no callable save().") + return + + try: + result = save() + logger.info("Processor save() completed with result: %r", result) + except Exception: + logger.exception("Processor save() failed.") + + def _notify_processor_recording_started(self, run_dir) -> None: + processor = self._get_dlc_processor_instance() + if processor is None: + return + + hook = getattr(processor, "on_recording_started", None) + if not callable(hook): + return + + try: + context = self._build_processor_recording_context(run_dir) + hook(context) + logger.info("Notified processor recording started: %s", context) + except Exception: + logger.exception("Processor on_recording_started hook failed") + + from pathlib import Path + + def _build_processor_recording_context(self, run_dir) -> dict: + run_dir = Path(run_dir) if run_dir is not None else None + + file_context = {} + try: + file_context = self._rec_manager.get_recording_file_context() + except Exception: + logger.exception("Failed to get recording file context from RecordingManager") + file_context = {} + + if run_dir is None: + run_dir = file_context.get("run_dir", None) + if run_dir is not None: + run_dir = Path(run_dir) + + session_name = "" + if hasattr(self, "session_name_edit"): + session_name = self.session_name_edit.text().strip() + + filename = "" + if hasattr(self, "filename_edit"): + filename = self.filename_edit.text().strip() + + filename_stem = Path(filename or session_name or "recording").stem + + ctx = { + "run_dir": run_dir, + "session_name": session_name, + "filename": filename, + "filename_stem": filename_stem, + "processor_base_path": run_dir / filename_stem if run_dir is not None else None, + } + ctx.update(file_context) + return ctx + + def _notify_processor_recording_stopped(self) -> None: + processor = self._get_dlc_processor_instance() + if processor is None: + return False + + hook = getattr(processor, "on_recording_stopped", None) + if not callable(hook): + return False + + try: + run_dir = getattr(self._rec_manager, "run_dir", None) + context = self._build_processor_recording_context(run_dir) + hook(context) + logger.info("Notified processor recording stopped") + return True + except Exception: + logger.exception("Processor on_recording_stopped hook failed") + return False + + def _on_recording_stopped_async(self) -> None: + handled_by_stop_hook = self._notify_processor_recording_stopped() + if not handled_by_stop_hook: + self._save_processor_data_if_available() + + self._recording_stopping = False self.start_record_button.setEnabled(True) self.stop_record_button.setEnabled(False) self.statusBar().showMessage("Multi-camera recording stopped", 3000) @@ -1569,6 +2026,7 @@ def _start_preview(self) -> None: self._raw_frame = None self._last_pose = None self._multi_camera_frames.clear() + self._multi_camera_display_ids.clear() self._fps_tracker.clear() self._last_display_time = 0.0 @@ -1603,8 +2061,9 @@ def _stop_preview(self) -> None: # Stop any active recording first self._stop_multi_camera_recording() - self.multi_camera_controller.stop() + self._pending_recording_after_preview = False self._stop_inference(show_message=False) + self.multi_camera_controller.stop() self._fps_tracker.clear() self._last_display_time = 0.0 if hasattr(self, "camera_stats_label"): @@ -1614,34 +2073,73 @@ def _stop_preview(self) -> None: def _configure_dlc(self) -> bool: try: settings = self._dlc_settings_from_ui() - except (ValueError, json.JSONDecodeError) as exc: + except (ValueError, RuntimeError, json.JSONDecodeError) as exc: self._show_error(f"Invalid DLCLive settings: {exc}") return False + if not settings.model_path: self._show_error("Please select a DLCLive model before starting inference.") return False - # Instantiate processor if selected processor = None + processor_spec = None + if self._processor_control_enabled(): selected_key = self.processor_combo.currentData() + self._settings_store.set_processor_key(selected_key) + if selected_key is not None and self._scanned_processors: try: - # For now, instantiate with no parameters - processor = instantiate_from_scan(self._scanned_processors, selected_key) - processor_name = self._scanned_processors[selected_key]["name"] + processor_info = self._scanned_processors[selected_key] + processor_class = processor_info["class"] + processor_name = processor_info.get("name", processor_class.__name__) + + if processor_builds_in_worker(processor_class): + processor_spec = create_spec_from_scan( + self._scanned_processors, + selected_key, + ) + processor = None + + log_processor_context( + f"MainWindow._configure_dlc - SPEC: {processor_class.__name__}", + logger, + ) + + else: + processor = instantiate_from_scan( + self._scanned_processors, + selected_key, + ) + processor_spec = None + + log_processor_context( + f"MainWindow._configure_dlc - INSTANCE: {type(processor).__name__}", + logger, + ) + self.statusBar().showMessage(f"Loaded processor: {processor_name}", 3000) + except Exception as e: - error_msg = f"Failed to instantiate processor: {e}" + error_msg = f"Failed to configure processor: {e}" self._show_error(error_msg) - logger.error(error_msg) + logger.exception(error_msg) return False + else: selected_key = self.processor_combo.currentData() if selected_key is not None: - self.statusBar().showMessage(f"Processor selection ignored (control disabled): {selected_key}", 3000) + self.statusBar().showMessage( + f"Processor selection ignored (control disabled): {selected_key}", + 3000, + ) + + self._dlc.configure( + settings, + processor=processor, + processor_spec=processor_spec, + ) - self._dlc.configure(settings, processor=processor) self._model_path_store.save_if_valid(settings.model_path) return True @@ -1653,24 +2151,28 @@ def _update_inference_buttons(self) -> None: def _update_dlc_controls_enabled(self) -> None: """Enable/disable DLC settings based on inference state.""" allow_changes = not self._dlc_active - processor_controls = allow_changes and self._processor_control_enabled() widgets = [ self.model_path_edit, self.browse_model_button, self.dlc_camera_combo, - # self.additional_options_edit, ] + processor_widgets = [ self.processor_folder_edit, self.browse_processor_folder_button, self.refresh_processors_button, self.processor_combo, ] + for widget in widgets: widget.setEnabled(allow_changes) + for widget in processor_widgets: - widget.setEnabled(processor_controls) + widget.setEnabled(allow_changes) + + if hasattr(self, "allow_processor_ctrl_checkbox"): + self.allow_processor_ctrl_checkbox.setEnabled(allow_changes) def _update_camera_controls_enabled(self) -> None: multi_cam_recording = self._rec_manager.is_active @@ -1684,6 +2186,8 @@ def _update_camera_controls_enabled(self) -> None: recording_editable = not multi_cam_recording self.codec_combo.setEnabled(recording_editable) self.crf_spin.setEnabled(recording_editable) + if hasattr(self, "fast_encoding_checkbox"): + self.fast_encoding_checkbox.setEnabled(recording_editable) # Config cameras button should be available when not in preview/recording self.config_cameras_button.setEnabled(allow_changes) @@ -1711,7 +2215,7 @@ def _update_display_from_pending(self) -> None: self._display_dirty = False # Create tiled frame on demand (moved from camera thread for performance) - tiled = create_tiled_frame(self._multi_camera_frames) + tiled = create_tiled_frame(self._multi_camera_frames, labels=self._multi_camera_display_ids) if tiled is not None: self._current_frame = tiled self._update_video_display(tiled) @@ -1727,10 +2231,11 @@ def _update_metrics(self) -> None: active_cams = self._config.multi_camera.get_active_cameras() lines = [] for cam in active_cams: - cam_id = get_camera_id(cam) # e.g., "opencv:0" or "pylon:1" + cam_id = get_camera_id(cam) + display_id = get_display_id(cam) fps = self._fps_tracker.fps(cam_id) # Make a compact label: name [backend:index] @ fps - label = f"{cam.name or cam_id} [{cam.backend}:{cam.index}]" + label = f"{display_id} [{cam.backend}:{cam.index}]" if fps > 0: lines.append(f"{label} @ {fps:.1f} fps") else: @@ -1884,17 +2389,44 @@ def _start_recording(self) -> None: """Start recording from all active cameras.""" # Auto-start preview if not running if not self.multi_camera_controller.is_running(): + self._pending_recording_after_preview = True self._start_preview() # Wait a moment for cameras to initialize before recording # The recording will start after preview is confirmed running self.statusBar().showMessage("Starting preview before recording...", 3000) # Use a single-shot timer to start recording after preview starts - QTimer.singleShot(500, self._start_multi_camera_recording) + # QTimer.singleShot(500, self._start_multi_camera_recording) return # Preview already running, start recording immediately self._start_multi_camera_recording() + def _try_start_pending_recording(self) -> None: + if not self._pending_recording_after_preview: + return + + if self._rec_manager.is_active: + self._pending_recording_after_preview = False + return + + if not self.multi_camera_controller.is_running(): + return + + active_cams = self._config.multi_camera.get_active_cameras() + expected_ids = {get_camera_id(cam) for cam in active_cams} + + if not expected_ids: + self._pending_recording_after_preview = False + return + + available_ids = set(self._multi_camera_frames.keys()) + + if not expected_ids.issubset(available_ids): + return + + self._pending_recording_after_preview = False + self._start_multi_camera_recording() + def _stop_recording(self) -> None: """Stop recording from all cameras.""" self._stop_multi_camera_recording() @@ -1902,10 +2434,23 @@ def _stop_recording(self) -> None: def _on_pose_ready(self, result: PoseResult) -> None: if not self._dlc_active: return - self._last_pose = result - # logger.debug(f"Pose result: {result.pose}, Timestamp: {result.timestamp}") - if self._current_frame is not None: - self._display_frame(self._current_frame, force=True) + + with self._dlc_timing.measure("DLC.pose_ready_callback"): + self._last_pose = result + + # try: + # latency_ms = (time.time() - float(result.timestamp)) * 1000.0 + # if logger.isEnabledFor(logging.DEBUG): + # logger.debug("DLC pose latency camera_timestamp_to_gui=%.2f ms", latency_ms) + # except Exception: + # pass + + if self._current_frame is not None: + self._display_dirty = True + # with self._dlc_timing.measure("DLC.display_after_pose"): + # self._display_frame(self._current_frame, force=True) + + self._dlc_timing.maybe_log() def _on_dlc_error(self, message: str) -> None: self._stop_inference(show_message=False) @@ -2047,5 +2592,27 @@ def closeEvent(self, event: QCloseEvent) -> None: # pragma: no cover - GUI beha # Remember model path on exit self._model_path_store.save_if_valid(self.model_path_edit.text().strip()) + # Remember processor folder on exit + if hasattr(self, "processor_folder_edit"): + self._settings_store.set_processor_folder(self.processor_folder_edit.text().strip()) + + # Remember user-preferred inference camera on exit. + if hasattr(self, "_inference_camera_id"): + self._settings_store.set_inference_camera_id(self._inference_camera_id) + + # Remember selected processor on exit + if hasattr(self, "processor_combo"): + self._settings_store.set_processor_key(self.processor_combo.currentData()) + + # Remember processor-control checkbox state on exit + if hasattr(self, "allow_processor_ctrl_checkbox"): + self._settings_store.set_processor_control_enabled(self.allow_processor_ctrl_checkbox.isChecked()) + + # Flush QSettings best-effort + try: + self.settings.sync() + except Exception: + logger.exception("Failed to sync QSettings on close", exc_info=True) + # Close the window super().closeEvent(event) diff --git a/dlclivegui/gui/misc/layouts.py b/dlclivegui/gui/misc/layouts.py index 2b09b47fc..d80ba114c 100644 --- a/dlclivegui/gui/misc/layouts.py +++ b/dlclivegui/gui/misc/layouts.py @@ -3,8 +3,19 @@ from collections.abc import Sequence -from PySide6.QtCore import QObject, Qt -from PySide6.QtWidgets import QComboBox, QGridLayout, QLabel, QSizePolicy, QStyle, QStyleOptionComboBox, QWidget +from PySide6.QtCore import QEvent, QObject, QSize, Qt, QTimer +from PySide6.QtWidgets import ( + QComboBox, + QFrame, + QGridLayout, + QLabel, + QScrollArea, + QSizePolicy, + QStyle, + QStyleOptionComboBox, + QVBoxLayout, + QWidget, +) def _combo_width_for_current_text(combo: QComboBox, extra_padding: int = 10) -> int: @@ -226,3 +237,186 @@ def _add_pair(label_text: str | None, widget: QWidget | None, stretch: int) -> N grid.setColumnStretch(c, s) return row + + +class _VerticalOnlyScrollArea(QScrollArea): + """ + Vertical-only scroll area. + + It does not introduce horizontal scrolling and does not manually force the + child width on resize. Instead, it makes the child keep at least its natural + layout width, and makes the scroll area advertise that width as its own + minimum width. + + This avoids horizontal clipping without creating a width feedback loop. + """ + + def __init__(self, content_widget: QWidget, parent: QWidget | None = None): + super().__init__(parent) + + self._content_widget = content_widget + + self.setFrameShape(QFrame.Shape.NoFrame) + self.setViewportMargins(0, 0, 0, 0) + self.setContentsMargins(0, 0, 0, 0) + + self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) + self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + + # Let the scroll area resize the content to the available viewport size, + # but never below the content's minimum width. + self.setWidgetResizable(True) + self.setWidget(content_widget) + + self.setSizePolicy(QSizePolicy.Policy.MinimumExpanding, QSizePolicy.Policy.Expanding) + content_widget.setSizePolicy(QSizePolicy.Policy.MinimumExpanding, QSizePolicy.Policy.Preferred) + + self.update_width_constraints() + + def _scrollbar_extent(self) -> int: + return self.style().pixelMetric(QStyle.PixelMetric.PM_ScrollBarExtent, None, self) + + def _frame_width_total(self) -> int: + frame = self.frameWidth() + return frame * 2 + + def _natural_content_width(self) -> int: + """ + Return the content's natural layout width. + + Important: do not use content_widget.minimumWidth() here, because this + class sets that value. Using it would create a feedback loop where the + natural width grows after resizes. + """ + layout = self._content_widget.layout() + + candidates: list[int] = [ + self._content_widget.sizeHint().width(), + self._content_widget.minimumSizeHint().width(), + ] + + if layout is not None: + candidates.append(layout.sizeHint().width()) + candidates.append(layout.minimumSize().width()) + + return max(0, *candidates) + + def _natural_content_height(self) -> int: + layout = self._content_widget.layout() + + candidates: list[int] = [ + self._content_widget.sizeHint().height(), + self._content_widget.minimumSizeHint().height(), + ] + + if layout is not None: + candidates.append(layout.sizeHint().height()) + candidates.append(layout.minimumSize().height()) + + return max(0, *candidates) + + def update_width_constraints(self) -> None: + natural_width = self._natural_content_width() + + # Reserve vertical-scrollbar width so that when the scrollbar appears, + # the content still has enough viewport width and is not horizontally clipped. + min_scroll_width = natural_width + self._scrollbar_extent() + self._frame_width_total() + + if self._content_widget.minimumWidth() != natural_width: + self._content_widget.setMinimumWidth(natural_width) + + if self.minimumWidth() != min_scroll_width: + self.setMinimumWidth(min_scroll_width) + + self._content_widget.updateGeometry() + self.updateGeometry() + + def event(self, event) -> bool: + result = super().event(event) + + if event.type() in { + QEvent.Type.Show, + QEvent.Type.LayoutRequest, + QEvent.Type.FontChange, + QEvent.Type.StyleChange, + QEvent.Type.PaletteChange, + }: + self.update_width_constraints() + + return result + + def resizeEvent(self, event) -> None: + super().resizeEvent(event) + self.update_width_constraints() + + def sizeHint(self) -> QSize: + return QSize( + self._natural_content_width() + self._scrollbar_extent() + self._frame_width_total(), + self._natural_content_height(), + ) + + def minimumSizeHint(self) -> QSize: + # Width is strict, height is deliberately small so vertical scrolling can happen. + return QSize( + self._natural_content_width() + self._scrollbar_extent() + self._frame_width_total(), + 120, + ) + + +def make_scrollable_with_fixed_footer( + content_widget: QWidget, + footer_widget: QWidget | None = None, + *, + object_name: str | None = None, + scroll_object_name: str | None = None, + footer_object_name: str | None = None, + margins: tuple[int, int, int, int] = (0, 0, 0, 0), + spacing: int = 0, + footer_margins: tuple[int, int, int, int] = (0, 0, 0, 0), +) -> QWidget: + """ + Wrap `content_widget` in a vertical-only scroll area and keep `footer_widget` + outside the scroll area. + + Guarantees: + - existing content layout is not restructured; + - vertical scrolling appears only when height is insufficient; + - horizontal scrolling is never introduced; + - content is not hidden horizontally: the wrapper advertises the natural + content width as its minimum width; + - footer controls remain visible. + """ + wrapper = QWidget() + if object_name: + wrapper.setObjectName(object_name) + + wrapper_layout = QVBoxLayout(wrapper) + wrapper_layout.setContentsMargins(*margins) + wrapper_layout.setSpacing(max(0, int(spacing))) + + scroll = _VerticalOnlyScrollArea(content_widget, wrapper) + if scroll_object_name: + scroll.setObjectName(scroll_object_name) + + wrapper_layout.addWidget(scroll, stretch=1) + + if footer_widget is not None: + footer_wrapper = QWidget(wrapper) + if footer_object_name: + footer_wrapper.setObjectName(footer_object_name) + + footer_layout = QVBoxLayout(footer_wrapper) + footer_layout.setContentsMargins(*footer_margins) + footer_layout.setSpacing(0) + footer_layout.addWidget(footer_widget) + + footer_wrapper.setSizePolicy(QSizePolicy.Policy.MinimumExpanding, QSizePolicy.Policy.Fixed) + wrapper_layout.addWidget(footer_wrapper, stretch=0) + + wrapper.setSizePolicy(QSizePolicy.Policy.MinimumExpanding, QSizePolicy.Policy.Expanding) + + # The wrapper must also resist horizontal shrinking, otherwise the dock can + # still become narrower than the scroll area's valid width. + QTimer.singleShot(0, lambda: wrapper.setMinimumWidth(scroll.minimumSizeHint().width())) + + return wrapper diff --git a/dlclivegui/gui/recording_manager.py b/dlclivegui/gui/recording_manager.py deleted file mode 100644 index 49ac9934b..000000000 --- a/dlclivegui/gui/recording_manager.py +++ /dev/null @@ -1,183 +0,0 @@ -from __future__ import annotations - -import logging -import time -from pathlib import Path - -import numpy as np - -from dlclivegui.config import CameraSettings, RecordingSettings -from dlclivegui.services.multi_camera_controller import get_camera_id -from dlclivegui.services.video_recorder import RecorderStats, VideoRecorder -from dlclivegui.utils.utils import build_run_dir, sanitize_name - -log = logging.getLogger(__name__) - - -class RecordingManager: - """Handle multi-camera recording lifecycle and filenames.""" - - def __init__(self): - self._recorders: dict[str, VideoRecorder] = {} - self._session_dir: Path | None = None - self._run_dir: Path | None = None - - @property - def is_active(self) -> bool: - return bool(self._recorders) - - @property - def recorders(self) -> dict[str, VideoRecorder]: - return self._recorders - - @property - def session_dir(self) -> Path | None: - return self._session_dir - - @property - def run_dir(self) -> Path | None: - return self._run_dir - - def pop(self, cam_id: str, default=None) -> VideoRecorder | None: - return self._recorders.pop(cam_id, default) - - def start_all( - self, - recording: RecordingSettings, - active_cams: list[CameraSettings], - current_frames: dict[str, np.ndarray], - *, - session_name: str = "session", - use_timestamp: bool = True, - all_or_nothing: bool = False, - ) -> Path | None: - """Start recording for all active cameras. - - Record into /// - - Args: - recording: Recording settings including output directory and codec. - active_cams: List of active camera settings to record. - current_frames: Dict of current frames by camera ID for size reference. - session_name: Name of the recording session (used in directory name). - use_timestamp: Whether to use timestamp-based run directories instead of indexed. - all_or_nothing: If True, stop all and return None if any recorder fails to start. - - Returns: - run_dir if at least one recorder started, else None. - """ - if self._recorders: - return self._run_dir - - if not active_cams: - return None - - base_path = recording.output_path() - base_stem = base_path.stem - - # create session/run directories - session_safe = sanitize_name(session_name, fallback="session") - session_dir = base_path.parent / session_safe - try: - run_dir = build_run_dir(session_dir, use_timestamp=use_timestamp) - except Exception as exc: - log.error("Failed to create run dir: %s", exc) - return None - - self._session_dir = session_dir - self._run_dir = run_dir - - started_any = False - - for cam in active_cams: - cam_id = get_camera_id(cam) - cam_filename = f"{base_stem}_{cam.backend}_cam{cam.index}{base_path.suffix}" - cam_path = run_dir / cam_filename - - frame = current_frames.get(cam_id) - frame_size = (frame.shape[0], frame.shape[1]) if frame is not None else None - - recorder = VideoRecorder( - cam_path, - frame_size=frame_size, - frame_rate=float(cam.fps), - codec=recording.codec, - crf=recording.crf, - ) - try: - recorder.start() - self._recorders[cam_id] = recorder - started_any = True - log.info("Started recording %s -> %s", cam_id, cam_path) - except Exception as exc: - log.error("Failed to start recording for %s: %s", cam_id, exc) - if all_or_nothing: - self.stop_all() - return None - - if not started_any: - self._recorders.clear() - self._session_dir = None - self._run_dir = None - return None - - return run_dir - - def stop_all(self) -> None: - for cam_id, rec in self._recorders.items(): - try: - rec.stop() - log.info("Stopped recording %s", cam_id) - except Exception as exc: - log.warning("Error stopping recorder for %s: %s", cam_id, exc) - self._recorders.clear() - self._session_dir = None - self._run_dir = None - - def write_frame(self, cam_id: str, frame: np.ndarray, timestamp: float | None = None) -> None: - rec = self._recorders.get(cam_id) - if not rec or not rec.is_running: - return - try: - rec.write(frame, timestamp=timestamp if timestamp is not None else time.time()) - except Exception as exc: - log.warning("Failed to write frame for %s: %s", cam_id, exc) - try: - rec.stop() - except Exception: - log.exception("Failed to stop recorder for %s after write error.") - self._recorders.pop(cam_id, None) - - def get_stats_summary(self) -> str: - totals = { - "written": 0, - "dropped": 0, - "queue": 0, - "max_latency": 0.0, - "avg_latencies": [], - } - for rec in self._recorders.values(): - stats: RecorderStats | None = rec.get_stats() - if not stats: - continue - totals["written"] += stats.frames_written - totals["dropped"] += stats.dropped_frames - totals["queue"] += stats.queue_size - totals["max_latency"] = max(totals["max_latency"], stats.last_latency) - totals["avg_latencies"].append(stats.average_latency) - - if len(self._recorders) == 1: - rec = next(iter(self._recorders.values())) - stats = rec.get_stats() - if stats: - from dlclivegui.utils.stats import format_recorder_stats - - return format_recorder_stats(stats) - return "Recording..." - else: - avg = sum(totals["avg_latencies"]) / len(totals["avg_latencies"]) if totals["avg_latencies"] else 0.0 - return ( - f"{len(self._recorders)} cams | {totals['written']} frames | " - f"latency {totals['max_latency'] * 1000:.1f}ms (avg {avg * 1000:.1f}ms) | " - f"queue {totals['queue']} | dropped {totals['dropped']}" - ) diff --git a/dlclivegui/main.py b/dlclivegui/main.py index eace8bda6..9e38a08cf 100644 --- a/dlclivegui/main.py +++ b/dlclivegui/main.py @@ -27,32 +27,70 @@ def _maybe_allow_keyboard_interrupt(app: QApplication) -> None: """ - Gracefully handle Ctrl+C (SIGINT) by closing the main window and quitting Qt. + Gracefully handle Ctrl+C/SIGTERM by closing the main window and quitting Qt. + + Notes: + - The small timer keeps Python signal handling responsive while Qt owns the event loop. + - First Ctrl+C tries graceful cleanup via closeEvent(). + - Second Ctrl+C exits immediately with code 130. """ + quitting = {"requested": False} def _request_quit() -> None: + if quitting["requested"]: + return + + quitting["requested"] = True logging.info("Keyboard interrupt received, closing application...") + win = getattr(app, "_main_window", None) + if win is not None: - # Trigger your existing closeEvent cleanup (camera stop, threads, timers, etc.) - win.close() - else: - app.quit() + try: + # Trigger existing closeEvent cleanup: + # camera stop, controller shutdown, timers, DLC shutdown, etc. + win.close() + except Exception: + logging.exception("Error while closing main window after Ctrl+C") + + # Explicitly ask Qt to leave app.exec(). + # Do this even after win.close(), because closeEvent cleanup can be async + # and relying only on quitOnLastWindowClosed can be fragile. + QTimer.singleShot(0, app.quit) + + def _force_exit() -> None: + logging.warning("Second interrupt received, forcing process exit.") + os._exit(130) def _sigint_handler(_signum, _frame) -> None: + if quitting["requested"]: + _force_exit() QTimer.singleShot(0, _request_quit) signal.signal(signal.SIGINT, _sigint_handler) - # Keepalive timer to allow Python to handle signals while Qt is running. - sig_timer = QTimer() - sig_timer.setInterval(100) # 50–200ms typical; keep low overhead + # Ctrl+Break on Windows. + if hasattr(signal, "SIGBREAK"): + signal.signal(signal.SIGBREAK, _sigint_handler) + + # Useful when process is terminated from shells/process managers. + if hasattr(signal, "SIGTERM"): + signal.signal(signal.SIGTERM, _sigint_handler) + + # Parent the timer to app so Qt owns its lifetime. + sig_timer = QTimer(app) + sig_timer.setInterval(100) sig_timer.timeout.connect(lambda: None) sig_timer.start() - if hasattr(app, "_sig_timer"): - app._sig_timer.stop() # Stop any existing timer to avoid duplicates - app._sig_timer = sig_timer # Store on app to keep it alive and allow cleanup on exit + old_timer = getattr(app, "_sig_timer", None) + if old_timer is not None: + try: + old_timer.stop() + except Exception: + pass + + app._sig_timer = sig_timer def configure_logging(debug: bool = False) -> None: diff --git a/dlclivegui/processors/PLUGIN_SYSTEM.md b/dlclivegui/processors/PLUGIN_SYSTEM.md index 9e975e01c..e6a143626 100644 --- a/dlclivegui/processors/PLUGIN_SYSTEM.md +++ b/dlclivegui/processors/PLUGIN_SYSTEM.md @@ -16,7 +16,8 @@ Processors are Python classes (typically subclasses of `dlclive.Processor`) that ### Useful files -- `dlclivegui/processors/dlc_processor_socket.py` — Example socket-based processor base class + examples +- `dlclivegui/processors/dlc_processor_socket.py` — Example socket-based processor base class +- `dlclivegui/processors/examples.py` — Example processor implementations (e.g., One-Euro filter) - `dlclivegui/processors/processor_utils.py` — Scanning + instantiation helpers used by the GUI --- @@ -204,12 +205,7 @@ The built-in `BaseProcessorSocket` (in `dlc_processor_socket.py`) demonstrates a ```python from dlclive import Processor - -PROCESSOR_REGISTRY = {} - -def register_processor(cls): - PROCESSOR_REGISTRY[getattr(cls, "PROCESSOR_ID", cls.__name__)] = cls - return cls +from dlclivegui.processors import register_processor, PROCESSOR_REGISTRY @register_processor class MyNewProcessor(Processor): diff --git a/dlclivegui/processors/__init__.py b/dlclivegui/processors/__init__.py new file mode 100644 index 000000000..8e7717155 --- /dev/null +++ b/dlclivegui/processors/__init__.py @@ -0,0 +1,3 @@ +from .registry import PROCESSOR_REGISTRY, register_processor + +__all__ = ["register_processor", "PROCESSOR_REGISTRY"] diff --git a/dlclivegui/processors/dlc_processor_socket.py b/dlclivegui/processors/dlc_processor_socket.py index 8ded01069..f2327901b 100644 --- a/dlclivegui/processors/dlc_processor_socket.py +++ b/dlclivegui/processors/dlc_processor_socket.py @@ -7,14 +7,17 @@ import sys import time from collections import deque -from math import acos, atan2, copysign, degrees, pi, sqrt from multiprocessing.connection import Client, Listener from pathlib import Path from threading import Event, Thread import numpy as np import pandas as pd -from dlclive import Processor # type: ignore + +try: + from dlclive.processor import Processor # type: ignore +except ImportError: + Processor = object # Fallback for type checking if dlclive is not installed logger = logging.getLogger("dlc_processor_socket") @@ -24,59 +27,6 @@ _handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s")) logger.addHandler(_handler) -# Registry for GUI discovery -PROCESSOR_REGISTRY = {} - - -def register_processor(cls): - registry_key = getattr(cls, "PROCESSOR_ID", cls.__name__) - if registry_key in PROCESSOR_REGISTRY: - raise ValueError( - f"Duplicate processor registration key '{registry_key}': " - f"{PROCESSOR_REGISTRY[registry_key].__name__} vs {cls.__name__}" - ) - PROCESSOR_REGISTRY[registry_key] = cls - return cls - - -class OneEuroFilter: # pragma: no cover - def __init__(self, t0, x0, dx0=None, min_cutoff=1.0, beta=0.0, d_cutoff=1.0): - self.min_cutoff = min_cutoff - self.beta = beta - self.d_cutoff = d_cutoff - self.x_prev = x0 - if dx0 is None: - dx0 = np.zeros_like(x0) - self.dx_prev = dx0 - self.t_prev = t0 - - @staticmethod - def smoothing_factor(t_e, cutoff): - r = 2 * pi * cutoff * t_e - return r / (r + 1) - - @staticmethod - def exponential_smoothing(alpha, x, x_prev): - return alpha * x + (1 - alpha) * x_prev - - def __call__(self, t, x): - t_e = t - self.t_prev - if t_e <= 0: - return x - a_d = self.smoothing_factor(t_e, self.d_cutoff) - dx = (x - self.x_prev) / t_e - dx_hat = self.exponential_smoothing(a_d, dx, self.dx_prev) - - cutoff = self.min_cutoff + self.beta * abs(dx_hat) - a = self.smoothing_factor(t_e, cutoff) - x_hat = self.exponential_smoothing(a, x, self.x_prev) - - self.x_prev = x_hat - self.dx_prev = dx_hat - self.t_prev = t - - return x_hat - # pragma: cover class BaseProcessorSocket(Processor): @@ -91,6 +41,11 @@ class BaseProcessorSocket(Processor): PROCESSOR_DESCRIPTION = "Base class for socket-based processors with multi-client support" PROCESSOR_PARAMS = {} + # Experimental: + # Socket/Teensy/Unity processors often start threads, sockets, serial ports, etc. + # Build them inside the DLCLive worker to match legacy Tk GUI behavior. + PROCESSOR_BUILD_IN_WORKER = False + def __init__( self, bind=None, @@ -131,6 +86,8 @@ def __init__( self._vid_recording = Event() self.curr_step = 0 self.save_original = save_original + self.recording_context = {} + self.save_path = None # Networking (optional) self.address = bind @@ -310,8 +267,14 @@ def stop_recording(self): # STOP / SHUTDOWN # -------------------------------------------------------------------------------------- - def stop(self): + def stop(self, save: bool = False, file=None): """Gracefully stop listener and clients.""" + if save: + try: + self.save(file) + except Exception: + logger.exception("Failed to save processor data on stop") + if self._stop.is_set(): return @@ -415,23 +378,79 @@ def _clear_data_queues(self): self.original_pose.clear() def save(self, file=None): - if not file: + target = file + + if target is None: + target = getattr(self, "save_path", None) + + if target is None: + logger.warning("Processor save skipped: no file or save_path provided.") return 0 + try: save_dict = self.get_data() - path2save = Path(__file__).parent.parent.parent / "data" / file - path2save.parent.mkdir(parents=True, exist_ok=True) + save_path = Path(target) + save_path.parent.mkdir(parents=True, exist_ok=True) + if self.save_original: original_pose = save_dict.pop("original_pose") - self.save_original_pose(original_pose, save_dict["frame_time"], save_dict["time_stamp"], path2save) - with open(path2save, "wb") as f: + self.save_original_pose( + original_pose, + save_dict["frame_time"], + save_dict["time_stamp"], + save_path, + ) + + with open(save_path, "wb") as f: pickle.dump(save_dict, f) - logger.info(f"Saved data to {path2save}") + + logger.info(f"Saved processor data to {save_path}") return 1 + except Exception as e: logger.error(f"Save failed: {e}") return -1 + def set_recording_context(self, context: dict | None) -> None: + """Set GUI-provided recording context. + + This is intentionally generic. Custom processors may use it to derive + processor-specific output paths. + + Expected keys may include: + run_dir + session_name + filename + filename_stem + processor_base_path + video_files + timestamp_files + """ + self.recording_context = dict(context or {}) + + base_path = self.recording_context.get("processor_base_path") + if base_path is not None: + self.save_path = Path(base_path) + + def set_save_path(self, path) -> None: + """Set default save path used by save() when no file is provided.""" + self.save_path = Path(path) if path is not None else None + + def get_save_path(self): + """Return default save path, if any.""" + return getattr(self, "save_path", None) + + def on_recording_started(self, context: dict) -> None: + """Optional hook called by GUI when recording starts.""" + self.set_recording_context(context) + + def on_recording_stopped(self, context: dict) -> None: + """Optional hook called by GUI when recording stops. + + Base implementation only updates context. Custom processors can override. + """ + self.set_recording_context(context) + def save_original_pose( self, original_pose: np.ndarray, @@ -474,375 +493,3 @@ def get_data(self): if self.dlc_cfg is not None: save_dict["dlc_cfg"] = self.dlc_cfg return save_dict - - -@register_processor -class ExampleProcessorSocketCalculateMousePose(BaseProcessorSocket): # pragma: no cover - """ - DLC Processor with pose calculations (center, heading, head angle) and optional filtering. - - Calculates: - - center: Weighted average of head keypoints - - heading: Body orientation (degrees) - - head_angle: Head rotation relative to body (radians) - - Broadcasts: [timestamp, center_x, center_y, heading, head_angle] - """ - - PROCESSOR_NAME = "Example Experiment Pose Processor" - PROCESSOR_DESCRIPTION = "Calculates mouse center, heading, and head angle with optional One-Euro filtering" - PROCESSOR_PARAMS = { - "bind": { - "type": "tuple", - "default": ("127.0.0.1", 6000), - "description": "Server address (host, port)", - }, - "authkey": { - "type": "bytes", - "default": b"secret password", - "description": "Authentication key for clients", - }, - "use_perf_counter": { - "type": "bool", - "default": False, - "description": "Use time.perf_counter() instead of time.time()", - }, - "use_filter": { - "type": "bool", - "default": False, - "description": "Apply One-Euro filter to calculated values", - }, - "filter_kwargs": { - "type": "dict", - "default": {"min_cutoff": 1.0, "beta": 0.02, "d_cutoff": 1.0}, - "description": "One-Euro filter parameters (min_cutoff, beta, d_cutoff)", - }, - "save_original": { - "type": "bool", - "default": False, - "description": "Save raw pose arrays for analysis", - }, - } - - def __init__( - self, - bind=("127.0.0.1", 6000), - authkey=b"secret password", - use_perf_counter=False, - use_filter=False, - filter_kwargs: dict | None = None, - save_original=False, - ): - super().__init__( - bind=bind, - authkey=authkey, - use_perf_counter=use_perf_counter, - save_original=save_original, - ) - - self.center_x = deque() - self.center_y = deque() - self.heading_direction = deque() - self.head_angle = deque() - - self.use_filter = use_filter - self.filter_kwargs = filter_kwargs if filter_kwargs is not None else {} - self.filters = None - - def _clear_data_queues(self): - super()._clear_data_queues() - self.center_x.clear() - self.center_y.clear() - self.heading_direction.clear() - self.head_angle.clear() - - def _initialize_filters(self, vals): - t0 = self.timing_func() - self.filters = { - "center_x": OneEuroFilter(t0, vals[0], **self.filter_kwargs), - "center_y": OneEuroFilter(t0, vals[1], **self.filter_kwargs), - "heading": OneEuroFilter(t0, vals[2], **self.filter_kwargs), - "head_angle": OneEuroFilter(t0, vals[3], **self.filter_kwargs), - } - logger.debug(f"Initialized One-Euro filters with parameters: {self.filter_kwargs}") - - def process(self, pose, **kwargs): - # Extract keypoints and confidence - xy = pose[:, :2] - conf = pose[:, 2] - - # Calculate weighted center from head keypoints - head_xy = xy[[0, 1, 2, 3, 4, 5, 6, 26], :] - head_conf = conf[[0, 1, 2, 3, 4, 5, 6, 26]] - center = np.average(head_xy, axis=0, weights=head_conf) - - # Calculate body axis (tail_base -> neck) - body_axis = xy[7] - xy[13] - body_axis /= sqrt(np.sum(body_axis**2)) - - # Calculate head axis (neck -> nose) - head_axis = xy[0] - xy[7] - head_axis /= sqrt(np.sum(head_axis**2)) - - # Calculate head angle relative to body - cross = body_axis[0] * head_axis[1] - head_axis[0] * body_axis[1] - sign = copysign(1, cross) # Positive when looking left - sign = copysign(1, cross) - try: - head_angle = acos(body_axis @ head_axis) * sign - except ValueError: - head_angle = 0 - - # Calculate heading (body orientation) - heading = degrees(atan2(body_axis[1], body_axis[0])) - - # Raw values (heading unwrapped for filtering) - vals = [center[0], center[1], heading, head_angle] - - # Apply filtering if enabled - curr_time = self.timing_func() - if self.use_filter: - if self.filters is None: - self._initialize_filters(vals) - - vals = [ - self.filters["center_x"](curr_time, vals[0]), - self.filters["center_y"](curr_time, vals[1]), - self.filters["heading"](curr_time, vals[2]), - self.filters["head_angle"](curr_time, vals[3]), - ] - - # Wrap heading to [0, 360) after filtering - vals[2] = vals[2] % 360 - # Update step counter - self.curr_step = self.curr_step + 1 - - # Store processed data (only if recording) - if self.recording: - if self.save_original and self.original_pose is not None: - self.original_pose.append(pose.copy()) - self.center_x.append(vals[0]) - self.center_y.append(vals[1]) - self.heading_direction.append(vals[2]) - self.head_angle.append(vals[3]) - self.time_stamp.append(curr_time) - self.step.append(self.curr_step) - self.frame_time.append(kwargs.get("frame_time", -1)) - if "pose_time" in kwargs: - self.pose_time.append(kwargs["pose_time"]) - - payload = [curr_time, vals[0], vals[1], vals[2], vals[3]] - self.broadcast(payload) - return pose - - def get_data(self): - save_dict = super().get_data() - save_dict["x_pos"] = np.array(self.center_x) - save_dict["y_pos"] = np.array(self.center_y) - save_dict["heading_direction"] = np.array(self.heading_direction) - save_dict["head_angle"] = np.array(self.head_angle) - save_dict["use_filter"] = self.use_filter - save_dict["filter_kwargs"] = self.filter_kwargs - return save_dict - - -@register_processor -class ExampleProcessorSocketFilterKeypoints(BaseProcessorSocket): # pragma: no cover - PROCESSOR_NAME = "Mouse Pose with less keypoints" - PROCESSOR_DESCRIPTION = "Calculates mouse center, heading, and head angle with optional One-Euro filtering" - PROCESSOR_PARAMS = { - "bind": { - "type": "tuple", - "default": ("127.0.0.1", 6000), - "description": "Server address (host, port)", - }, - "authkey": { - "type": "bytes", - "default": b"secret password", - "description": "Authentication key for clients", - }, - "use_perf_counter": { - "type": "bool", - "default": False, - "description": "Use time.perf_counter() instead of time.time()", - }, - "use_filter": { - "type": "bool", - "default": False, - "description": "Apply One-Euro filter to calculated values", - }, - "filter_kwargs": { - "type": "dict", - "default": {"min_cutoff": 1.0, "beta": 0.02, "d_cutoff": 1.0}, - "description": "One-Euro filter parameters (min_cutoff, beta, d_cutoff)", - }, - "save_original": { - "type": "bool", - "default": True, - "description": "Save raw pose arrays for analysis", - }, - } - - def __init__( - self, - bind=("127.0.0.1", 6000), - authkey=b"secret password", - use_perf_counter=False, - use_filter=False, - filter_kwargs: dict | None = None, - save_original=True, - p_cutoff=0.4, - ): - super().__init__( - bind=bind, - authkey=authkey, - use_perf_counter=use_perf_counter, - save_original=save_original, - ) - - self.center_x = deque() - self.center_y = deque() - self.heading_direction = deque() - self.head_angle = deque() - - self.p_cutoff = p_cutoff - - self.use_filter = use_filter - self.filter_kwargs = filter_kwargs if filter_kwargs is not None else {} - self.filters = None - - def _clear_data_queues(self): - super()._clear_data_queues() - self.center_x.clear() - self.center_y.clear() - self.heading_direction.clear() - self.head_angle.clear() - - def _initialize_filters(self, vals): - t0 = self.timing_func() - self.filters = { - "center_x": OneEuroFilter(t0, vals[0], **self.filter_kwargs), - "center_y": OneEuroFilter(t0, vals[1], **self.filter_kwargs), - "heading": OneEuroFilter(t0, vals[2], **self.filter_kwargs), - "head_angle": OneEuroFilter(t0, vals[3], **self.filter_kwargs), - } - logger.debug(f"Initialized One-Euro filters with parameters: {self.filter_kwargs}") - - def process(self, pose, **kwargs): - # Extract keypoints and confidence - xy = pose[:, :2] - conf = pose[:, 2] - - # Calculate weighted center from head keypoints - head_xy = xy[[0, 1, 2, 3, 5, 6, 7], :] - head_conf = conf[[0, 1, 2, 3, 5, 6, 7]] - # set low confidence keypoints to zero weight - head_conf = np.where(head_conf < self.p_cutoff, 0, head_conf) - try: - center = np.average(head_xy, axis=0, weights=head_conf) - except ZeroDivisionError: - # If all keypoints have zero weight, return without processing - return pose - - neck = np.average(xy[[2, 3, 6, 7], :], axis=0, weights=conf[[2, 3, 6, 7]]) - - # Calculate body axis (tail_base -> neck) - body_axis = neck - xy[9] - body_axis /= sqrt(np.sum(body_axis**2)) - - # Calculate head axis (neck -> nose) - head_axis = xy[0] - neck - head_axis /= sqrt(np.sum(head_axis**2)) - - # Calculate head angle relative to body - cross = body_axis[0] * head_axis[1] - head_axis[0] * body_axis[1] - sign = copysign(1, cross) # Positive when looking left - sign = copysign(1, cross) - try: - head_angle = acos(body_axis @ head_axis) * sign - except ValueError: - head_angle = 0 - - # Calculate heading (body orientation) - heading = degrees(atan2(body_axis[1], body_axis[0])) - vals = [center[0], center[1], heading, head_angle] - - curr_time = self.timing_func() - if self.use_filter: - if self.filters is None: - self._initialize_filters(vals) - - vals = [ - self.filters["center_x"](curr_time, vals[0]), - self.filters["center_y"](curr_time, vals[1]), - self.filters["heading"](curr_time, vals[2]), - self.filters["head_angle"](curr_time, vals[3]), - ] - - # Wrap heading to [0, 360) after filtering - vals[2] = vals[2] % 360 - # Update step counter - self.curr_step = self.curr_step + 1 - - # Store processed data (only if recording) - if self.recording: - if self.save_original and self.original_pose is not None: - self.original_pose.append(pose.copy()) - self.center_x.append(vals[0]) - self.center_y.append(vals[1]) - self.heading_direction.append(vals[2]) - self.head_angle.append(vals[3]) - self.time_stamp.append(curr_time) - self.step.append(self.curr_step) - self.frame_time.append(kwargs.get("frame_time", -1)) - if "pose_time" in kwargs: - self.pose_time.append(kwargs["pose_time"]) - - payload = [curr_time, vals[0], vals[1], vals[2], vals[3]] - self.broadcast(payload) - return pose - - def get_data(self): - save_dict = super().get_data() - save_dict["x_pos"] = np.array(self.center_x) - save_dict["y_pos"] = np.array(self.center_y) - save_dict["heading_direction"] = np.array(self.heading_direction) - save_dict["head_angle"] = np.array(self.head_angle) - save_dict["use_filter"] = self.use_filter - save_dict["filter_kwargs"] = self.filter_kwargs - return save_dict - - -def get_available_processors(): - """ - Get list of available processor classes. - - Returns: - dict: Dictionary mapping registry keys to processor info. - """ - return { - name: { - "class": cls, - "name": getattr(cls, "PROCESSOR_NAME", name), - "description": getattr(cls, "PROCESSOR_DESCRIPTION", ""), - "params": getattr(cls, "PROCESSOR_PARAMS", {}), - } - for name, cls in PROCESSOR_REGISTRY.items() - } - - -def instantiate_processor(class_name, **kwargs): - """ - Instantiate a processor by class name with given parameters. - - Args: - class_name: Registry key (e.g., "MyProcessorSocket") - **kwargs: Constructor kwargs - - Raises: - ValueError: If class_name is not in registry - """ - if class_name not in PROCESSOR_REGISTRY: - available = ", ".join(PROCESSOR_REGISTRY.keys()) - raise ValueError(f"Unknown processor '{class_name}'. Available: {available}") - return PROCESSOR_REGISTRY[class_name](**kwargs) diff --git a/dlclivegui/processors/examples.py b/dlclivegui/processors/examples.py new file mode 100644 index 000000000..7e96fc068 --- /dev/null +++ b/dlclivegui/processors/examples.py @@ -0,0 +1,389 @@ +from __future__ import annotations + +import logging +from collections import deque +from math import acos, atan2, copysign, degrees, pi, sqrt + +import numpy as np + +from dlclivegui.processors import register_processor +from dlclivegui.processors.dlc_processor_socket import BaseProcessorSocket + +logger = logging.getLogger(__name__) + + +class OneEuroFilter: # pragma: no cover + def __init__(self, t0, x0, dx0=None, min_cutoff=1.0, beta=0.0, d_cutoff=1.0): + self.min_cutoff = min_cutoff + self.beta = beta + self.d_cutoff = d_cutoff + self.x_prev = x0 + if dx0 is None: + dx0 = np.zeros_like(x0) + self.dx_prev = dx0 + self.t_prev = t0 + + @staticmethod + def smoothing_factor(t_e, cutoff): + r = 2 * pi * cutoff * t_e + return r / (r + 1) + + @staticmethod + def exponential_smoothing(alpha, x, x_prev): + return alpha * x + (1 - alpha) * x_prev + + def __call__(self, t, x): + t_e = t - self.t_prev + if t_e <= 0: + return x + a_d = self.smoothing_factor(t_e, self.d_cutoff) + dx = (x - self.x_prev) / t_e + dx_hat = self.exponential_smoothing(a_d, dx, self.dx_prev) + + cutoff = self.min_cutoff + self.beta * abs(dx_hat) + a = self.smoothing_factor(t_e, cutoff) + x_hat = self.exponential_smoothing(a, x, self.x_prev) + + self.x_prev = x_hat + self.dx_prev = dx_hat + self.t_prev = t + + return x_hat + + +@register_processor +class ExampleProcessorSocketCalculateMousePose(BaseProcessorSocket): # pragma: no cover + """ + DLC Processor with pose calculations (center, heading, head angle) and optional filtering. + + Calculates: + - center: Weighted average of head keypoints + - heading: Body orientation (degrees) + - head_angle: Head rotation relative to body (radians) + + Broadcasts: [timestamp, center_x, center_y, heading, head_angle] + """ + + PROCESSOR_NAME = "Example Experiment Pose Processor" + PROCESSOR_DESCRIPTION = "Calculates mouse center, heading, and head angle with optional One-Euro filtering" + PROCESSOR_PARAMS = { + "bind": { + "type": "tuple", + "default": ("127.0.0.1", 6000), + "description": "Server address (host, port)", + }, + "authkey": { + "type": "bytes", + "default": b"secret password", + "description": "Authentication key for clients", + }, + "use_perf_counter": { + "type": "bool", + "default": False, + "description": "Use time.perf_counter() instead of time.time()", + }, + "use_filter": { + "type": "bool", + "default": False, + "description": "Apply One-Euro filter to calculated values", + }, + "filter_kwargs": { + "type": "dict", + "default": {"min_cutoff": 1.0, "beta": 0.02, "d_cutoff": 1.0}, + "description": "One-Euro filter parameters (min_cutoff, beta, d_cutoff)", + }, + "save_original": { + "type": "bool", + "default": False, + "description": "Save raw pose arrays for analysis", + }, + } + + def __init__( + self, + bind=("127.0.0.1", 6000), + authkey=b"secret password", + use_perf_counter=False, + use_filter=False, + filter_kwargs: dict | None = None, + save_original=False, + ): + super().__init__( + bind=bind, + authkey=authkey, + use_perf_counter=use_perf_counter, + save_original=save_original, + ) + + self.center_x = deque() + self.center_y = deque() + self.heading_direction = deque() + self.head_angle = deque() + + self.use_filter = use_filter + self.filter_kwargs = filter_kwargs if filter_kwargs is not None else {} + self.filters = None + + def _clear_data_queues(self): + super()._clear_data_queues() + self.center_x.clear() + self.center_y.clear() + self.heading_direction.clear() + self.head_angle.clear() + + def _initialize_filters(self, vals): + t0 = self.timing_func() + self.filters = { + "center_x": OneEuroFilter(t0, vals[0], **self.filter_kwargs), + "center_y": OneEuroFilter(t0, vals[1], **self.filter_kwargs), + "heading": OneEuroFilter(t0, vals[2], **self.filter_kwargs), + "head_angle": OneEuroFilter(t0, vals[3], **self.filter_kwargs), + } + logger.debug(f"Initialized One-Euro filters with parameters: {self.filter_kwargs}") + + def process(self, pose, **kwargs): + # Extract keypoints and confidence + xy = pose[:, :2] + conf = pose[:, 2] + + # Calculate weighted center from head keypoints + head_xy = xy[[0, 1, 2, 3, 4, 5, 6, 26], :] + head_conf = conf[[0, 1, 2, 3, 4, 5, 6, 26]] + try: + center = np.average(head_xy, axis=0, weights=head_conf) + except ZeroDivisionError: + center = np.zeros(2) + + # Calculate body axis (tail_base -> neck) + body_axis = xy[7] - xy[13] + body_axis /= sqrt(np.sum(body_axis**2)) + + # Calculate head axis (neck -> nose) + head_axis = xy[0] - xy[7] + head_axis /= sqrt(np.sum(head_axis**2)) + + # Calculate head angle relative to body + cross = body_axis[0] * head_axis[1] - head_axis[0] * body_axis[1] + sign = copysign(1, cross) # Positive when looking left + try: + head_angle = acos(body_axis @ head_axis) * sign + except ValueError: + head_angle = 0 + + # Calculate heading (body orientation) + heading = degrees(atan2(body_axis[1], body_axis[0])) + + # Raw values (heading unwrapped for filtering) + vals = [center[0], center[1], heading, head_angle] + + # Apply filtering if enabled + curr_time = self.timing_func() + if self.use_filter: + if self.filters is None: + self._initialize_filters(vals) + + vals = [ + self.filters["center_x"](curr_time, vals[0]), + self.filters["center_y"](curr_time, vals[1]), + self.filters["heading"](curr_time, vals[2]), + self.filters["head_angle"](curr_time, vals[3]), + ] + + # Wrap heading to [0, 360) after filtering + vals[2] = vals[2] % 360 + # Update step counter + self.curr_step = self.curr_step + 1 + + # Store processed data (only if recording) + if self.recording: + if self.save_original and self.original_pose is not None: + self.original_pose.append(pose.copy()) + self.center_x.append(vals[0]) + self.center_y.append(vals[1]) + self.heading_direction.append(vals[2]) + self.head_angle.append(vals[3]) + self.time_stamp.append(curr_time) + self.step.append(self.curr_step) + self.frame_time.append(kwargs.get("frame_time", -1)) + if "pose_time" in kwargs: + self.pose_time.append(kwargs["pose_time"]) + + payload = [curr_time, vals[0], vals[1], vals[2], vals[3]] + self.broadcast(payload) + return pose + + def get_data(self): + save_dict = super().get_data() + save_dict["x_pos"] = np.array(self.center_x) + save_dict["y_pos"] = np.array(self.center_y) + save_dict["heading_direction"] = np.array(self.heading_direction) + save_dict["head_angle"] = np.array(self.head_angle) + save_dict["use_filter"] = self.use_filter + save_dict["filter_kwargs"] = self.filter_kwargs + return save_dict + + +@register_processor +class ExampleProcessorSocketFilterKeypoints(BaseProcessorSocket): # pragma: no cover + PROCESSOR_NAME = "Mouse Pose with less keypoints" + PROCESSOR_DESCRIPTION = "Calculates mouse center, heading, and head angle with optional One-Euro filtering" + PROCESSOR_PARAMS = { + "bind": { + "type": "tuple", + "default": ("127.0.0.1", 6000), + "description": "Server address (host, port)", + }, + "authkey": { + "type": "bytes", + "default": b"secret password", + "description": "Authentication key for clients", + }, + "use_perf_counter": { + "type": "bool", + "default": False, + "description": "Use time.perf_counter() instead of time.time()", + }, + "use_filter": { + "type": "bool", + "default": False, + "description": "Apply One-Euro filter to calculated values", + }, + "filter_kwargs": { + "type": "dict", + "default": {"min_cutoff": 1.0, "beta": 0.02, "d_cutoff": 1.0}, + "description": "One-Euro filter parameters (min_cutoff, beta, d_cutoff)", + }, + "save_original": { + "type": "bool", + "default": True, + "description": "Save raw pose arrays for analysis", + }, + } + + def __init__( + self, + bind=("127.0.0.1", 6000), + authkey=b"secret password", + use_perf_counter=False, + use_filter=False, + filter_kwargs: dict | None = None, + save_original=True, + p_cutoff=0.4, + ): + super().__init__( + bind=bind, + authkey=authkey, + use_perf_counter=use_perf_counter, + save_original=save_original, + ) + + self.center_x = deque() + self.center_y = deque() + self.heading_direction = deque() + self.head_angle = deque() + + self.p_cutoff = p_cutoff + + self.use_filter = use_filter + self.filter_kwargs = filter_kwargs if filter_kwargs is not None else {} + self.filters = None + + def _clear_data_queues(self): + super()._clear_data_queues() + self.center_x.clear() + self.center_y.clear() + self.heading_direction.clear() + self.head_angle.clear() + + def _initialize_filters(self, vals): + t0 = self.timing_func() + self.filters = { + "center_x": OneEuroFilter(t0, vals[0], **self.filter_kwargs), + "center_y": OneEuroFilter(t0, vals[1], **self.filter_kwargs), + "heading": OneEuroFilter(t0, vals[2], **self.filter_kwargs), + "head_angle": OneEuroFilter(t0, vals[3], **self.filter_kwargs), + } + logger.debug(f"Initialized One-Euro filters with parameters: {self.filter_kwargs}") + + def process(self, pose, **kwargs): + # Extract keypoints and confidence + xy = pose[:, :2] + conf = pose[:, 2] + + # Calculate weighted center from head keypoints + head_xy = xy[[0, 1, 2, 3, 5, 6, 7], :] + head_conf = conf[[0, 1, 2, 3, 5, 6, 7]] + # set low confidence keypoints to zero weight + head_conf = np.where(head_conf < self.p_cutoff, 0, head_conf) + try: + center = np.average(head_xy, axis=0, weights=head_conf) + except ZeroDivisionError: + # If all keypoints have zero weight, return without processing + return pose + + neck = np.average(xy[[2, 3, 6, 7], :], axis=0, weights=conf[[2, 3, 6, 7]]) + + # Calculate body axis (tail_base -> neck) + body_axis = neck - xy[9] + body_axis /= sqrt(np.sum(body_axis**2)) + + # Calculate head axis (neck -> nose) + head_axis = xy[0] - neck + head_axis /= sqrt(np.sum(head_axis**2)) + + # Calculate head angle relative to body + cross = body_axis[0] * head_axis[1] - head_axis[0] * body_axis[1] + sign = copysign(1, cross) # Positive when looking left + try: + head_angle = acos(body_axis @ head_axis) * sign + except ValueError: + head_angle = 0 + + # Calculate heading (body orientation) + heading = degrees(atan2(body_axis[1], body_axis[0])) + vals = [center[0], center[1], heading, head_angle] + + curr_time = self.timing_func() + if self.use_filter: + if self.filters is None: + self._initialize_filters(vals) + + vals = [ + self.filters["center_x"](curr_time, vals[0]), + self.filters["center_y"](curr_time, vals[1]), + self.filters["heading"](curr_time, vals[2]), + self.filters["head_angle"](curr_time, vals[3]), + ] + + # Wrap heading to [0, 360) after filtering + vals[2] = vals[2] % 360 + # Update step counter + self.curr_step = self.curr_step + 1 + + # Store processed data (only if recording) + if self.recording: + if self.save_original and self.original_pose is not None: + self.original_pose.append(pose.copy()) + self.center_x.append(vals[0]) + self.center_y.append(vals[1]) + self.heading_direction.append(vals[2]) + self.head_angle.append(vals[3]) + self.time_stamp.append(curr_time) + self.step.append(self.curr_step) + self.frame_time.append(kwargs.get("frame_time", -1)) + if "pose_time" in kwargs: + self.pose_time.append(kwargs["pose_time"]) + + payload = [curr_time, vals[0], vals[1], vals[2], vals[3]] + self.broadcast(payload) + return pose + + def get_data(self): + save_dict = super().get_data() + save_dict["x_pos"] = np.array(self.center_x) + save_dict["y_pos"] = np.array(self.center_y) + save_dict["heading_direction"] = np.array(self.heading_direction) + save_dict["head_angle"] = np.array(self.head_angle) + save_dict["use_filter"] = self.use_filter + save_dict["filter_kwargs"] = self.filter_kwargs + return save_dict diff --git a/dlclivegui/processors/processor_utils.py b/dlclivegui/processors/processor_utils.py index b32445c38..b370118b0 100644 --- a/dlclivegui/processors/processor_utils.py +++ b/dlclivegui/processors/processor_utils.py @@ -5,18 +5,96 @@ import logging import pkgutil import sys +from dataclasses import dataclass, field from importlib import import_module from importlib.resources import as_file, files from pathlib import Path +from typing import Any + +from dlclivegui.config import DLC_LIFECYCLE_EXTRA_LOGS logger = logging.getLogger(__name__) +@dataclass +class ProcessorSpec: + cls: type + kwargs: dict[str, Any] = field(default_factory=dict) + + @property + def name(self) -> str: + return getattr(self.cls, "PROCESSOR_NAME", self.cls.__name__) + + def build(self) -> Any: + """Instantiate the processor class with the provided kwargs.""" + log_processor_context(f"ProcessorSpec.build: {self.name} with kwargs={self.kwargs}", logger) + return self.cls(**self.kwargs) + + def default_processors_dir() -> str: with as_file(files("dlclivegui").joinpath("processors")) as path: return str(path) +def _processor_base_class(): + from dlclive.processor import Processor + + return Processor + + +def _is_processor_subclass(obj, *, include_base: bool = False) -> bool: + """Return True for dlclive.Processor subclasses, including indirect subclasses.""" + if not inspect.isclass(obj): + return False + + try: + processor_base = _processor_base_class() + except Exception: + logger.exception("Could not import dlclive.Processor") + return False + + try: + if obj is processor_base: + return bool(include_base) + return issubclass(obj, processor_base) + except Exception: + logger.exception(f"Error checking if {obj} is a subclass of dlclive.Processor") + return False + + +def _processor_info_from_class(cls, fallback_name: str) -> dict: + return { + "class": cls, + "name": getattr(cls, "PROCESSOR_NAME", fallback_name), + "description": getattr(cls, "PROCESSOR_DESCRIPTION", ""), + "params": getattr(cls, "PROCESSOR_PARAMS", {}), + } + + +def discover_processor_classes(module, *, only_defined_in_module: bool = True) -> dict[str, dict]: + """Discover dlclive.Processor subclasses in a module. + + Includes indirect subclasses of Processor. + + Args: + module: Imported Python module. + only_defined_in_module: If True, ignore Processor subclasses imported + from other modules to avoid duplicate registry entries. + """ + processors: dict[str, dict] = {} + + for name, obj in inspect.getmembers(module, inspect.isclass): + if only_defined_in_module and getattr(obj, "__module__", None) != module.__name__: + continue + + if not _is_processor_subclass(obj): + continue + + processors[name] = _processor_info_from_class(obj, name) + + return processors + + def scan_processor_folder(folder_path): all_processors = {} folder = Path(folder_path) @@ -39,11 +117,9 @@ def scan_processor_folder(folder_path): return all_processors -def scan_processor_package(package_name: str = "dlclivegui.processors") -> dict[str | dict]: +def scan_processor_package(package_name: str = "dlclivegui.processors") -> dict[str, dict]: """ Discover and load processor classes from a package namespace. - Returns a dict keyed as 'module.py::ClassName' with the same - structure you use today. """ all_processors: dict[str, dict] = {} @@ -59,28 +135,16 @@ def scan_processor_package(package_name: str = "dlclivegui.processors") -> dict[ continue try: mod = import_module(mod_name) + # Skip dlc_processor_socket.py as it's the base class and registry + if mod.__name__.endswith("dlc_processor_socket"): + continue # Prefer module-level registry function if present if hasattr(mod, "get_available_processors"): processors = mod.get_available_processors() else: # Fallback: scan for dlclive.Processor subclasses - from dlclive import Processor - - processors = {} - for attr_name in dir(mod): - obj = getattr(mod, attr_name) - try: - if isinstance(obj, type) and obj is not Processor and issubclass(obj, Processor): - processors[attr_name] = { - "class": obj, - "name": getattr(obj, "PROCESSOR_NAME", attr_name), - "description": getattr(obj, "PROCESSOR_DESCRIPTION", ""), - "params": getattr(obj, "PROCESSOR_PARAMS", {}), - } - except Exception: - # Non-class or weird metaclass; ignore - pass + processors = discover_processor_classes(mod) # Normalize into your “file::class” shape module_file = mod.__name__.split(".")[-1] + ".py" @@ -131,26 +195,7 @@ def load_processors_from_file(file_path: str | Path): return processors # Fallback path: discover subclasses of dlclive.Processor - from dlclive import Processor - - processors: dict[str, dict] = {} - for name, obj in inspect.getmembers(module, inspect.isclass): - if obj is Processor: - continue - # Guard: module might define other classes; only include Processor subclasses - try: - if issubclass(obj, Processor): - processors[name] = { - "class": obj, - "name": getattr(obj, "PROCESSOR_NAME", name), - "description": getattr(obj, "PROCESSOR_DESCRIPTION", ""), - "params": getattr(obj, "PROCESSOR_PARAMS", {}), - } - except Exception: - # Some "classes" can fail issubclass checks; ignore safely - continue - - return processors + return discover_processor_classes(module) except Exception: # Full traceback helps a ton when a plugin fails to import @@ -158,6 +203,28 @@ def load_processors_from_file(file_path: str | Path): return {} +def create_spec_from_scan(processors_dict, processor_key, **kwargs) -> ProcessorSpec: + """Create a ProcessorSpec from scan_processor_folder results, without instantiating the processor yet.""" + if processor_key not in processors_dict: + available = ", ".join(processors_dict.keys()) + raise ValueError(f"Unknown processor '{processor_key}'. Available: {available}") + + processor_info = processors_dict[processor_key] + processor_class = processor_info["class"] + return ProcessorSpec(cls=processor_class, kwargs=kwargs) + + +def processor_builds_in_worker(processor_class: type) -> bool: + """ + Return True if this processor class requests construction inside DLCLiveWorker. + + Processors opt in by defining: + + PROCESSOR_BUILD_IN_WORKER = True + """ + return bool(getattr(processor_class, "PROCESSOR_BUILD_IN_WORKER", False)) + + def instantiate_from_scan(processors_dict, processor_key, **kwargs): """ Instantiate a processor from scan_processor_folder results. @@ -202,3 +269,40 @@ def display_processor_info(processors): print(f" - {param_name} ({param_info['type']})") print(f" Default: {param_info['default']}") print(f" {param_info['description']}") + + +def log_processor_context(label: str, custom_logger: logging.Logger = logger): + if not DLC_LIFECYCLE_EXTRA_LOGS: + return + + import multiprocessing as mp + import os + import threading + import time + + custom_logger.info( + "[CUSTOM PROCESSOR] %s | pid=%s process=%s thread=%s time=%.6f", + label, + os.getpid(), + mp.current_process().name, + threading.current_thread().name, + time.time(), + ) + + +def log_dlclive_context(logger: logging.Logger = logger): + runner_logger = logging.getLogger("dlclive.pose_estimation_pytorch.runner") + + runner_logger.disabled = False + runner_logger.setLevel(logger.getEffectiveLevel()) + runner_logger.propagate = True + + logger.debug( + "[DLC DEBUG] runner logger state: name=%s disabled=%s level=%s effective_level=%s propagate=%s handlers=%s", + runner_logger.name, + runner_logger.disabled, + logging.getLevelName(runner_logger.level), + logging.getLevelName(runner_logger.getEffectiveLevel()), + runner_logger.propagate, + [type(handler).__name__ for handler in runner_logger.handlers], + ) diff --git a/dlclivegui/processors/registry.py b/dlclivegui/processors/registry.py new file mode 100644 index 000000000..28892975e --- /dev/null +++ b/dlclivegui/processors/registry.py @@ -0,0 +1,53 @@ +import logging + +logger = logging.getLogger(__name__) + +# Registry for GUI discovery +PROCESSOR_REGISTRY = {} + + +def register_processor(cls): + registry_key = getattr(cls, "PROCESSOR_ID", cls.__name__) + if registry_key in PROCESSOR_REGISTRY: + msg = ( + f"Duplicate processor registration key '{registry_key}': " + f"{PROCESSOR_REGISTRY[registry_key].__name__} vs {cls.__name__}" + ) + logger.warning(msg) + PROCESSOR_REGISTRY[registry_key] = cls + return cls + + +def get_available_processors(): + """ + Get list of available processor classes. + + Returns: + dict: Dictionary mapping registry keys to processor info. + """ + return { + name: { + "class": cls, + "name": getattr(cls, "PROCESSOR_NAME", name), + "description": getattr(cls, "PROCESSOR_DESCRIPTION", ""), + "params": getattr(cls, "PROCESSOR_PARAMS", {}), + } + for name, cls in PROCESSOR_REGISTRY.items() + } + + +def instantiate_processor(class_name, **kwargs): + """ + Instantiate a processor by class name with given parameters. + + Args: + class_name: Registry key (e.g., "MyProcessorSocket") + **kwargs: Constructor kwargs + + Raises: + ValueError: If class_name is not in registry + """ + if class_name not in PROCESSOR_REGISTRY: + available = ", ".join(PROCESSOR_REGISTRY.keys()) + raise ValueError(f"Unknown processor '{class_name}'. Available: {available}") + return PROCESSOR_REGISTRY[class_name](**kwargs) diff --git a/dlclivegui/services/camera_controller.py b/dlclivegui/services/camera_controller.py new file mode 100644 index 000000000..57173530f --- /dev/null +++ b/dlclivegui/services/camera_controller.py @@ -0,0 +1,258 @@ +from __future__ import annotations + +import copy +import logging +import time +from threading import Event, Lock + +import cv2 +import numpy as np +from PySide6.QtCore import QObject, Signal, Slot + +from dlclivegui.cameras import CameraFactory +from dlclivegui.cameras.base import CameraBackend + +# from dlclivegui.config import CameraSettings +from dlclivegui.config import ( + SINGLE_CAMERA_WORKER_DO_LOG_TIMING, + CameraSettings, +) +from dlclivegui.utils.stats import WorkerTimingStats + +logger = logging.getLogger(__name__) + + +class SingleCameraWorker(QObject): + """Worker for a single camera in multi-camera mode.""" + + frame_captured = Signal(str, object, float, object) # camera_id, frame, timestamp, timestamp_metadata + error_occurred = Signal(str, str) # camera_id, error_message + runtime_info = Signal(str, object) # camera_id, dict of runtime info + started = Signal(str) # camera_id + stopped = Signal(str) # camera_id + + def __init__(self, camera_id: str, settings: CameraSettings): + super().__init__() + self._camera_id = camera_id + self._settings = copy.deepcopy(settings) + self._stop_event = Event() + self._backend: CameraBackend | None = None + self._max_consecutive_errors = 5 + self._retry_delay = 0.1 + self._trigger_timeout_delay = 0.05 + self._trigger_wait_log_interval = 2.0 + self._last_trigger_wait_log = 0.0 + self._trigger_wait_suppressed_count = 0 + + self._recording_sink = None + self._recording_enabled = False + self._recording_sink_lock = Lock() + + # Performance logs + self._timing = WorkerTimingStats( + camera_id, logger=logger, log_interval=1.0, enabled=SINGLE_CAMERA_WORKER_DO_LOG_TIMING + ) + + def set_recording_sink(self, sink) -> None: + with self._recording_sink_lock: + self._recording_sink = sink + + def set_recording_enabled(self, enabled: bool) -> None: + with self._recording_sink_lock: + self._recording_enabled = bool(enabled) + + @Slot() + def run(self) -> None: + self._stop_event.clear() + + try: + logger.debug( + "[Worker %s] before create: backend=%s index=%s properties=%s", + self._camera_id, + self._settings.backend, + self._settings.index, + self._settings.properties, + ) + + self._backend = CameraFactory.create(self._settings) + + logger.debug( + "[Worker %s] after create: backend=%s index=%s properties=%s", + self._camera_id, + self._backend.settings.backend, + self._backend.settings.index, + self._backend.settings.properties, + ) + + self._backend.open() + self.runtime_info.emit( + self._camera_id, + { + "actual_fps": getattr(self._backend, "actual_fps", None), + "actual_resolution": getattr(self._backend, "actual_resolution", None), + "actual_pixel_format": getattr(self._backend, "actual_pixel_format", None), + "actual_output_format": getattr(self._backend, "actual_output_format", None), + }, + ) + except Exception as exc: + logger.exception(f"Failed to initialize camera {self._camera_id}", exc_info=exc) + self.error_occurred.emit(self._camera_id, f"Failed to initialize camera: {exc}") + self.stopped.emit(self._camera_id) + return + + self.started.emit(self._camera_id) + consecutive_errors = 0 + + while not self._stop_event.is_set(): + try: + with self._timing.measure("Single.read"): + captured = self._backend.read() + frame = captured.frame + timestamp = captured.software_timestamp + timestamp_metadata = captured.timestamp_metadata + if frame is None or frame.size == 0: + consecutive_errors += 1 + if consecutive_errors >= self._max_consecutive_errors: + self.error_occurred.emit( + self._camera_id, "Too many empty frames.\nWas the device disconnected ?" + ) + break + if self._stop_event.wait(self._retry_delay): + break + continue + + consecutive_errors = 0 + with self._timing.measure("Single.transforms"): + frame = self._apply_worker_transforms(frame) + + with self._recording_sink_lock: + recording_enabled = self._recording_enabled + recording_sink = self._recording_sink + + if recording_enabled and recording_sink is not None: + try: + with self._timing.measure("Single.recording_sink"): + recording_sink(self._camera_id, frame, timestamp, timestamp_metadata) + except Exception as exc: + logger.exception(f"Failed to write frame for camera {self._camera_id}: {exc}") + + with self._timing.measure("Single.emit"): + self.frame_captured.emit(self._camera_id, frame, timestamp, timestamp_metadata) + + self._timing.note_frame() + self._timing.maybe_log() + + except TimeoutError as exc: + self._timing.note_timeout() + self._timing.maybe_log() + if self._stop_event.is_set(): + break + + # In hardware-trigger mode, a timeout usually means: + # "no trigger pulse arrived during this poll interval". + # This is expected and should not count as a camera failure. + if bool(getattr(self._backend, "waits_for_hardware_trigger", False)): + self._log_trigger_wait_throttled(exc) + consecutive_errors = 0 + + if self._stop_event.wait(self._trigger_timeout_delay): + break # Stop event set during wait + continue + + consecutive_errors += 1 + if consecutive_errors >= self._max_consecutive_errors: + self.error_occurred.emit(self._camera_id, f"Camera read timeout: {exc}") + break + if self._stop_event.wait(self._retry_delay): + break + continue + + except Exception as exc: + self._timing.note_error() + self._timing.maybe_log() + consecutive_errors += 1 + if self._stop_event.is_set(): + break + if consecutive_errors >= self._max_consecutive_errors: + self.error_occurred.emit(self._camera_id, f"Camera read error: {exc}") + break + if self._stop_event.wait(self._retry_delay): + break + continue + + # Cleanup + if self._backend is not None: + try: + self._backend.close() + except Exception: + pass + self.stopped.emit(self._camera_id) + + def stop(self) -> None: + self._stop_event.set() + + @staticmethod + def apply_rotation(frame: np.ndarray, degrees: int) -> np.ndarray: + """Apply rotation to frame.""" + if degrees == 90: + return cv2.rotate(frame, cv2.ROTATE_90_CLOCKWISE) + elif degrees == 180: + return cv2.rotate(frame, cv2.ROTATE_180) + elif degrees == 270: + return cv2.rotate(frame, cv2.ROTATE_90_COUNTERCLOCKWISE) + return frame + + @staticmethod + def apply_crop(frame: np.ndarray, crop_region: tuple[int, int, int, int]) -> np.ndarray: + """Apply crop to frame.""" + x0, y0, x1, y1 = crop_region + height, width = frame.shape[:2] + + x0 = max(0, min(x0, width)) + y0 = max(0, min(y0, height)) + x1 = max(x0, min(x1, width)) if x1 > 0 else width + y1 = max(y0, min(y1, height)) if y1 > 0 else height + + if x0 < x1 and y0 < y1: + return frame[y0:y1, x0:x1] + return frame + + def _apply_worker_transforms(self, frame: np.ndarray) -> np.ndarray: + if self._settings.rotation: + frame = self.apply_rotation(frame, self._settings.rotation) + + crop_region = self._settings.get_crop_region() + if crop_region: + frame = self.apply_crop(frame, crop_region) + + return frame + + def _log_trigger_wait_throttled(self, exc: BaseException) -> None: + """Log hardware-trigger wait timeouts at a controlled rate. + + In trigger-waiting modes, read timeouts are expected polling misses. + Without throttling, the log can be flooded at ~10-20 messages/sec/camera. + """ + now = time.monotonic() + + if now - self._last_trigger_wait_log < self._trigger_wait_log_interval: + self._trigger_wait_suppressed_count += 1 + return + + suppressed = self._trigger_wait_suppressed_count + self._trigger_wait_suppressed_count = 0 + self._last_trigger_wait_log = now + + if suppressed: + logger.debug( + "[Worker %s] waiting for hardware trigger: %s (suppressed %d repeated timeout logs)", + self._camera_id, + exc, + suppressed, + ) + else: + logger.debug( + "[Worker %s] waiting for hardware trigger: %s", + self._camera_id, + exc, + ) diff --git a/dlclivegui/services/dlc_processor.py b/dlclivegui/services/dlc_processor.py index b4476e116..f5a88c32b 100644 --- a/dlclivegui/services/dlc_processor.py +++ b/dlclivegui/services/dlc_processor.py @@ -16,9 +16,18 @@ import numpy as np from PySide6.QtCore import QObject, Signal -from dlclivegui.config import DLCProcessorSettings, ModelType -from dlclivegui.processors.processor_utils import instantiate_from_scan +from dlclivegui.config import DLC_DO_LOG_TIMING, DLCProcessorSettings, ModelType +from dlclivegui.processors.dlc_processor_socket import BaseProcessorSocket +from dlclivegui.processors.processor_utils import ( + ProcessorSpec, + create_spec_from_scan, + instantiate_from_scan, + log_dlclive_context, + log_processor_context, +) from dlclivegui.temp import Engine # type: ignore # TODO use main package enum when released +from dlclivegui.utils.stats import WorkerTimingStats +from dlclivegui.utils.utils import format_thread_stack logger = logging.getLogger(__name__) STOP_WORKER_TIMEOUT = 10.0 # # seconds to wait in STOPPING state before scheduling background reaping @@ -154,6 +163,8 @@ def __init__(self) -> None: self._settings = DLCProcessorSettings() self._dlc: Any | None = None self._processor: Any | None = None + self._processor_spec: ProcessorSpec | None = None + self.processor_built_from_spec = False # Worker thread and queue self._queue: queue.Queue[Any] | None = None self._worker_thread: threading.Thread | None = None @@ -181,19 +192,40 @@ def __init__(self) -> None: self._gpu_inference_times: deque[float] = deque(maxlen=60) self._processor_overhead_times: deque[float] = deque(maxlen=60) + self._timing = WorkerTimingStats( + "DLCLiveProcessor", + logger=logger, + log_interval=1.0, + enabled=bool(DLC_DO_LOG_TIMING or ENABLE_PROFILING), + ) + @staticmethod def get_model_backend(model_path: str) -> Engine: return Engine.from_model_path(model_path) - def configure(self, settings: DLCProcessorSettings, processor: Any | None = None) -> None: + def configure( + self, settings: DLCProcessorSettings, processor: Any | None = None, processor_spec: ProcessorSpec | None = None + ) -> None: + logger.debug("[DLC CONFIG] %s", settings.model_dump()) + with self._lifecycle_lock: if self._state != WorkerState.STOPPED: raise RuntimeError("Cannot configure DLCLiveProcessor while it is running. Please stop it first.") + if processor is not None and processor_spec is not None: + raise ValueError( + "Cannot provide both a processor instance and a processor_spec. Please provide only one." + ) self._settings = settings self._processor = processor + self._processor_spec = processor_spec + self.processor_built_from_spec = False - def reset(self) -> None: + def reset(self, reset_processor_plugin: bool = False) -> None: """Stop the worker thread and drop the current DLCLive instance.""" + had_runtime = ( + self._worker_thread is not None or self._dlc is not None or self._initialized or reset_processor_plugin + ) + stopped = self._stop_worker() if not stopped: with self._lifecycle_lock: @@ -202,6 +234,10 @@ def reset(self) -> None: "Reset requested but worker thread is still alive; skipping DLCLive reset to avoid potential issues." ) return + + if had_runtime and self._processor is not None: + self._cleanup_processor() + self._dlc = None self._initialized = False with self._stats_lock: @@ -217,10 +253,28 @@ def reset(self) -> None: self._gpu_inference_times.clear() self._processor_overhead_times.clear() + def _cleanup_processor(self) -> None: + proc = self._processor + if proc is None: + return + + stop = getattr(proc, "stop", None) + if callable(stop): + try: + log_processor_context(f"Stopping processor: {type(proc).__name__}", logger) + stop() + except Exception: + logger.exception("Failed to stop processor cleanly") + + self._processor = None + self._processor_built_from_spec = False + def shutdown(self) -> None: stopped = self._stop_worker() if not stopped: with self._lifecycle_lock: + if self._processor is not None: + self._cleanup_processor() self._pending_reset = True logger.warning( "Shutdown requested but worker thread is still alive; DLCLive instance may not be fully released." @@ -232,24 +286,40 @@ def shutdown(self) -> None: def enqueue_frame(self, frame: np.ndarray, timestamp: float) -> None: # Keep lifecycle lock held only for quick state checks and snapshots. with self._lifecycle_lock: - if self._state in (WorkerState.STOPPING, WorkerState.FAULTED) or self._stop_event.is_set(): + if ( + self._state in (WorkerState.STOPPING, WorkerState.FAULTED, WorkerState.STARTING) + or self._stop_event.is_set() + ): return t = self._worker_thread q = self._queue should_start = t is None or not t.is_alive() - frame_c = frame.copy() + with self._timing.measure("DLC.enqueue.copy_frame"): + frame_c = frame.copy() enq_time = time.perf_counter() if should_start: # Re-acquire the lifecycle lock to safely (re)start the worker if needed. with self._lifecycle_lock: # Re-check state in case it changed while we were copying the frame. - if self._state in (WorkerState.STOPPING, WorkerState.FAULTED) or self._stop_event.is_set(): + if ( + self._state in (WorkerState.STOPPING, WorkerState.FAULTED, WorkerState.STARTING) + or self._stop_event.is_set() + ): return t = self._worker_thread if t is None or not t.is_alive(): - # _start_worker_locked expects the lifecycle lock to be held. + if logger.isEnabledFor(logging.DEBUG): + logger.debug( + "Starting DLC worker from first frame: " + "shape=%s dtype=%s contiguous=%s strides=%s timestamp=%.6f", + frame_c.shape, + frame_c.dtype, + frame_c.flags["C_CONTIGUOUS"], + frame_c.strides, + timestamp, + ) self._start_worker_locked(frame_c, timestamp) return # Worker is now running; refresh queue snapshot. @@ -258,13 +328,30 @@ def enqueue_frame(self, frame: np.ndarray, timestamp: float) -> None: if q is None: return - try: - q.put_nowait((frame_c, timestamp, enq_time)) - with self._stats_lock: - self._frames_enqueued += 1 - except queue.Full: - with self._stats_lock: - self._frames_dropped += 1 + item = (frame_c, timestamp, enq_time) + + while True: + try: + with self._timing.measure("DLC.enqueue.put"): + q.put_nowait(item) + with self._stats_lock: + self._frames_enqueued += 1 + return + + except queue.Full: + try: + with self._timing.measure("DLC.enqueue.drop_stale"): + q.get_nowait() + try: + q.task_done() + except ValueError: + pass + + with self._stats_lock: + self._frames_dropped += 1 + + except queue.Empty: + continue def get_stats(self) -> ProcessorStats: """Get current processing statistics.""" @@ -317,13 +404,94 @@ def get_stats(self) -> ProcessorStats: avg_processor_overhead=avg_proc_overhead, ) + def _debug_log_dlc_runner_device(self) -> None: + if not logger.isEnabledFor(logging.DEBUG): + return + + try: + import torch + + logger.debug( + "Torch CUDA state: available=%s built=%s device_count=%s current_device=%s device_name=%s " + "allocated=%.2fMB reserved=%.2fMB", + torch.cuda.is_available(), + torch.backends.cuda.is_built(), + torch.cuda.device_count(), + torch.cuda.current_device() if torch.cuda.is_available() else None, + torch.cuda.get_device_name(0) if torch.cuda.is_available() and torch.cuda.device_count() else None, + torch.cuda.memory_allocated(0) / (1024 * 1024) if torch.cuda.is_available() else 0.0, + torch.cuda.memory_reserved(0) / (1024 * 1024) if torch.cuda.is_available() else 0.0, + ) + except Exception: + logger.debug("Could not query torch CUDA state", exc_info=True) + + dlc = self._dlc + runner = getattr(dlc, "runner", None) + + logger.debug( + "DLCLive runner: type=%s runner.device=%r runner.model=%r runner.net=%r", + type(runner).__name__ if runner is not None else None, + getattr(runner, "device", None), + type(getattr(runner, "model", None)).__name__ if getattr(runner, "model", None) is not None else None, + type(getattr(runner, "net", None)).__name__ if getattr(runner, "net", None) is not None else None, + ) + + seen: set[int] = set() + + def walk(obj, path: str, depth: int = 0) -> None: + if obj is None or depth > 7: + return + + oid = id(obj) + if oid in seen: + return + seen.add(oid) + + try: + params = getattr(obj, "parameters", None) + if callable(params): + first_param = next(iter(params()), None) + if first_param is not None: + logger.debug( + "Torch module at %s: parameter device=%s is_cuda=%s dtype=%s shape=%s", + path, + first_param.device, + first_param.is_cuda, + first_param.dtype, + tuple(first_param.shape), + ) + except Exception: + pass + + for name in ( + "runner", + "model", + "net", + "pose_model", + "dlc_model", + "module", + "engine", + "predictor", + "detector", + "backbone", + ): + try: + child = getattr(obj, name, None) + except Exception: + child = None + if child is not None: + walk(child, f"{path}.{name}", depth + 1) + + walk(self._dlc, "self._dlc") + def _start_worker_locked(self, init_frame: np.ndarray, init_timestamp: float) -> None: # lifecycle_lock must already be held if self._worker_thread is not None and self._worker_thread.is_alive(): return - self._queue = queue.Queue(maxsize=1) + self._queue = None self._stop_event.clear() self._state = WorkerState.STARTING + log_processor_context("Starting DLCLive worker thread", logger) self._worker_thread = threading.Thread( target=self._worker_loop, args=(init_frame, init_timestamp), @@ -349,7 +517,7 @@ def _stop_worker(self) -> bool: t.join(timeout=STOP_WORKER_TIMEOUT) if t.is_alive(): qsize = self._queue.qsize() if self._queue is not None else -1 - logger.warning("DLC worker thread did not terminate cleanly (qsize=%s)", qsize) + logger.warning("DLC worker thread did not terminate cleanly (qsize=%s)\n%s", qsize, format_thread_stack(t)) self._schedule_reap(t) return False @@ -412,7 +580,8 @@ def _timed_processor(self): def timed_process(pose, _op=original, _holder=holder, **kwargs): start = time.perf_counter() try: - return _op(pose, **kwargs) + with self._timing.measure("DLC.processor.process"): + return _op(pose, **kwargs) finally: _holder[0] = time.perf_counter() - start @@ -423,6 +592,24 @@ def timed_process(pose, _op=original, _holder=holder, **kwargs): # Restore even if inference/errors occur self._processor.process = original + @staticmethod + def _prepare_input_frame(frame: np.ndarray) -> np.ndarray: + """Normalize camera frames for DLCLive inference.""" + arr = np.asarray(frame) + + if arr.ndim == 2: + # Mono8 / grayscale -> 3-channel + arr = np.repeat(arr[:, :, None], 3, axis=2) + elif arr.ndim == 3 and arr.shape[2] == 4: + arr = arr[:, :, :3] + elif arr.ndim != 3 or arr.shape[2] != 3: + raise ValueError(f"Unsupported DLCLive input frame shape: {arr.shape}") + + if arr.dtype != np.uint8: + arr = np.clip(arr, 0, 255).astype(np.uint8, copy=False) + + return np.ascontiguousarray(arr) + def _process_frame( self, frame: np.ndarray, @@ -437,12 +624,25 @@ def _process_frame( """ if self._dlc is None: raise RuntimeError("DLCLive instance is not initialized.") + # log_processor_context(f"DLCLiveProcessor._process_frame: timestamp={timestamp:.6f}", logger) # Time GPU inference (and processor overhead when present) + with self._timing.measure("DLC.prepare_frame"): + frame = self._prepare_input_frame(frame) with self._timed_processor() as proc_holder: inference_start = time.perf_counter() - raw_pose: Any = self._dlc.get_pose(frame, frame_time=timestamp) + + with self._timing.measure("DLC.process_frame"): + processed_frame = self._dlc.process_frame(frame) + + with self._timing.measure("DLC.runner.get_pose"): + self._dlc.pose = self._dlc.runner.get_pose(processed_frame) + + with self._timing.measure("DLC.post_process_pose"): + raw_pose: Any = self._dlc._post_process_pose(processed_frame, frame_time=timestamp) + inference_time = time.perf_counter() - inference_start - pose_arr: np.ndarray = validate_pose_array(raw_pose, source_backend=PoseBackends.DLC_LIVE) + with self._timing.measure("DLC.validate_pose"): + pose_arr: np.ndarray = validate_pose_array(raw_pose, source_backend=PoseBackends.DLC_LIVE) pose_packet = PosePacket( schema_version=0, keypoints=pose_arr, @@ -460,7 +660,8 @@ def _process_frame( # Emit pose (measure signal overhead) signal_start = time.perf_counter() - self.pose_ready.emit(PoseResult(pose=pose_packet.keypoints, timestamp=timestamp, packet=pose_packet)) + with self._timing.measure("DLC.emit.pose_ready"): + self.pose_ready.emit(PoseResult(pose=pose_packet.keypoints, timestamp=timestamp, packet=pose_packet)) signal_time = time.perf_counter() - signal_start end_ts = time.perf_counter() @@ -481,68 +682,194 @@ def _process_frame( self._gpu_inference_times.append(gpu_inference_time) self._processor_overhead_times.append(processor_overhead) + self._timing.note_frame() + self._timing.maybe_log() self.frame_processed.emit() def _worker_loop(self, init_frame: np.ndarray, init_timestamp: float) -> None: try: - # -------- Initialization (unchanged) -------- + # -------- Initialization -------- if not self._settings.model_path: raise RuntimeError("No DLCLive model path configured.") - init_start = time.perf_counter() - dyn = self._settings.dynamic - if not isinstance(dyn, (list, tuple)) or len(dyn) != 3: - try: - dyn = dyn.to_tuple() - except Exception as e: - raise RuntimeError("Invalid dynamic crop settings format.") from e - enabled, margin, max_missing = dyn - - options = { - "model_path": self._settings.model_path, - "model_type": self._settings.model_type, - "processor": self._processor, - "dynamic": [enabled, margin, max_missing], - "resize": self._settings.resize, - "precision": self._settings.precision, - "single_animal": self._settings.single_animal, - } - if self._settings.device is not None: - options["device"] = self._settings.device + log_processor_context("DLCLiveProcessor._worker_loop", logger) + with self._timing.measure("DLC.build_options"): + dyn = self._settings.dynamic + if not isinstance(dyn, (list, tuple)) or len(dyn) != 3: + try: + dyn = dyn.to_tuple() + except Exception as e: + raise RuntimeError("Invalid dynamic crop settings format.") from e + enabled, margin, max_missing = dyn + + custom_proc = None + + if self._processor is not None: + custom_proc = self._processor + log_processor_context( + f"Using existing processor instance: {type(custom_proc).__name__}", + logger, + ) + + elif self._processor_spec is not None: + log_processor_context( + f"Building processor from spec: {self._processor_spec.name}", + logger, + ) + custom_proc = self._processor_spec.build() + + with self._lifecycle_lock: + self._processor = custom_proc + self._processor_built_from_spec = True + + else: + custom_proc = None + + options = { + "model_path": self._settings.model_path, + "model_type": self._settings.model_type, + "processor": custom_proc, + "dynamic": [enabled, margin, max_missing], + "resize": self._settings.resize, + "precision": self._settings.precision, + "single_animal": self._settings.single_animal, + } + + additional_options = dict(self._settings.additional_options or {}) + + protected_options = { + "model_path", + "model_type", + "processor", + } + + unexpected_overrides = protected_options & additional_options.keys() + + if unexpected_overrides: + logger.warning( + "Protected additional options ignored: %s", + sorted(unexpected_overrides), + ) + + options.update( + {key: value for key, value in additional_options.items() if key not in protected_options} + ) + + if self._settings.device is not None: + options["device"] = self._settings.device + + logger.info( + "DLCLive options: device=%r precision=%r dynamic=%r top_down_config=%r single_animal=%r", + options.get("device", "auto"), + options.get("precision"), + options.get("dynamic"), + options.get("top_down_config"), + options.get("single_animal"), + ) + + if logger.isEnabledFor(logging.DEBUG): + logger.debug( + "DLC worker starting: model_path=%s model_type=%s device=%s " + "init_frame_shape=%s dtype=%s contiguous=%s", + self._settings.model_path, + self._settings.model_type, + self._settings.device, + init_frame.shape, + init_frame.dtype, + init_frame.flags["C_CONTIGUOUS"], + ) try: if DLCLive is None: raise RuntimeError( "DLCLive class is not available. Ensure the dlclive package is installed and can be imported." ) - self._dlc = DLCLive(**options) + with self._timing.measure("DLC.construct"): + logger.info( + "DLCLive options: device=%r precision=%r dynamic=%r top_down_config=%r single_animal=%r", + options.get("device", "auto"), + options.get("precision"), + options.get("dynamic"), + options.get("top_down_config"), + options.get("single_animal"), + ) + + self._dlc = DLCLive(**options) + log_dlclive_context(logger) + log_processor_context("DLCLive instance constructed", logger) + + self._timing.maybe_log() except Exception as exc: + self._timing.note_error() + self._timing.maybe_log() with self._lifecycle_lock: self._state = WorkerState.FAULTED raise RuntimeError( f"Failed to initialize DLCLive with model '{self._settings.model_path}': {exc}" ) from exc + if self._stop_event.is_set(): + logger.debug("DLC worker stop requested during construction; exiting before init_inference.") + return + + with self._timing.measure("DLC.prepare_init_frame"): + init_frame = self._prepare_input_frame(init_frame) + + if logger.isEnabledFor(logging.DEBUG): + logger.debug( + "Calling DLCLive.init_inference with frame shape=%s dtype=%s contiguous=%s", + init_frame.shape, + init_frame.dtype, + init_frame.flags["C_CONTIGUOUS"], + ) # First inference to initialize - init_inference_start = time.perf_counter() - self._dlc.init_inference(init_frame) - init_inference_time = time.perf_counter() - init_inference_start + with self._timing.measure("DLC.init_inference"): + self._dlc.init_inference(init_frame) + log_processor_context("DLCLive init_inference completed", logger) + runner = self._dlc.runner + top_down_config = getattr( + runner, + "top_down_config", + None, + ) + + logger.info( + "Runner options: device=%r precision=%r detector=%s dynamic=%s skip_frames=%r", + getattr(runner, "device", None), + getattr(runner, "precision", None), + type(getattr(runner, "detector", None)).__name__ + if getattr(runner, "detector", None) is not None + else None, + type(getattr(runner, "dynamic", None)).__name__ + if getattr(runner, "dynamic", None) is not None + else None, + getattr(top_down_config, "skip_frames", None), + ) + + self._debug_log_dlc_runner_device() + self._timing.note_frame() + self._timing.maybe_log() + + if self._stop_event.is_set(): + logger.debug("DLC worker stop requested after init_inference; exiting before RUNNING state.") + return # Pass DLCLive cfg to processor if available if hasattr(self._dlc, "processor") and hasattr(self._dlc.processor, "set_dlc_cfg"): - self._dlc.processor.set_dlc_cfg(getattr(self._dlc, "cfg", None)) + with self._timing.measure("DLC.processor.set_dlc_cfg"): + self._dlc.processor.set_dlc_cfg(getattr(self._dlc, "cfg", None)) self._initialized = True self.initialized.emit(True) with self._lifecycle_lock: + if self._stop_event.is_set(): + logger.debug("DLC worker stop requested before RUNNING state; exiting.") + return + + self._queue = queue.Queue(maxsize=1) self._state = WorkerState.RUNNING - total_init_time = time.perf_counter() - init_start - logger.info( - "DLCLive model initialized successfully (total: %.3fs, init_inference: %.3fs)", - total_init_time, - init_inference_time, - ) + logger.info("DLCLive model initialized successfully") # Emit pose for init frame & update stats (not dequeued) self._process_frame(init_frame, init_timestamp, time.perf_counter(), queue_wait_time=0.0) @@ -583,6 +910,8 @@ def _worker_loop(self, init_frame: np.ndarray, init_timestamp: float) -> None: try: self._process_frame(frame, ts, enq, queue_wait_time=0.0) except Exception as exc: + self._timing.note_error() + self._timing.maybe_log() logger.exception("Pose inference failed", exc_info=exc) self.error.emit(str(exc)) finally: @@ -595,11 +924,14 @@ def _worker_loop(self, init_frame: np.ndarray, init_timestamp: float) -> None: # Normal operation: timed get try: wait_start = time.perf_counter() - item = q.get(timeout=0.05) + with self._timing.measure("DLC.queue_get"): + item = q.get(timeout=0.05) queue_wait_time = time.perf_counter() - wait_start except queue.Empty: continue except Exception as exc: + self._timing.note_error() + self._timing.maybe_log() logger.exception("Error getting item from queue", exc_info=exc) with self._lifecycle_lock: self._state = WorkerState.FAULTED @@ -610,6 +942,8 @@ def _worker_loop(self, init_frame: np.ndarray, init_timestamp: float) -> None: frame, ts, enq = item self._process_frame(frame, ts, enq, queue_wait_time=queue_wait_time) except Exception as exc: + self._timing.note_error() + self._timing.maybe_log() logger.exception("Pose inference failed", exc_info=exc) self.error.emit(str(exc)) finally: @@ -620,6 +954,8 @@ def _worker_loop(self, init_frame: np.ndarray, init_timestamp: float) -> None: logger.info("DLC worker thread exiting") + self._timing.maybe_log() + class DLCService: """Wrap DLCLiveProcessor lifecycle & configuration.""" @@ -656,13 +992,33 @@ def configure(self, settings: DLCProcessorSettings, scanned_processors: dict, se raise RuntimeError("Cannot configure DLCLiveProcessor while it is running. Please stop it first.") processor = None + processor_spec = None + if selected_key is not None and scanned_processors: try: - processor = instantiate_from_scan(scanned_processors, selected_key) + processor_info = scanned_processors[selected_key] + processor_class = processor_info["class"] + + if BaseProcessorSocket.do_build_in_worker(processor_class): + processor_spec = create_spec_from_scan(scanned_processors, selected_key) + + log_processor_context( + f"DLCLiveProcessor.configure - SPEC: {processor_class.__name__}", + logger, + ) + else: + processor = instantiate_from_scan(scanned_processors, selected_key) + + log_processor_context( + f"DLCLiveProcessor.configure - INSTANCE: {type(processor).__name__}", + logger, + ) + except Exception as exc: - logger.error("Failed to instantiate processor: %s", exc) + logger.error("Failed to configure processor: %s", exc, exc_info=True) return False - self._proc.configure(settings, processor=processor) + + self._proc.configure(settings, processor=processor, processor_spec=processor_spec) return True def start(self): diff --git a/dlclivegui/services/multi_camera_controller.py b/dlclivegui/services/multi_camera_controller.py index c9b65dd4a..5224751e0 100644 --- a/dlclivegui/services/multi_camera_controller.py +++ b/dlclivegui/services/multi_camera_controller.py @@ -7,19 +7,25 @@ import time from dataclasses import dataclass from functools import partial -from threading import Event, Lock +from threading import Lock import cv2 import numpy as np -from PySide6.QtCore import QObject, QThread, Signal, Slot +from PySide6.QtCore import QObject, QThread, Signal from PySide6.QtGui import QImage, QPixmap -from dlclivegui.cameras import CameraFactory -from dlclivegui.cameras.base import CameraBackend from dlclivegui.cameras.factory import camera_identity_key # from dlclivegui.config import CameraSettings -from dlclivegui.config import CameraSettings +from dlclivegui.config import ( + GUI_MAX_DISPLAY_FPS, + MULTI_CAMERA_WORKER_DO_LOG_TIMING, + CameraSettings, + CameraTriggerSettings, +) +from dlclivegui.utils.stats import WorkerTimingStats + +from .camera_controller import SingleCameraWorker LOGGER = logging.getLogger(__name__) @@ -38,95 +44,23 @@ class MultiFrameData: display_ids: dict[str, str] = None # camera_id -> display_id (for labeling) -class SingleCameraWorker(QObject): - """Worker for a single camera in multi-camera mode.""" - - frame_captured = Signal(str, object, float) # camera_id, frame, timestamp - error_occurred = Signal(str, str) # camera_id, error_message - started = Signal(str) # camera_id - stopped = Signal(str) # camera_id - - def __init__(self, camera_id: str, settings: CameraSettings): - super().__init__() - self._camera_id = camera_id - self._settings = copy.deepcopy(settings) - self._stop_event = Event() - self._backend: CameraBackend | None = None - self._max_consecutive_errors = 5 - self._retry_delay = 0.1 - - @Slot() - def run(self) -> None: - self._stop_event.clear() - - try: - LOGGER.debug( - "[Worker %s] before create: backend=%s index=%s properties=%s", - self._camera_id, - self._settings.backend, - self._settings.index, - self._settings.properties, - ) - - self._backend = CameraFactory.create(self._settings) - - LOGGER.debug( - "[Worker %s] after create: backend=%s index=%s properties=%s", - self._camera_id, - self._backend.settings.backend, - self._backend.settings.index, - self._backend.settings.properties, - ) - - self._backend.open() - except Exception as exc: - LOGGER.exception(f"Failed to initialize camera {self._camera_id}", exc_info=exc) - self.error_occurred.emit(self._camera_id, f"Failed to initialize camera: {exc}") - self.stopped.emit(self._camera_id) - return - - self.started.emit(self._camera_id) - consecutive_errors = 0 - - while not self._stop_event.is_set(): - try: - frame, timestamp = self._backend.read() - if frame is None or frame.size == 0: - consecutive_errors += 1 - if consecutive_errors >= self._max_consecutive_errors: - self.error_occurred.emit( - self._camera_id, "Too many empty frames.\nWas the device disconnected ?" - ) - break - time.sleep(self._retry_delay) - continue - - consecutive_errors = 0 - self.frame_captured.emit(self._camera_id, frame, timestamp) - - except Exception as exc: - consecutive_errors += 1 - if self._stop_event.is_set(): - break - if consecutive_errors >= self._max_consecutive_errors: - self.error_occurred.emit(self._camera_id, f"Camera read error: {exc}") - break - time.sleep(self._retry_delay) - continue - - # Cleanup - if self._backend is not None: - try: - self._backend.close() - except Exception: - pass - self.stopped.emit(self._camera_id) +def get_display_id(settings: CameraSettings) -> str: + """Return the human-friendly camera label used for GUI display. + Intentionally different from get_camera_id(), which should return a stable + internal, reliable and unambiguous identity and may contain serials or machine paths. + """ + name = str(getattr(settings, "name", "") or "").strip() + if name: + return name - def stop(self) -> None: - self._stop_event.set() + backend = (settings.backend or "").lower() + props = settings.properties if isinstance(settings.properties, dict) else {} + ns = props.get(backend, {}) if isinstance(props.get(backend), dict) else {} + device_name = str(ns.get("device_name", "") or "").strip() + if device_name: + return device_name -def get_display_id(settings: CameraSettings) -> str: return f"{settings.backend}:{settings.index}" @@ -147,11 +81,39 @@ def get_camera_id(settings: CameraSettings) -> str: return f"{backend}:index:{int(settings.index)}" +def _trigger_role_from_settings(settings: CameraSettings) -> str: + try: + trigger = settings.get_trigger_settings() + return str(CameraTriggerSettings.from_any(trigger).role).strip().lower() + except Exception: + return "off" + + +def _camera_start_priority(settings: CameraSettings) -> int: + """Start trigger-waiting cameras before trigger-generating cameras. + + Priority: + 0: external/follower cameras, which should be armed first + 1: normal/free-run cameras + 2: master cameras, which may generate trigger pulses + """ + role = _trigger_role_from_settings(settings) + if role in {"external", "follower"}: + return 0 + if role == "master": + return 2 + return 1 + + class MultiCameraController(QObject): """Controller for managing multiple cameras simultaneously.""" # Signals - frame_ready = Signal(object) # MultiFrameData + frame_ready = Signal(object) # MultiFrameData (full cam FPS; inference only) + # recording_frame_ready = Signal( + # str, object, float, object + # ) # camera_id, frame, timestamp, timestamp_metadata (full cam FPS; for recording) + 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 @@ -166,15 +128,27 @@ def __init__(self): self._workers: dict[str, SingleCameraWorker] = {} self._threads: dict[str, QThread] = {} self._settings: dict[str, CameraSettings] = {} + self._runtime_info: dict[str, dict] = {} self._frames: dict[str, np.ndarray] = {} self._timestamps: dict[str, float] = {} self._frame_lock = Lock() self._running = False + self._stopping = False + self._all_stopped_emitted = False + self._recording_frame_emission_enabled: bool = False + self._recording_sink = None self._started_cameras: set = set() self._display_ids: dict[str, str] = {} # camera_id -> display_id (for labeling) + self._camera_display_order: list[str] = [] 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] = {} + def is_running(self) -> bool: """Check if any camera is currently running.""" return self._running and len(self._started_cameras) > 0 @@ -183,13 +157,60 @@ def get_active_count(self) -> int: """Get the number of active cameras.""" return len(self._started_cameras) + def _timing_for_camera(self, camera_id: str) -> WorkerTimingStats: + timing = self._timing_per_cam.get(camera_id) + if timing is None: + timing = WorkerTimingStats( + f"Controller {camera_id}", + logger=LOGGER, + log_interval=1.0, + enabled=MULTI_CAMERA_WORKER_DO_LOG_TIMING, + ) + self._timing_per_cam[camera_id] = timing + return timing + + def set_recording_frame_do_emit(self, enabled: bool) -> None: + self._recording_frame_emission_enabled = bool(enabled) + for worker in list(self._workers.values()): + worker.set_recording_enabled(enabled) + + 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: + if self._running or self._stopping: LOGGER.warning("Multi-camera controller already running") return - active_settings = [s for s in camera_settings if s.enabled][: self.MAX_CAMERAS] + active_settings_user_order = [s for s in camera_settings if s.enabled][: self.MAX_CAMERAS] + if not active_settings_user_order: + LOGGER.warning("No active cameras to start") + return + + # Display/tile order follows the user-configured camera order. + self._camera_display_order = [get_camera_id(s) for s in active_settings_user_order] + + # Startup order may differ for trigger safety: + # followers/external first, master last. + # Note that this is not a hard sync guarantee, it just calls start() on the workers + # in the order of priority. + active_settings = sorted(active_settings_user_order, key=_camera_start_priority) if not active_settings: LOGGER.warning("No active cameras to start") return @@ -221,6 +242,9 @@ def start(self, camera_settings: list[CameraSettings]) -> None: seen[key] = camera_id self._running = True + self._stopping = False + self._all_stopped_emitted = False + self._recording_frame_emission_enabled = False self._frames.clear() self._timestamps.clear() self._started_cameras.clear() @@ -253,11 +277,14 @@ def _start_camera(self, settings: CameraSettings) -> None: self._display_ids[cam_id] = display_id dc = self._settings[cam_id] worker = SingleCameraWorker(cam_id, dc) + worker.set_recording_sink(self._recording_sink) + worker.set_recording_enabled(self._recording_frame_emission_enabled) thread = QThread() worker.moveToThread(thread) # Connections unchanged thread.started.connect(worker.run) + worker.runtime_info.connect(self._on_camera_runtime_info) worker.frame_captured.connect(self._on_frame_captured) worker.started.connect(self._on_camera_started) worker.stopped.connect(self._on_camera_stopped) @@ -269,7 +296,12 @@ def _start_camera(self, settings: CameraSettings) -> None: worker.stopped.connect(thread.quit) thread.start() - def _cleanup_camera(self, camera_id: str) -> None: + def set_recording_sink(self, sink) -> None: + self._recording_sink = sink + for worker in list(self._workers.values()): + worker.set_recording_sink(sink) + + def _cleanup_camera(self, camera_id: str, *, finalize: bool = True) -> None: # remove stored frame data with self._frame_lock: self._frames.pop(camera_id, None) @@ -278,140 +310,222 @@ def _cleanup_camera(self, camera_id: str) -> None: worker = self._workers.pop(camera_id, None) thread = self._threads.pop(camera_id, None) self._settings.pop(camera_id, None) + self._display_ids.pop(camera_id, None) + self._started_cameras.discard(camera_id) if worker is not None: worker.deleteLater() if thread is not None: thread.deleteLater() - def stop(self, wait: bool = True) -> None: - """Stop all cameras.""" - if not self._running: + if finalize: + self._maybe_finalize_stop() + + def _maybe_finalize_stop(self) -> None: + """Finalize shutdown after every owned camera thread has finished.""" + # FUTURE FIXME: clear runtime info + if not self._stopping: return - self._running = False + if any(thread is not None and thread.isRunning() for thread in self._threads.values()): + return - # Signal all workers to stop - for worker in self._workers.values(): - worker.stop() - - # Wait for threads to finish - if wait: - still_running: list[str] = [] - for cam_id, thread in list(self._threads.items()): - if thread is None: - self._cleanup_camera(cam_id) - continue - if not thread.isRunning(): - self._cleanup_camera(cam_id) - continue - - thread.quit() - if thread.wait(QUIT_WAIT_MS): - self._cleanup_camera(cam_id) - continue # Clean exit - - LOGGER.error( - "Camera thread %s did not quit within %dms; forcing terminate()", - cam_id, - QUIT_WAIT_MS, - ) + for camera_id, thread in list(self._threads.items()): + if thread is None or not thread.isRunning(): + self._cleanup_camera(camera_id, finalize=False) - thread.terminate() - if thread.wait(TERMINATE_WAIT_MS): - self._cleanup_camera(cam_id) - continue # Terminated successfully - - LOGGER.critical( - "Camera thread %s refused to terminate after terminate()+wait(%dms). " - "Keeping references to avoid use-after-free/segfaults. " - "Application restart may be required.", - cam_id, - TERMINATE_WAIT_MS, - ) - still_running.append(cam_id) - - if still_running: - self._started_cameras.clear() - self._failed_cameras.clear() - with self._frame_lock: - self._frames.clear() - self._timestamps.clear() - self._expected_cameras = 0 - - LOGGER.critical( - "Force stop failed for camera threads: %s. " - "Sending all_stopped to leave GUI in running state, " - "but background camera threads may still be active and interfere with future camera starts. " - "Application restart may be required.", - still_running, - ) - self.all_stopped.emit() - return + if self._threads: + return + self._running = False + self._recording_frame_emission_enabled = False + self._timing_per_cam.clear() + self._gui_display_last_emit = 0.0 + + self._workers.clear() + self._settings.clear() + # self._runtime_info.clear() self._started_cameras.clear() self._failed_cameras.clear() self._display_ids.clear() + self._camera_display_order.clear() + with self._frame_lock: self._frames.clear() self._timestamps.clear() + self._expected_cameras = 0 + self._stopping = False + if self._all_stopped_emitted: + return + + self._all_stopped_emitted = True self.all_stopped.emit() - def _on_frame_captured(self, camera_id: str, frame: np.ndarray, timestamp: float) -> None: + def stop(self, wait: bool = True) -> None: + """Request shutdown of all cameras. + + If wait is True, block while attempting cooperative and forced shutdown. + If wait is False, retain all worker/thread references and emit all_stopped + later, after every QThread has actually finished. + """ + if not self._running and not self._stopping: + return + + if self._running: + self._running = False + self._stopping = True + self._all_stopped_emitted = False + + for worker in list(self._workers.values()): + worker.stop() + + if not wait: + self._maybe_finalize_stop() + return + + still_running: list[str] = [] + + for camera_id, thread in list(self._threads.items()): + if thread is None or not thread.isRunning(): + self._cleanup_camera(camera_id, finalize=False) + continue + + thread.quit() + + if thread.wait(QUIT_WAIT_MS): + self._cleanup_camera(camera_id, finalize=False) + continue + + LOGGER.error( + "Camera thread %s did not quit within %dms; forcing terminate()", + camera_id, + QUIT_WAIT_MS, + ) + + thread.terminate() + + if thread.wait(TERMINATE_WAIT_MS): + self._cleanup_camera(camera_id, finalize=False) + continue + + LOGGER.critical( + "Camera thread %s refused to terminate after terminate()+wait(%dms). " + "Keeping references to avoid destroying a running QThread. " + "Application restart may be required.", + camera_id, + TERMINATE_WAIT_MS, + ) + still_running.append(camera_id) + + if still_running: + LOGGER.critical( + "Camera shutdown incomplete; threads remain active: %s. " + "all_stopped will not be emitted until every remaining thread actually finishes. " + "Restarting cameras is unsafe in the meantime.", + still_running, + ) + return + + self._maybe_finalize_stop() + + def _on_frame_captured( + self, camera_id: str, frame: np.ndarray, timestamp: float, timestamp_metadata: object | None = None + ) -> None: """Handle a frame from one camera.""" - # Apply rotation if configured - settings = self._settings.get(camera_id) - if settings and settings.rotation: - frame = MultiCameraController.apply_rotation(frame, settings.rotation) + timing = self._timing_for_camera(camera_id) + frame_data: MultiFrameData | None = None + + with timing.measure("Multi.slot.total"): + # self._settings.get(camera_id) + + # with timing.measure("Multi.apply_transforms"): + # if settings and settings.rotation: + # frame = MultiCameraController.apply_rotation(frame, settings.rotation) + + # if settings: + # crop_region = settings.get_crop_region() + # if crop_region: + # frame = MultiCameraController.apply_crop(frame, crop_region) + + # if self._recording_frame_emission_enabled: + # with timing.measure("Multi.emit.recording_frame_ready"): + # self.recording_frame_ready.emit(camera_id, frame, timestamp) + + with self._frame_lock: + with timing.measure("Multi.store_latest"): + self._frames[camera_id] = frame + self._timestamps[camera_id] = timestamp + + 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, + display_ids=dict(self._display_ids), + ) + + 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() + + def _on_camera_runtime_info(self, camera_id: str, info: object) -> None: + if not isinstance(info, dict): + return - # Apply cropping if configured - if settings: - crop_region = settings.get_crop_region() - if crop_region: - frame = MultiCameraController.apply_crop(frame, crop_region) + self._runtime_info[camera_id] = dict(info) - with self._frame_lock: - self._frames[camera_id] = frame - self._timestamps[camera_id] = timestamp - - # Emit frame data without tiling (tiling done in GUI for performance) - if self._frames: - frame_data = MultiFrameData( - frames=dict(self._frames), - timestamps=dict(self._timestamps), - source_camera_id=camera_id, # Track which camera triggered this - tiled_frame=None, - display_ids=dict(self._display_ids), - ) - self.frame_ready.emit(frame_data) + actual_fps = info.get("actual_fps") + LOGGER.info( + "Camera %s runtime info: actual_fps=%s actual_resolution=%s pixel_format=%s output_format=%s", + camera_id, + actual_fps, + info.get("actual_resolution"), + info.get("actual_pixel_format"), + info.get("actual_output_format"), + ) - @staticmethod - def apply_rotation(frame: np.ndarray, degrees: int) -> np.ndarray: - """Apply rotation to frame.""" - if degrees == 90: - return cv2.rotate(frame, cv2.ROTATE_90_CLOCKWISE) - elif degrees == 180: - return cv2.rotate(frame, cv2.ROTATE_180) - elif degrees == 270: - return cv2.rotate(frame, cv2.ROTATE_90_COUNTERCLOCKWISE) - return frame + def actual_fps_by_camera_id(self) -> dict[str, float]: + out: dict[str, float] = {} - @staticmethod - def apply_crop(frame: np.ndarray, crop_region: tuple[int, int, int, int]) -> np.ndarray: - """Apply crop to frame.""" - x0, y0, x1, y1 = crop_region - height, width = frame.shape[:2] - - x0 = max(0, min(x0, width)) - y0 = max(0, min(y0, height)) - x1 = max(x0, min(x1, width)) if x1 > 0 else width - y1 = max(y0, min(y1, height)) if y1 > 0 else height - - if x0 < x1 and y0 < y1: - return frame[y0:y1, x0:x1] - return frame + for camera_id, info in self._runtime_info.items(): + try: + fps = float(info.get("actual_fps") or 0.0) + except Exception: + fps = 0.0 + + if fps > 0.0: + out[camera_id] = fps + + return out @staticmethod def apply_resize(frame: np.ndarray, max_w: int, max_h: int, allow_upscale: bool = False) -> np.ndarray: @@ -459,98 +573,6 @@ def to_display_pixmap(frame: np.ndarray) -> QPixmap: q_img = QImage(frame.data, w, h, bytes_per_line, QImage.Format.Format_RGB888).copy() return QPixmap.fromImage(q_img) - def _create_tiled_frame(self) -> np.ndarray: - """Create a tiled frame from all camera frames. - - The tiled frame is scaled to fit within a maximum canvas size - while maintaining aspect ratio of individual camera frames. - """ - if not self._frames: - return np.zeros((480, 640, 3), dtype=np.uint8) - - frames_list = [self._frames[idx] for idx in sorted(self._frames.keys())] - num_frames = len(frames_list) - - if num_frames == 0: - return np.zeros((480, 640, 3), dtype=np.uint8) - - # Determine grid layout - if num_frames == 1: - rows, cols = 1, 1 - elif num_frames == 2: - rows, cols = 1, 2 - elif num_frames <= 4: - rows, cols = 2, 2 - else: - rows, cols = 2, 2 # Limit to 4 - - # Maximum canvas size to fit on screen (leaving room for UI elements) - max_canvas_width = 1200 - max_canvas_height = 800 - - # Calculate tile size based on frame aspect ratio and available space - first_frame = frames_list[0] - frame_h, frame_w = first_frame.shape[:2] - frame_aspect = frame_w / frame_h if frame_h > 0 else 1.0 - - # Calculate tile dimensions that fit within the canvas - tile_w = max_canvas_width // cols - tile_h = max_canvas_height // rows - - # Maintain aspect ratio of original frames - tile_aspect = tile_w / tile_h if tile_h > 0 else 1.0 - - if frame_aspect > tile_aspect: - # Frame is wider than tile slot - constrain by width - tile_h = int(tile_w / frame_aspect) - else: - # Frame is taller than tile slot - constrain by height - tile_w = int(tile_h * frame_aspect) - - # Ensure minimum size - tile_w = max(160, tile_w) - tile_h = max(120, tile_h) - - # Create canvas - canvas = np.zeros((rows * tile_h, cols * tile_w, 3), dtype=np.uint8) - - # Get sorted camera IDs for consistent ordering - cam_ids = sorted(self._frames.keys()) - frames_list = [self._frames[cam_id] for cam_id in cam_ids] - - # Place each frame in the grid - for idx, frame in enumerate(frames_list[: rows * cols]): - row = idx // cols - col = idx % cols - - # Ensure frame is 3-channel - frame = MultiCameraController.ensure_color_bgr(frame) - - # Resize to tile size - resized = MultiCameraController.apply_resize(frame, tile_w, tile_h, allow_upscale=True) - - # Add camera ID label - if idx < len(cam_ids): - label = cam_ids[idx] - cv2.putText( - resized, - label, - (10, 30), - cv2.FONT_HERSHEY_SIMPLEX, - 0.7, - (0, 255, 0), - 2, - ) - - # Place in canvas - y_start = row * tile_h - y_end = y_start + tile_h - x_start = col * tile_w - x_end = x_start + tile_w - canvas[y_start:y_end, x_start:x_end] = resized - - return canvas - def _on_camera_started(self, camera_id: str) -> None: """Handle camera start event.""" self._started_cameras.add(camera_id) @@ -572,39 +594,33 @@ def _on_camera_stopped(self, camera_id: str) -> None: was_started = camera_id in self._started_cameras self._started_cameras.discard(camera_id) self._display_ids.pop(camera_id, None) + self.camera_stopped.emit(camera_id) - LOGGER.info(f"Camera {camera_id} stopped (was_started={was_started})") + LOGGER.info("Camera %s stopped (was_started=%s)", camera_id, was_started) - # Cleanup thread - if camera_id in self._threads: - thread = self._threads.get(camera_id) - if thread is not None and thread.isRunning(): - thread.quit() + thread = self._threads.get(camera_id) + if thread is not None and thread.isRunning(): + thread.quit() - # Remove frame data with self._frame_lock: self._frames.pop(camera_id, None) self._timestamps.pop(camera_id, None) - # Check if all cameras have reported and none started total_reported = len(self._started_cameras) + len(self._failed_cameras) - if total_reported == self._expected_cameras and not self._started_cameras: - # All cameras failed to start - if self._running and self._failed_cameras: - self._running = False - failure_list = list(self._failed_cameras.items()) - self.initialization_failed.emit(failure_list) - self.all_stopped.emit() - return + all_initialization_failed = ( + total_reported == self._expected_cameras and not self._started_cameras and bool(self._failed_cameras) + ) - # Check if all running cameras have stopped (normal shutdown) - if ( - not self._started_cameras - and self._running - and all(not t.isRunning() for t in self._threads.values() if t is not None) - ): + if all_initialization_failed and self._running: self._running = False - self.all_stopped.emit() + self._stopping = True + self.initialization_failed.emit(list(self._failed_cameras.items())) + return + + # If no camera remains after a runtime stop, enter shutdown finalization. + if was_started and not self._started_cameras and self._running: + self._running = False + self._stopping = True def _on_camera_error(self, camera_id: str, message: str) -> None: """Handle camera error event.""" @@ -613,20 +629,3 @@ def _on_camera_error(self, camera_id: str, message: str) -> None: if camera_id not in self._started_cameras: self._failed_cameras[camera_id] = message self.camera_error.emit(camera_id, message) - - def get_frame(self, camera_id: str) -> np.ndarray | None: - """Get the latest frame from a specific camera.""" - with self._frame_lock: - return self._frames.get(camera_id) - - def get_all_frames(self) -> dict[str, np.ndarray]: - """Get the latest frames from all cameras.""" - with self._frame_lock: - return dict(self._frames) - - def get_tiled_frame(self) -> np.ndarray | None: - """Get a tiled view of all camera frames.""" - with self._frame_lock: - if self._frames: - return self._create_tiled_frame() - return None diff --git a/dlclivegui/services/recording_manager.py b/dlclivegui/services/recording_manager.py new file mode 100644 index 000000000..829a97ed4 --- /dev/null +++ b/dlclivegui/services/recording_manager.py @@ -0,0 +1,497 @@ +from __future__ import annotations + +import logging +import queue +import threading +import time +from pathlib import Path + +import numpy as np + +from dlclivegui.config import CameraSettings, RecordingSettings +from dlclivegui.services.multi_camera_controller import get_camera_id +from dlclivegui.services.video_recorder import VideoRecorder +from dlclivegui.utils.stats import RecorderStats +from dlclivegui.utils.utils import build_run_dir, sanitize_name + +log = logging.getLogger(__name__) + +_FRAME_SENTINEL = object() + + +class RecordingManager: + """Handle multi-camera recording lifecycle and filenames.""" + + def __init__(self): + self._recorders: dict[str, VideoRecorder] = {} + self._session_dir: Path | None = None + self._run_dir: Path | None = None + + self._lock = threading.RLock() + self._frame_queue: queue.Queue | None = None + self._dispatch_thread: threading.Thread | None = None + self._dispatch_stop = threading.Event() + + # Utility for operation on latest recording file context (e.g., for processor hooks) + self._last_recording_file_context: dict | None = None + + @property + def is_active(self) -> bool: + with self._lock: + return bool(self._recorders) + + @property + def recorders(self) -> dict[str, VideoRecorder]: + with self._lock: + return dict(self._recorders) + + @property + def session_dir(self) -> Path | None: + with self._lock: + return self._session_dir + + @property + def run_dir(self) -> Path | None: + with self._lock: + return self._run_dir + + @staticmethod + def _backend_ns(cam: CameraSettings) -> dict: + backend = (cam.backend or "").lower() + props = cam.properties if isinstance(cam.properties, dict) else {} + ns = props.get(backend, {}) + return ns if isinstance(ns, dict) else {} + + @classmethod + def _resolve_recording_fps( + cls, + cam: CameraSettings, + cam_id: str, + frame_rates: dict[str, float] | None, + ) -> float | None: + """Resolve writer FPS. + + Prefer runtime measured FPS, then backend-probed detected_fps, + then explicit requested cam.fps. Auto/unknown returns None. + """ + measured_fps = 0.0 + if frame_rates: + try: + measured_fps = float(frame_rates.get(cam_id, 0.0) or 0.0) + except Exception: + measured_fps = 0.0 + + if measured_fps > 0.0: + return measured_fps + + ns = cls._backend_ns(cam) + + try: + detected_fps = float(ns.get("detected_fps", 0.0) or 0.0) + except Exception: + detected_fps = 0.0 + + if detected_fps > 0.0: + return detected_fps + + try: + requested_fps = float(getattr(cam, "fps", 0.0) or 0.0) + except Exception: + requested_fps = 0.0 + + if requested_fps > 0.0: + return requested_fps + + return None + + def pop(self, cam_id: str, default=None) -> VideoRecorder | None: + with self._lock: + return self._recorders.pop(cam_id, default) + + def _start_dispatcher(self) -> None: + if self._dispatch_thread is not None and self._dispatch_thread.is_alive(): + return + + self._dispatch_stop.clear() + self._frame_queue = queue.Queue(maxsize=4096) + self._dispatch_thread = threading.Thread( + target=self._dispatch_loop, + name="RecordingManagerDispatcher", + daemon=True, + ) + self._dispatch_thread.start() + + def _stop_dispatcher(self, timeout: float = 2.0) -> None: + with self._lock: + q = self._frame_queue + t = self._dispatch_thread + + if q is not None: + try: + q.put(_FRAME_SENTINEL, block=True, timeout=timeout) + except queue.Full: + log.warning("Recording frame queue full while stopping dispatcher; dispatcher may not stop promptly.") + + if t is not None: + t.join(timeout=timeout) + if t.is_alive(): + log.warning("Recording frame dispatcher did not stop within %.1fs", timeout) + + with self._lock: + if self._dispatch_thread is t: + self._dispatch_thread = None + self._frame_queue = None + self._dispatch_stop.clear() + + def _dispatch_loop(self) -> None: + with self._lock: + q = self._frame_queue + + if q is None: + return + + while True: + item = q.get() + + try: + if item is _FRAME_SENTINEL: + break + + cam_id, frame, timestamp, timestamp_metadata = item + self._write_frame_now(cam_id, frame, timestamp, timestamp_metadata) + + finally: + try: + q.task_done() + except ValueError: + pass + + def start_all( + self, + recording: RecordingSettings, + active_cams: list[CameraSettings], + current_frames: dict[str, np.ndarray], + *, + frame_rates: dict[str, float] | None = None, + session_name: str = "session", + use_timestamp: bool = True, + all_or_nothing: bool = False, + ) -> Path | None: + """Start recording for all active cameras. + + Record into /// + + Args: + recording: Recording settings including output directory and codec. + active_cams: List of active camera settings to record. + current_frames: Dict of current frames by camera ID for size reference. + session_name: Name of the recording session (used in directory name). + use_timestamp: Whether to use timestamp-based run directories instead of indexed. + all_or_nothing: If True, stop all and return None if any recorder fails to start. + + Returns: + run_dir if at least one recorder started, else None. + """ + with self._lock: + if self._recorders: + return self._run_dir + + if not active_cams: + return None + + base_path = recording.output_path() + base_stem = base_path.stem + + # create session/run directories + session_safe = sanitize_name(session_name, fallback="session") + session_dir = base_path.parent / session_safe + try: + run_dir = build_run_dir(session_dir, use_timestamp=use_timestamp) + except Exception as exc: + log.error("Failed to create run dir: %s", exc) + return None + + with self._lock: + self._session_dir = session_dir + self._run_dir = run_dir + + started_any = False + + for cam in active_cams: + cam_id = get_camera_id(cam) + cam_filename = f"{base_stem}_{cam.backend}_cam{cam.index}{base_path.suffix}" + cam_path = run_dir / cam_filename + + frame = current_frames.get(cam_id) + frame_size = (frame.shape[0], frame.shape[1]) if frame is not None else None + recorder_fps = self._resolve_recording_fps(cam, cam_id, frame_rates) + writer_options = recording.writegear_options(recorder_fps) + + log.debug( + "Starting recorder %s -> %s frame_size=%s requested_fps=%s detected_fps=%s " + "recorder_fps=%s fast_encoding=%s writer_options=%s", + cam_id, + cam_path, + frame_size, + getattr(cam, "fps", None), + self._backend_ns(cam).get("detected_fps"), + f"{recorder_fps:.3f}" if recorder_fps else "auto/fallback", + bool(getattr(recording, "fast_encoding", False)), + writer_options, + ) + + recorder = VideoRecorder( + cam_path, + frame_size=frame_size, + frame_rate=recorder_fps, + codec=recording.codec, + crf=recording.crf, + convert_grayscale_to_rgb=not bool(getattr(cam, "preserve_mono", False)), + writer_options=writer_options, + ) + try: + recorder.start() + with self._lock: + self._recorders[cam_id] = recorder + started_any = True + log.info("Started recording %s -> %s", cam_id, cam_path) + except Exception as exc: + log.error("Failed to start recording for %s: %s", cam_id, exc) + if all_or_nothing: + self.stop_all() + return None + + if not started_any: + with self._lock: + self._recorders.clear() + self._session_dir = None + self._run_dir = None + return None + + with self._lock: + self._last_recording_file_context = self._build_recording_file_context_unlocked() + + return run_dir + + def stop_all(self) -> None: + self._stop_dispatcher() + + with self._lock: + recorders = list(self._recorders.items()) + run_dir = self._run_dir + session_dir = self._session_dir + self._last_recording_file_context = self._build_recording_file_context_unlocked( + recorders=recorders, run_dir=run_dir, session_dir=session_dir + ) + self._recorders.clear() + + for cam_id, rec in recorders: + try: + rec.stop() + log.info("Stopped recording %s", cam_id) + except Exception as exc: + log.warning("Error stopping recorder for %s: %s", cam_id, exc) + + with self._lock: + self._session_dir = None + self._run_dir = None + + def _write_frame_now( + self, cam_id: str, frame: np.ndarray, timestamp: float | None = None, timestamp_metadata: object | None = None + ) -> None: + with self._lock: + rec = self._recorders.get(cam_id) + + if not rec or not rec.is_running: + return + + try: + rec.write( + frame, + timestamp=timestamp if timestamp is not None else time.time(), + timestamp_metadata=timestamp_metadata, + ) + except Exception as exc: + log.warning( + "Failed to write frame for %s: %s: %s frame_shape=%s dtype=%s. Removing recorder.", + cam_id, + type(exc).__name__, + str(exc) or repr(exc), + getattr(frame, "shape", None), + getattr(frame, "dtype", None), + ) + + with self._lock: + rec = self._recorders.pop(cam_id, None) + + if rec is not None: + try: + rec.stop() + except Exception: + log.exception("Failed to stop recorder for %s after write error.", cam_id) + + def write_frame( + self, + cam_id: str, + frame: np.ndarray, + timestamp: float | None = None, + timestamp_metadata: object | None = None, + ) -> None: + with self._lock: + active = cam_id in self._recorders + if not active: + return + + if self._frame_queue is None or self._dispatch_thread is None or not self._dispatch_thread.is_alive(): + self._start_dispatcher() + + q = self._frame_queue + + if q is None: + return + + try: + q.put_nowait((cam_id, frame, timestamp if timestamp is not None else time.time(), timestamp_metadata)) + except queue.Full: + log.warning( + "Recording manager frame queue full; dropping frame for %s. frame_shape=%s dtype=%s", + cam_id, + getattr(frame, "shape", None), + getattr(frame, "dtype", None), + ) + + def flush(self, timeout: float = 2.0) -> bool: + """Wait until all currently queued recording frames have been dispatched. + + Returns True if the queue drained before timeout, False otherwise. + """ + with self._lock: + q = self._frame_queue + + if q is None: + return True + + done = threading.Event() + + def waiter() -> None: + q.join() + done.set() + + t = threading.Thread(target=waiter, name="RecordingManagerFlush", daemon=True) + t.start() + return done.wait(timeout) + + def get_stats_summary(self) -> str: + totals = { + "enqueued": 0, + "written": 0, + "dropped": 0, + "queue": 0, + "buffer": 0, + "backlog": 0, + "write_fps": 0.0, + "max_latency": 0.0, + "avg_latencies": [], + } + + with self._lock: + recorders = list(self._recorders.values()) + + for rec in recorders: + stats: RecorderStats | None = rec.get_stats() + if not stats: + continue + totals["enqueued"] += stats.frames_enqueued + totals["written"] += stats.frames_written + totals["dropped"] += stats.dropped_frames + totals["queue"] += stats.queue_size + totals["buffer"] += stats.buffer_size + totals["backlog"] += stats.backlog_frames + totals["write_fps"] += stats.write_fps + totals["max_latency"] = max(totals["max_latency"], stats.last_latency) + totals["avg_latencies"].append(stats.average_latency) + + if len(recorders) == 1: + rec = recorders[0] + stats = rec.get_stats() + if stats: + from dlclivegui.utils.stats import format_recorder_stats + + return format_recorder_stats(stats) + return "Recording..." + else: + avg = sum(totals["avg_latencies"]) / len(totals["avg_latencies"]) if totals["avg_latencies"] else 0.0 + + buffer = totals["buffer"] + queue_text = f"{totals['queue']}/{buffer}" if buffer > 0 else str(totals["queue"]) + fill_pct = (100.0 * totals["queue"] / buffer) if buffer > 0 else 0.0 + + return ( + f"{len(recorders)} cams | {totals['written']}/{totals['enqueued']} frames | " + f"writer {totals['write_fps']:.1f} fps | " + f"latency {totals['max_latency'] * 1000:.1f}ms (avg {avg * 1000:.1f}ms) | " + f"queue {queue_text} ({fill_pct:.0f}%) | " + f"backlog {totals['backlog']} | " + f"dropped {totals['dropped']}" + ) + + def _build_recording_file_context_unlocked( + self, + recorders: list[tuple[str, VideoRecorder]] | None = None, + run_dir: Path | None = None, + session_dir: Path | None = None, + ) -> dict: + """Build a file context for active or recently stopped recorders. + + Must be called with self._lock held if using internal state. + """ + if recorders is None: + recorders = list(self._recorders.items()) + + if run_dir is None: + run_dir = self._run_dir + + if session_dir is None: + session_dir = self._session_dir + + video_files: dict[str, Path] = {} + timestamp_json_files: dict[str, Path] = {} + + for cam_id, recorder in recorders: + video_path = getattr(recorder, "output_path", None) + timestamp_path = getattr(recorder, "timestamp_json_path", None) + + if video_path is not None: + video_files[str(cam_id)] = Path(video_path) + + if timestamp_path is not None: + timestamp_json_files[str(cam_id)] = Path(timestamp_path) + + return { + "run_dir": run_dir, + "session_dir": session_dir, + "video_files": video_files, + "timestamp_json_files": timestamp_json_files, + } + + def get_recording_file_context(self) -> dict: + """Return current or last recording file context. + + This is used by optional custom processors to save compatibility sidecars + next to the videos after RecordingManager.stop_all() has finalized them. + """ + with self._lock: + if self._recorders: + context = self._build_recording_file_context_unlocked() + self._last_recording_file_context = context + return dict(context) + + if self._last_recording_file_context is not None: + return dict(self._last_recording_file_context) + + return { + "run_dir": self._run_dir, + "session_dir": self._session_dir, + "video_files": {}, + "timestamp_json_files": {}, + } diff --git a/dlclivegui/services/video_recorder.py b/dlclivegui/services/video_recorder.py index e2ae15c9e..ecb8f4fb6 100644 --- a/dlclivegui/services/video_recorder.py +++ b/dlclivegui/services/video_recorder.py @@ -9,12 +9,14 @@ import threading import time from collections import deque -from dataclasses import dataclass from pathlib import Path from typing import Any import numpy as np +from dlclivegui.config import REC_DO_LOG_TIMING +from dlclivegui.utils.stats import RecorderStats, WorkerTimingStats + try: from vidgear.gears import WriteGear except ImportError: # pragma: no cover - handled at runtime @@ -26,25 +28,55 @@ STOP_JOIN_TIMEOUT = 5.0 # seconds -@dataclass -class RecorderStats: - """Snapshot of recorder throughput metrics.""" - - frames_enqueued: int = 0 - frames_written: int = 0 - dropped_frames: int = 0 - queue_size: int = 0 - average_latency: float = 0.0 - last_latency: float = 0.0 - write_fps: float = 0.0 - buffer_seconds: float = 0.0 - - _SENTINEL = object() class VideoRecorder: - """Thin wrapper around :class:`vidgear.gears.WriteGear`.""" + """Asynchronous video recorder backed by VidGear/FFmpeg. + + `VideoRecorder` wraps VidGear's `WriteGear` writer with a bounded in-memory + queue and a dedicated writer thread. Calls to `write()` perform minimal frame + validation/preprocessing, enqueue accepted frames without blocking, and return + immediately. The writer thread consumes queued frames and writes them to disk, + while also recording timestamps for successfully written frames. + + The recorder is intended for high-throughput camera pipelines where frame + acquisition should not block on video encoding. If the internal queue fills, + incoming frames are dropped and counted in recorder statistics. Timestamp + sidecar files are written on `stop()` for frames that were actually written. + + Args: + output: Output video path. + frame_size: Expected frame size as `(height, width)`. If provided, + incoming frames with different dimensions are rejected and the + recorder enters an error state. + frame_rate: Output video frame rate. If missing or non-positive, the + recorder falls back to 30 FPS and logs a warning. + codec: FFmpeg video codec name passed to WriteGear, for example + `"libx264"`. + crf: Constant Rate Factor passed to compatible FFmpeg encoders. Lower + values generally increase quality and file size. + buffer_size: Maximum number of frames that may wait in the recorder + queue before new frames are dropped. + convert_grayscale_to_rgb: Whether 2D grayscale frames should be expanded + to 3-channel RGB before writing. Set to `False` to preserve mono + frames when supported by the chosen writer/codec path. + writer_options: Optional dictionary of additional keyword arguments passed + to `WriteGear`. If provided, this overrides the default options. + + Attributes: + is_running: Whether the writer thread is currently alive. + + Raises: + RuntimeError: If VidGear is unavailable, if the recorder is abandoned + after a failed stop, or if a previous encoding error is detected + during `write()`. + + Notes: + This class does not guarantee that every submitted frame is written. + Frames may be dropped when the queue is full, and timestamps are only + saved for frames successfully consumed by the writer thread. + """ def __init__( self, @@ -54,15 +86,20 @@ def __init__( codec: str = "libx264", crf: int = 23, buffer_size: int = 240, + convert_grayscale_to_rgb: bool = True, + writer_options: dict[str, Any] | None = None, ): # Config self._output = Path(output) self._writer: Any | None = None self._frame_size = frame_size self._frame_rate = frame_rate + self._hardware_timestamp_source: dict[str, Any] | None = None self._codec = codec self._crf = int(crf) self._buffer_size = max(1, int(buffer_size)) + self._convert_grayscale_to_rgb = bool(convert_grayscale_to_rgb) + self._writer_options = dict(writer_options) if writer_options is not None else None # Worker state self._queue: queue.Queue[Any] | None = None self._writer_thread: threading.Thread | None = None @@ -79,12 +116,30 @@ def __init__( self._written_times: deque[float] = deque(maxlen=600) self._encode_error: Exception | None = None self._last_log_time = 0.0 - self._frame_timestamps: list[float] = [] + self._frame_timestamps: list[dict[str, Any]] = [] + # Timing + self._process_timing = WorkerTimingStats( + f"RecorderProcess[{self._output.name}]", logger=logger, log_interval=1.0, enabled=REC_DO_LOG_TIMING + ) + self._writer_timing = WorkerTimingStats( + f"RecorderWriter[{self._output.name}]", logger=logger, log_interval=1.0, enabled=REC_DO_LOG_TIMING + ) + self._logged_first_frame = False @property def is_running(self) -> bool: return self._writer_thread is not None and self._writer_thread.is_alive() + @property + def output_path(self) -> Path: + """Video output path.""" + return self._output + + @property + def timestamp_json_path(self) -> Path: + """Timestamp JSON sidecar path written by _save_timestamps().""" + return self._output.with_suffix("").with_suffix(self._output.suffix + "_timestamps.json") + def start(self) -> None: if WriteGear is None: raise RuntimeError("vidgear is required for video recording. Install it with 'pip install vidgear'.") @@ -111,16 +166,51 @@ def start(self) -> None: self._queue = None self._writer_thread = None - fps_value = float(self._frame_rate) if self._frame_rate else 30.0 + if self._frame_rate and float(self._frame_rate) > 0.0: + fps_value = float(self._frame_rate) + else: + fps_value = 30.0 + logger.warning( + "VideoRecorder frame_rate missing/zero for %s; falling back to %.3f FPS. " + "Video playback duration may not match capture timestamps.", + self._output.name, + fps_value, + ) + + logger.info( + "Starting VideoRecorder output=%s frame_size=%s frame_rate=%.3f " + "codec=%s crf=%s buffer_size=%s convert_grayscale_to_rgb=%s writer_options=%s", + self._output, + self._frame_size, + fps_value, + self._codec, + self._crf, + self._buffer_size, + self._convert_grayscale_to_rgb, + self._writer_options, + ) + codec_value = (self._codec or "libx264").strip() or "libx264" writer_kwargs: dict[str, Any] = { "compression_mode": True, "logging": False, - "-input_framerate": fps_value, - "-vcodec": (self._codec or "libx264").strip() or "libx264", + "-input_framerate": float(fps_value), + "-vcodec": codec_value, "-crf": int(self._crf), } - # TODO deal with pixel format + + if self._writer_options is not None: + writer_kwargs.update(self._writer_options) + + # if not self._convert_grayscale_to_rgb: + # writer_kwargs.update( + # { + # "-pix_fmt": "yuv420p", + # } + # ) + # if self._frame_size is not None: + # h, w = self._frame_size + # writer_kwargs["-output_dimensions"] = (int(w), int(h)) self._output.parent.mkdir(parents=True, exist_ok=True) self._writer = WriteGear(output=str(self._output), **writer_kwargs) @@ -132,6 +222,7 @@ def start(self) -> None: self._last_latency = 0.0 self._written_times.clear() self._frame_timestamps.clear() + self._hardware_timestamp_source = None self._encode_error = None self._stop_event.clear() self._writer_thread = threading.Thread( @@ -145,7 +236,9 @@ def configure_stream(self, frame_size: tuple[int, int], frame_rate: float | None self._frame_size = frame_size self._frame_rate = frame_rate - def write(self, frame: np.ndarray, timestamp: float | None = None) -> bool: + def write( + self, frame: np.ndarray, timestamp: float | None = None, timestamp_metadata: object | None = None + ) -> bool: error = self._current_error() if error is not None: raise RuntimeError(f"Video encoding failed: {error}") from error @@ -160,41 +253,58 @@ def write(self, frame: np.ndarray, timestamp: float | None = None) -> bool: if timestamp is None: timestamp = time.time() - # Convert frame to uint8 if needed - if frame.dtype != np.uint8: - frame_float = frame.astype(np.float32, copy=False) - max_val = float(frame_float.max()) if frame_float.size else 0.0 - scale = 1.0 - if max_val > 0: - scale = 255.0 / max_val if max_val > 255.0 else (255.0 if max_val <= 1.0 else 1.0) - frame = np.clip(frame_float * scale, 0.0, 255.0).astype(np.uint8) - - # Convert grayscale to RGB if needed - if frame.ndim == 2: - frame = np.repeat(frame[:, :, None], 3, axis=2) - - # Ensure contiguous array - frame = np.ascontiguousarray(frame) - - # Check if frame size matches expected size - if self._frame_size is not None: - expected_h, expected_w = self._frame_size - actual_h, actual_w = frame.shape[:2] - if (actual_h, actual_w) != (expected_h, expected_w): - logger.warning( - f"Frame size mismatch: expected (h={expected_h}, w={expected_w}), " - f"got (h={actual_h}, w={actual_w}). " - "Stopping recorder to prevent encoding errors." + with self._process_timing.measure("Recorder.preprocess"): + # Convert frame to uint8 if needed + if frame.dtype != np.uint8: + frame_float = frame.astype(np.float32, copy=False) + max_val = float(frame_float.max()) if frame_float.size else 0.0 + scale = 1.0 + if max_val > 0: + scale = 255.0 / max_val if max_val > 255.0 else (255.0 if max_val <= 1.0 else 1.0) + frame = np.clip(frame_float * scale, 0.0, 255.0).astype(np.uint8) + + # Convert grayscale to RGB if needed + if self._convert_grayscale_to_rgb and frame.ndim == 2: + frame = np.repeat(frame[:, :, None], 3, axis=2) + + # Ensure contiguous array + frame = np.ascontiguousarray(frame) + + if not self._logged_first_frame: + self._logged_first_frame = True + logger.info( + "Recorder %s first frame: shape=%s dtype=%s " + "contiguous=%s nbytes=%.2f MB convert_grayscale_to_rgb=%s", + self._output.name, + frame.shape, + frame.dtype, + frame.flags.c_contiguous, + frame.nbytes / (1024 * 1024), + self._convert_grayscale_to_rgb, ) - # Set error to stop recording gracefully - with self._stats_lock: - self._encode_error = ValueError( - f"Frame size changed from (h={expected_h}, w={expected_w}) to (h={actual_h}, w={actual_w})" + + # Check if frame size matches expected size + if self._frame_size is not None: + expected_h, expected_w = self._frame_size + actual_h, actual_w = frame.shape[:2] + if (actual_h, actual_w) != (expected_h, expected_w): + message = ( + f"Frame size mismatch for recorder {self._output.name}: " + f"expected_hw=({expected_h}, {expected_w}) " + f"actual_hw=({actual_h}, {actual_w}) " + f"{self._describe_frame(frame)}. " + "Stopping recorder to prevent FFmpeg pipe errors." ) - return False + + logger.warning(message) + self._set_encode_error(message) + self._process_timing.note_error() + self._process_timing.maybe_log() + return False try: - q.put((frame, timestamp), block=False) + with self._process_timing.measure("Recorder.queue_put"): + q.put((frame, timestamp, timestamp_metadata), block=False) except queue.Full: with self._stats_lock: self._dropped_frames += 1 @@ -204,9 +314,16 @@ def write(self, frame: np.ndarray, timestamp: float | None = None) -> bool: queue_size, self._buffer_size, ) + self._process_timing.note_error() + self._process_timing.maybe_log() return False + with self._stats_lock: self._frames_enqueued += 1 + + self._process_timing.note_frame() + self._process_timing.maybe_log() + return True def stop(self) -> None: @@ -282,12 +399,21 @@ def get_stats(self) -> RecorderStats | None: avg_latency = self._total_latency / self._frames_written if self._frames_written else 0.0 last_latency = self._last_latency write_fps = self._compute_write_fps_locked() - buffer_seconds = queue_size * avg_latency if avg_latency > 0 else 0.0 + + if write_fps > 0: + buffer_seconds = queue_size / write_fps + elif avg_latency > 0: + buffer_seconds = queue_size * avg_latency + elif last_latency > 0: + buffer_seconds = queue_size * last_latency + else: + buffer_seconds = 0.0 return RecorderStats( frames_enqueued=frames_enqueued, frames_written=frames_written, dropped_frames=dropped, queue_size=queue_size, + buffer_size=self._buffer_size, average_latency=avg_latency, last_latency=last_latency, write_fps=write_fps, @@ -311,9 +437,12 @@ def _writer_loop(self) -> None: break continue except Exception as exc: - with self._stats_lock: - self._encode_error = exc - logger.exception("Could not retrieve item from queue", exc_info=exc) + message = ( + f"Could not retrieve frame from recorder queue for {self._output.name}: " + f"{type(exc).__name__}: {exc!s}" + ) + self._set_encode_error(message, exc) + logger.exception(message) self._stop_event.set() break @@ -321,19 +450,68 @@ def _writer_loop(self) -> None: if item is _SENTINEL: break else: - frame, timestamp = item + frame, timestamp, timestamp_metadata = item start = time.perf_counter() try: writer = self._writer if writer is None: raise RuntimeError("WriteGear writer is not initialized") - writer.write(frame) + + with self._writer_timing.measure("Recorder.writer_write"): + writer.write(frame) + + record: dict[str, Any] = { + "frame_index": self._frames_written, + "software_timestamp": float(timestamp), + } + + if timestamp_metadata is not None: + if ( + hasattr(timestamp_metadata, "to_source_dict") + and self._hardware_timestamp_source is None + ): + self._hardware_timestamp_source = timestamp_metadata.to_source_dict() + + if hasattr(timestamp_metadata, "to_frame_dict"): + record["hardware_timestamp"] = timestamp_metadata.to_frame_dict() + if hasattr(timestamp_metadata, "get_default_reported"): + default_value = timestamp_metadata.get_default_reported() + if default_value is not None: + record["hardware_timestamp_default"] = default_value + elif isinstance(timestamp_metadata, dict): + record["hardware_timestamp"] = dict(timestamp_metadata) + else: + record["hardware_timestamp"] = repr(timestamp_metadata) + + self._frame_timestamps.append(record) + except Exception as exc: + queue_size = q.qsize() if q is not None else -1 + with self._stats_lock: - self._encode_error = exc - logger.exception("Video encoding failed while writing frame", exc_info=exc) + frames_enqueued = self._frames_enqueued + frames_written = self._frames_written + dropped_frames = self._dropped_frames + + message = ( + f"Video encoding failed for recorder {self._output.name}: " + f"{type(exc).__name__}: {exc!s}. " + f"{self._describe_frame(frame)} " + f"expected_frame_size={self._frame_size} " + f"frames_written={frames_written} " + f"frames_enqueued={frames_enqueued} " + f"dropped={dropped_frames} " + f"queue_size={queue_size}. " + "The FFmpeg/WriteGear pipe is no longer usable; stopping this recorder." + ) + + self._set_encode_error(message, exc) + + logger.exception(message) self._stop_event.set() + self._writer_timing.note_error() + self._writer_timing.maybe_log() break else: elapsed = time.perf_counter() - start @@ -343,11 +521,13 @@ def _writer_loop(self) -> None: self._total_latency += elapsed self._last_latency = elapsed self._written_times.append(now) - self._frame_timestamps.append(timestamp) if now - self._last_log_time >= 1.0: self._compute_write_fps_locked() self._last_log_time = now + self._writer_timing.note_frame() + self._writer_timing.maybe_log() + finally: # Ensure queue accounting is correct for every item pulled from q try: @@ -377,37 +557,80 @@ def _compute_write_fps_locked(self) -> float: return 0.0 return (len(self._written_times) - 1) / duration + def _describe_frame(self, frame: np.ndarray | None) -> str: + if frame is None: + return "frame=None" + + try: + return ( + f"shape={frame.shape} " + f"dtype={frame.dtype} " + f"contiguous={frame.flags.c_contiguous} " + f"nbytes={frame.nbytes / (1024 * 1024):.2f}MB" + ) + except Exception: + return f"frame=" + def _current_error(self) -> Exception | None: with self._stats_lock: return self._encode_error + def _set_encode_error(self, message: str, exc: Exception | None = None) -> Exception: + error = RuntimeError(message) + if exc is not None: + error.__cause__ = exc + + with self._stats_lock: + self._encode_error = error + + return error + def _save_timestamps(self) -> None: """Save frame timestamps to a JSON file alongside the video.""" if not self._frame_timestamps: logger.info("No timestamps to save") return - # Create timestamps file path - timestamp_file = self._output.with_suffix("").with_suffix(self._output.suffix + "_timestamps.json") + timestamp_file = self.timestamp_json_path try: with self._stats_lock: - timestamps = self._frame_timestamps.copy() + frame_timestamps = self._frame_timestamps.copy() + hardware_timestamp_source = ( + dict(self._hardware_timestamp_source) if self._hardware_timestamp_source is not None else None + ) + + software_timestamps = [ + float(rec["software_timestamp"]) for rec in frame_timestamps if "software_timestamp" in rec + ] - # Prepare metadata data = { + "schema_version": 2, "video_file": str(self._output.name), - "num_frames": len(timestamps), - "timestamps": timestamps, - "start_time": timestamps[0] if timestamps else None, - "end_time": timestamps[-1] if timestamps else None, - "duration_seconds": timestamps[-1] - timestamps[0] if len(timestamps) > 1 else 0.0, + "num_frames": len(frame_timestamps), + # "timestamps": software_timestamps, + "timestamp_sources": { + "software_timestamp": { + "source": "host_time.time", + "backend": "host", + "kind": "software_wall_clock", + "timebase": "Unix epoch", + "unit": "seconds", + "description": "Host-side software timestamp captured during acquisition.", + }, + "hardware_timestamp": hardware_timestamp_source, + }, + "frame_timestamps": frame_timestamps, + "start_time": software_timestamps[0] if software_timestamps else None, + "end_time": software_timestamps[-1] if software_timestamps else None, + "duration_seconds": ( + software_timestamps[-1] - software_timestamps[0] if len(software_timestamps) > 1 else 0.0 + ), } - # Write to JSON with open(timestamp_file, "w") as f: json.dump(data, f, indent=2) - logger.info(f"Saved {len(timestamps)} frame timestamps to {timestamp_file}") + logger.info("Saved %d frame timestamps to %s", len(frame_timestamps), timestamp_file) except Exception as exc: - logger.exception(f"Failed to save timestamps to {timestamp_file}: {exc}") + logger.exception("Failed to save timestamps to %s: %s", timestamp_file, exc) diff --git a/dlclivegui/temp/engine.py b/dlclivegui/temp/engine.py index a6bb225eb..85c4755f7 100644 --- a/dlclivegui/temp/engine.py +++ b/dlclivegui/temp/engine.py @@ -6,7 +6,7 @@ # or if we update dlclive.Engine to have these methods and use that instead of a separate enum here. # The latter would be more cohesive but also creates a dependency from utils to dlclive, # pending release of dlclive -class Engine(Enum): +class Engine(str, Enum): TENSORFLOW = "tensorflow" PYTORCH = "pytorch" @@ -26,6 +26,12 @@ def is_tensorflow_model_dir_path(model_path: str | Path) -> bool: @classmethod def from_model_type(cls, model_type: str) -> "Engine": + if not isinstance(model_type, str): + try: + model_type = getattr(model_type, "value", str(model_type)) + except Exception as e: + raise ValueError(f"Could not convert model_type to string: {model_type}") from e + if model_type.lower() == "pytorch": return cls.PYTORCH elif model_type.lower() in ("tensorflow", "base", "tensorrt", "lite"): diff --git a/dlclivegui/utils/display.py b/dlclivegui/utils/display.py index 00eae0d64..5cd297449 100644 --- a/dlclivegui/utils/display.py +++ b/dlclivegui/utils/display.py @@ -38,10 +38,10 @@ def compute_tiling_geometry( """Compute consistent tiling geometry for both tiling and overlay transforms. Returns: - (sorted_cam_ids, rows, cols, tile_w, tile_h) + (cam_ids, rows, cols, tile_w, tile_h) Notes: - - We intentionally base tile aspect on the first frame in sorted_cam_ids, + - We intentionally base tile aspect on the first frame in cam_ids, because create_tiled_frame uses the same ordering. This guarantees that compute_tile_info() and create_tiled_frame() agree on tile_w/tile_h. - If frames have different aspect ratios, they will be resized (possibly distorted) @@ -50,7 +50,7 @@ def compute_tiling_geometry( if not frames: return ([], 1, 1, 640, 480) - cam_ids = sorted(frames.keys()) + cam_ids = list(frames.keys()) frames_list = [frames[cid] for cid in cam_ids] num_frames = len(frames_list) @@ -63,7 +63,7 @@ def compute_tiling_geometry( max_w, max_h = max_canvas - # Reference aspect is based on the first frame in sorted order (matches tiler). + # Reference aspect is based on the first frame in display order (matches tiler). h0, w0 = frames_list[0].shape[:2] frame_aspect = (w0 / h0) if h0 > 0 else 1.0 @@ -138,7 +138,7 @@ def compute_tile_info( Critical robustness fix: - Tile dimensions are computed from the same reference used by create_tiled_frame() - (first frame in sorted order), so offsets/scales match the actual tiling. + (first frame in display order), so offsets/scales match the actual tiling. """ if not frames: return (0, 0), (1.0, 1.0) diff --git a/dlclivegui/utils/settings_store.py b/dlclivegui/utils/settings_store.py index fcf36fdd3..c6c0171e0 100644 --- a/dlclivegui/utils/settings_store.py +++ b/dlclivegui/utils/settings_store.py @@ -13,56 +13,192 @@ class DLCLiveGUISettingsStore: + """Small QSettings-backed store for lightweight GUI preferences. + + Stores UI/session preferences that should survive + application restarts but do not necessarily belong in exported JSON configs. + + Full application configuration snapshots are also stored here separately as + JSON for convenient startup restore. + """ + + # --- app/config keys --- + KEY_LAST_CONFIG_PATH = "app/last_config_path" + KEY_CONFIG_JSON = "app/config_json" + + # --- dlc/model keys --- + KEY_LAST_MODEL_PATH = "dlc/last_model_path" + KEY_PROCESSOR_FOLDER = "dlc/processor_folder" + KEY_INFERENCE_CAMERA_ID = "dlc/inference_camera_id" + KEY_PROCESSOR_KEY = "dlc/processor_key" + KEY_PROCESSOR_CONTROL_ENABLED = "dlc/processor_control_enabled" + + # --- recording keys --- + KEY_SESSION_NAME = "recording/session_name" + KEY_USE_TIMESTAMP = "recording/use_timestamp" + KEY_FAST_ENCODING = "recording/fast_encoding" + def __init__(self, qsettings: QSettings | None = None): self._s = qsettings or QSettings("DeepLabCut", "DLCLiveGUI") - # --- lightweight prefs --- + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + def _get_bool(self, key: str, default: bool = False) -> bool: + """Read a bool from QSettings, handling Qt/string/int variants.""" + value = self._s.value(key, default) + + if isinstance(value, bool): + return value + + if isinstance(value, (int, float)): + return bool(value) + + if isinstance(value, str): + text = value.strip().lower() + if text in {"1", "true", "yes", "on"}: + return True + if text in {"0", "false", "no", "off"}: + return False + + return bool(default) + + def _get_optional_str(self, key: str, default: str = "") -> str | None: + """Read an optional string from QSettings.""" + value = self._s.value(key, default) + value = str(value).strip() if value is not None else "" + return value or None + + def _set_optional_str(self, key: str, value: str | None) -> None: + """Persist optional string values as empty strings when unset.""" + self._s.setValue(key, str(value).strip() if value else "") + + # ------------------------------------------------------------------ + # App/model prefs + # ------------------------------------------------------------------ def get_last_model_path(self) -> str | None: - v = self._s.value("dlc/last_model_path", "") - return str(v) if v else None + return self._get_optional_str(self.KEY_LAST_MODEL_PATH) def set_last_model_path(self, path: str) -> None: - self._s.setValue("dlc/last_model_path", path or "") + self._set_optional_str(self.KEY_LAST_MODEL_PATH, path) def get_last_config_path(self) -> str | None: - v = self._s.value("app/last_config_path", "") - return str(v) if v else None + return self._get_optional_str(self.KEY_LAST_CONFIG_PATH) def set_last_config_path(self, path: str) -> None: - self._s.setValue("app/last_config_path", path or "") + self._set_optional_str(self.KEY_LAST_CONFIG_PATH, path) + # ------------------------------------------------------------------ + # Recording prefs + # ------------------------------------------------------------------ def get_session_name(self) -> str: - v = self._s.value("recording/session_name", "") - return str(v) if v else "" + return self._get_optional_str(self.KEY_SESSION_NAME) or "" def set_session_name(self, name: str) -> None: - self._s.setValue("recording/session_name", name or "") + self._set_optional_str(self.KEY_SESSION_NAME, name) def get_use_timestamp(self, default: bool = True) -> bool: - v = self._s.value("recording/use_timestamp", default) - if isinstance(v, bool): - return v - if isinstance(v, (int, float)): - return bool(v) - if isinstance(v, str): - return v.strip().lower() in ("1", "true", "yes", "on") - return bool(default) + return self._get_bool(self.KEY_USE_TIMESTAMP, default=default) def set_use_timestamp(self, value: bool) -> None: - self._s.setValue("recording/use_timestamp", bool(value)) + self._s.setValue(self.KEY_USE_TIMESTAMP, bool(value)) + + def get_fast_encoding(self, default: bool = False) -> bool: + return self._get_bool(self.KEY_FAST_ENCODING, default=default) - # --- optional: snapshot full config as JSON in QSettings --- + def set_fast_encoding(self, enabled: bool) -> None: + self._s.setValue(self.KEY_FAST_ENCODING, bool(enabled)) + + # ------------------------------------------------------------------ + # DLC camera / processor prefs + # ------------------------------------------------------------------ + def get_inference_camera_id(self, default: str | None = None) -> str | None: + """Return the last explicitly selected DLC inference camera ID. + + This is a user preference. Runtime fallbacks during preview should not + overwrite this value unless the user explicitly changes the combo. + """ + return self._get_optional_str(self.KEY_INFERENCE_CAMERA_ID, default or "") + + def set_inference_camera_id(self, camera_id: str | None) -> None: + """Persist the explicitly selected DLC inference camera ID.""" + self._set_optional_str(self.KEY_INFERENCE_CAMERA_ID, camera_id) + + def get_processor_key(self, default: str | None = None) -> str | None: + """Return the last selected processor key, if any.""" + return self._get_optional_str(self.KEY_PROCESSOR_KEY, default or "") + + def set_processor_key(self, processor_key: str | None) -> None: + """Persist the selected processor key. + + The key may become unavailable if the processor folder changes. In that + case the GUI should simply fall back to "No Processor" while keeping + refresh behavior graceful. + """ + self._set_optional_str(self.KEY_PROCESSOR_KEY, processor_key) + + def get_processor_control_enabled(self, default: bool = False) -> bool: + """Return whether processor-based control was enabled last time.""" + return self._get_bool(self.KEY_PROCESSOR_CONTROL_ENABLED, default=default) + + def set_processor_control_enabled(self, enabled: bool) -> None: + """Persist processor-based control checkbox state.""" + self._s.setValue(self.KEY_PROCESSOR_CONTROL_ENABLED, bool(enabled)) + + def get_processor_folder(self, default: str = "") -> str: + """Return the persisted processor folder if it still exists. + + If the stored folder is missing or invalid, return default. + """ + value = self._s.value(self.KEY_PROCESSOR_FOLDER, default) + value = str(value).strip() if value is not None else "" + + if not value: + return default + + try: + path = Path(value).expanduser() + if path.is_dir(): + return str(path.resolve()) + except Exception: + logger.debug("Persisted processor folder is invalid: %s", value, exc_info=True) + + return default + + def set_processor_folder(self, folder: str) -> None: + """Persist processor folder only if it exists and is a directory. + + Invalid folders are ignored so we do not accidentally replace a valid + stored folder with an unusable value. + """ + folder = str(folder).strip() if folder is not None else "" + if not folder: + return + + try: + path = Path(folder).expanduser() + if path.is_dir(): + self._s.setValue(self.KEY_PROCESSOR_FOLDER, str(path.resolve())) + except Exception: + logger.debug("Failed to persist processor folder: %s", folder, exc_info=True) + + # ------------------------------------------------------------------ + # Full config snapshot + # ------------------------------------------------------------------ def save_full_config_snapshot(self, cfg: ApplicationSettings) -> None: - self._s.setValue("app/config_json", cfg.model_dump_json()) + """Persist the current full application config as JSON in QSettings.""" + self._s.setValue(self.KEY_CONFIG_JSON, cfg.model_dump_json()) def load_full_config_snapshot(self) -> ApplicationSettings | None: - raw = self._s.value("app/config_json", "") + """Load the previously persisted full application config snapshot.""" + raw = self._s.value(self.KEY_CONFIG_JSON, "") if not raw: return None + try: return ApplicationSettings.model_validate_json(str(raw)) except Exception: - logger.debug("Failed to load full config snapshot from QSettings") + logger.debug("Failed to load full config snapshot from QSettings", exc_info=True) return None @@ -76,19 +212,24 @@ def __init__(self, settings: QSettings | None = None): # Normalization helpers # ------------------------- def _as_path(self, p: str | None) -> Path | None: - """Best-effort conversion to Path (expand ~, interpret '.' as cwd).""" + """Best-effort conversion to Path. + + Expands '~' and interprets '.' as the current working directory. + """ if not p: return None + s = str(p).strip() if not s: return None + try: pp = Path(s).expanduser() if s in (".", "./"): pp = Path.cwd() return pp except Exception: - logger.debug("Failed to parse path: %s", p) + logger.debug("Failed to parse path: %s", p, exc_info=True) return None def _norm_existing_dir(self, p: str | None) -> str | None: @@ -96,27 +237,31 @@ def _norm_existing_dir(self, p: str | None) -> str | None: pp = self._as_path(p) if pp is None: return None + try: - # If a file was given, use its parent directory + # If a file was given, use its parent directory. if pp.exists() and pp.is_file(): pp = pp.parent if pp.exists() and pp.is_dir(): return str(pp.resolve()) except Exception: - logger.debug("Failed to normalize directory: %s", p) + logger.debug("Failed to normalize directory: %s", p, exc_info=True) + return None def _norm_existing_path(self, p: str | None) -> str | None: - """Return an absolute, resolved existing path (file or dir), else None.""" + """Return an absolute, resolved existing path, file or dir, else None.""" pp = self._as_path(p) if pp is None: return None + try: if pp.exists(): return str(pp.resolve()) except Exception: - logger.debug("Failed to normalize path: %s", p) + logger.debug("Failed to normalize path: %s", p, exc_info=True) + return None # ------------------------- @@ -131,28 +276,30 @@ def load_last(self) -> str | None: try: pp = Path(path) - # Accept a valid model *file* + + # Accept a valid model file. if pp.is_file() and (Engine.is_pytorch_model_path(pp) or Engine.is_tensorflow_model_dir_path(pp.parent)): return str(pp) except Exception: - logger.debug("Last model path not valid/usable: %s", path) + logger.debug("Last model path not valid/usable: %s", path, exc_info=True) return None def load_last_dir(self) -> str | None: """Return last directory if it still exists and is a directory.""" val = self._settings.value("dlc/last_model_dir") - d = self._norm_existing_dir(str(val)) if val else None - return d + return self._norm_existing_dir(str(val)) if val else None # ------------------------- # Save # ------------------------- def save_if_valid(self, path: str) -> None: - """ - Save last model path if it looks valid/usable, and always save its directory. - - For files: always save parent directory. - - For directories: save directory itself if it looks like a TF model dir. + """Save last model path if it looks valid/usable. + + Also saves a safe directory for QFileDialog.setDirectory(...). + + - For files: saves parent directory. + - For directories: saves the directory itself when appropriate. """ norm = self._norm_existing_path(path) if not norm: @@ -161,7 +308,7 @@ def save_if_valid(self, path: str) -> None: try: p = Path(norm) - # Always persist a *directory* that is safe for QFileDialog.setDirectory(...) + # Always persist a directory that is safe for QFileDialog.setDirectory(...). if p.is_dir(): model_dir = p else: @@ -171,13 +318,12 @@ def save_if_valid(self, path: str) -> None: if model_dir_norm: self._settings.setValue("dlc/last_model_dir", model_dir_norm) - # Persist model path if it is a valid model file, or a TF model directory + # Persist model path if it is a valid model file, or a TF model file + # whose parent is a TensorFlow model directory. if Engine.is_pytorch_model_path(p): self._settings.setValue("dlc/last_model_path", str(p)) elif p.parent.is_dir() and Engine.is_tensorflow_model_dir_path(p.parent): self._settings.setValue("dlc/last_model_path", str(p)) - # elif p.is_dir() and Engine.is_tensorflow_model_dir_path(p): - # self._settings.setValue("dlc/last_model_path", str(p)) except Exception: logger.debug("Failed to save model path: %s", path, exc_info=True) @@ -186,6 +332,7 @@ def save_last_dir(self, directory: str) -> None: d = self._norm_existing_dir(directory) if not d: return + try: self._settings.setValue("dlc/last_model_dir", d) except Exception: @@ -195,12 +342,12 @@ def save_last_dir(self, directory: str) -> None: # Resolve # ------------------------- def resolve(self, config_path: str | None) -> str: - """ - Resolve the best model path to display in the UI. + """Resolve the best model path to display in the UI. + Preference: - 1) config_path if valid/usable - 2) persisted last model path if valid/usable - 3) empty + 1. config_path if valid/usable + 2. persisted last model path if valid/usable + 3. empty string """ cfg = self._norm_existing_path(config_path) if cfg: @@ -211,7 +358,7 @@ def resolve(self, config_path: str | None) -> str: if p.is_dir() and Engine.is_tensorflow_model_dir_path(p): return cfg except Exception: - logger.debug("Config path not usable: %s", cfg) + logger.debug("Config path not usable: %s", cfg, exc_info=True) persisted = self.load_last() if persisted: @@ -220,16 +367,16 @@ def resolve(self, config_path: str | None) -> str: return "" def suggest_start_dir(self, fallback_dir: str | None = None) -> str: + """Pick the best directory to start file dialogs in. + + Guarantees: returns an existing absolute directory, never '.'. """ - Pick the best directory to start file dialogs in. - Guarantees: returns an existing absolute directory (never '.'). - """ - # 1) last dir + # 1. last dir last_dir = self.load_last_dir() if last_dir: return last_dir - # 2) directory of last valid model path + # 2. directory of last valid model path last = self.load_last() if last: try: @@ -243,25 +390,29 @@ def suggest_start_dir(self, fallback_dir: str | None = None) -> str: if d: return d except Exception: - logger.debug("Failed to derive start dir from last model: %s", last) + logger.debug("Failed to derive start dir from last model: %s", last, exc_info=True) - # 3) fallback dir (e.g. config.dlc.model_directory) + # 3. fallback dir, e.g. config.dlc.model_directory fb = self._norm_existing_dir(fallback_dir) if fb: return fb - # 4) last resort: cwd if exists else home + # 4. last resort: cwd if exists else home cwd = self._norm_existing_dir(str(Path.cwd())) return cwd or str(Path.home()) def suggest_selected_file(self) -> str | None: - """Return a file to preselect if it exists (only files, not directories).""" + """Return a file to preselect if it exists. + + Only files are returned, not directories. + """ last = self.load_last() if not last: return None + try: p = Path(last) return str(p) if p.exists() and p.is_file() else None except Exception: - logger.debug("Failed to check existence of last model: %s", last) + logger.debug("Failed to check existence of last model: %s", last, exc_info=True) return None diff --git a/dlclivegui/utils/stats.py b/dlclivegui/utils/stats.py index 23e9d57f0..0ef0528e2 100644 --- a/dlclivegui/utils/stats.py +++ b/dlclivegui/utils/stats.py @@ -1,19 +1,160 @@ # dlclivegui/utils/stats.py from __future__ import annotations -from dlclivegui.services.dlc_processor import ProcessorStats -from dlclivegui.services.video_recorder import RecorderStats +import logging +import time +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from dlclivegui.services.dlc_processor import ProcessorStats + + +@dataclass +class RecorderStats: + """Snapshot of recorder throughput metrics.""" + + frames_enqueued: int = 0 + frames_written: int = 0 + dropped_frames: int = 0 + queue_size: int = 0 + buffer_size: int = 0 + average_latency: float = 0.0 + last_latency: float = 0.0 + write_fps: float = 0.0 + buffer_seconds: float = 0.0 + + @property + def backlog_frames(self) -> int: + """Frames accepted by recorder but not yet written.""" + return max(0, self.frames_enqueued - self.frames_written) + + @property + def queue_fill_ratio(self) -> float: + """Queue fill ratio in [0, 1], or 0 when capacity is unknown.""" + if self.buffer_size <= 0: + return 0.0 + return min(1.0, max(0.0, self.queue_size / self.buffer_size)) + + +class WorkerTimingStats: + """Tiny timing accumulator for camera worker performance diagnostics. + + Usage: + with stats.measure("read"): + frame, ts = backend.read() + + Logs aggregate timings once per log_interval seconds. + """ + + def __init__( + self, camera_id: str, *, logger: logging.Logger | None = None, log_interval: float = 1.0, enabled: bool = True + ): + self.camera_id = camera_id + self.log_interval = float(log_interval) + self.enabled = bool(enabled) + self.logger = logger or logging.getLogger(__name__) + if self.enabled: # force logger to proper level + if not self.logger.isEnabledFor(logging.DEBUG): + self.logger.setLevel(logging.DEBUG) + + self._last_log = time.perf_counter() + self._frames = 0 + self._timeouts = 0 + self._errors = 0 + self._totals: dict[str, float] = {} + self._counts: dict[str, int] = {} + + class _Measure: + def __init__(self, parent: WorkerTimingStats, name: str): + self.parent = parent + self.name = name + self.t0 = 0.0 + self.elapsed = 0.0 + + def __enter__(self): + if self.parent.enabled: + self.t0 = time.perf_counter() + return self + + def __exit__(self, exc_type, exc, tb): + if not self.parent.enabled: + return False + + self.elapsed = time.perf_counter() - self.t0 + self.parent._totals[self.name] = self.parent._totals.get(self.name, 0.0) + self.elapsed + self.parent._counts[self.name] = self.parent._counts.get(self.name, 0) + 1 + return False + + def measure(self, name: str): + return self._Measure(self, name) + + def note_frame(self) -> None: + if self.enabled: + self._frames += 1 + + def note_timeout(self) -> None: + if self.enabled: + self._timeouts += 1 + + def note_error(self) -> None: + if self.enabled: + self._errors += 1 + + def maybe_log(self) -> None: + if not self.enabled: + return + + now = time.perf_counter() + elapsed = now - self._last_log + if elapsed < self.log_interval: + return + + fps = self._frames / max(elapsed, 1e-9) + + parts = [ + f"[Worker {self.camera_id}]", + f"fps={fps:.1f}", + f"frames={self._frames}", + ] + + if self._timeouts: + parts.append(f"timeouts={self._timeouts}") + if self._errors: + parts.append(f"errors={self._errors}") + + for name in sorted(self._totals): + count = max(self._counts.get(name, 0), 1) + avg_ms = 1000.0 * self._totals[name] / count + parts.append(f"avg_{name}_ms={avg_ms:.3f}") + + self.logger.debug(" ".join(parts)) + + self._last_log = now + self._frames = 0 + self._timeouts = 0 + self._errors = 0 + self._totals.clear() + self._counts.clear() def format_recorder_stats(stats: RecorderStats) -> str: latency_ms = stats.last_latency * 1000.0 avg_ms = stats.average_latency * 1000.0 buffer_ms = stats.buffer_seconds * 1000.0 + + if stats.buffer_size > 0: + fill_pct = stats.queue_fill_ratio * 100.0 + queue_text = f"{stats.queue_size}/{stats.buffer_size} ({fill_pct:.0f}%, ~{buffer_ms:.0f} ms)" + else: + queue_text = f"{stats.queue_size} (~{buffer_ms:.0f} ms)" + return ( f"{stats.frames_written}/{stats.frames_enqueued} frames | " f"write {stats.write_fps:.1f} fps | " f"latency {latency_ms:.1f} ms (avg {avg_ms:.1f} ms) | " - f"queue {stats.queue_size} (~{buffer_ms:.0f} ms) | " + f"queue {queue_text} | " + f"backlog {stats.backlog_frames} | " f"dropped {stats.dropped_frames}" ) diff --git a/dlclivegui/utils/timestamps.py b/dlclivegui/utils/timestamps.py new file mode 100644 index 000000000..dea14ed2d --- /dev/null +++ b/dlclivegui/utils/timestamps.py @@ -0,0 +1,82 @@ +# dlclivegui/utils/timestamps.py +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + + +@dataclass(frozen=True) +class FrameTimestampMetadata: + """Optional backend-provided timestamp metadata for a captured frame. + + This supplements, but does not replace, the software timestamp. + + Notes: + - `seconds` is in the backend/hardware timebase. + - `wall_clock_time` should only be set if the backend can confidently + provide Unix/wall-clock seconds. + - `raw_value` preserves the original device-specific timestamp. + """ + + source: str + backend: str + + # Which value should downstream consumers use by default, if any. + # Expected values: "seconds", "wall_clock_time", or "raw_value". + default_reported: str | None = None + + # Device/hardware timebase value, if convertible to seconds + seconds: float | None = None + + # True Unix/wall-clock timestamp, if available + wall_clock_time: float | None = None + + # Raw backend value, e.g. device clock ticks + raw_value: int | float | str | None = None + raw_unit: str | None = None + + # Conversion metadata. + tick_frequency_hz: float | None = None + timebase: str | None = None + + # e.g. "camera_clock", "ptp_camera_clock", "hardware_wall_clock", + # "frame_counter", "unknown" + kind: str = "unknown" + + # Backend-specific extras. + extra: dict[str, Any] | None = None + + def to_source_dict(self) -> dict[str, Any]: + """Return metadata that should be written once per recording stream.""" + return { + "source": self.source, + "backend": self.backend, + "default_reported": self.default_reported, + "raw_unit": self.raw_unit, + "tick_frequency_hz": self.tick_frequency_hz, + "timebase": self.timebase, + "kind": self.kind, + "extra": self.extra or {}, + } + + def to_frame_dict(self) -> dict[str, Any]: + """Return defined per-frame timestamp values only.""" + ts = {} + for k in ["seconds", "wall_clock_time", "raw_value"]: + v = getattr(self, k) + if v is not None: + ts[k] = v + return ts + + def to_dict(self) -> dict[str, Any]: + """Return full representation, useful for logging/debugging.""" + return { + **self.to_source_dict(), + **self.to_frame_dict(), + } + + def get_default_reported(self) -> int | float | str | None: + """Return the value selected by `default_reported`, if configured.""" + if not self.default_reported: + return None + return self.to_frame_dict().get(self.default_reported) diff --git a/dlclivegui/utils/utils.py b/dlclivegui/utils/utils.py index 6af003dad..bd72958cd 100644 --- a/dlclivegui/utils/utils.py +++ b/dlclivegui/utils/utils.py @@ -1,13 +1,18 @@ from __future__ import annotations import re +import sys +import threading import time +import traceback from collections import deque from collections.abc import Iterable from dataclasses import dataclass from datetime import datetime from pathlib import Path +from dlclivegui.config import DEFAULT_RECORDING_CONTAINER + _INVALID_CHARS = re.compile(r"[^A-Za-z0-9._-]+") @@ -36,7 +41,7 @@ def split_stem_ext(base_filename: str, container: str) -> tuple[str, str]: If user typed an extension, keep it. Else use container. """ base = (base_filename or "").strip() - container = (container or "mp4").strip().lstrip(".") or "mp4" + container = (container or DEFAULT_RECORDING_CONTAINER).strip().lstrip(".") or DEFAULT_RECORDING_CONTAINER if not base: base = "recording" @@ -85,6 +90,19 @@ def build_run_dir(session_dir: Path, *, use_timestamp: bool) -> Path: return run_dir +def format_thread_stack(thread: threading.Thread) -> str: + ident = thread.ident + if ident is None: + return f"Thread {thread.name!r} has no ident." + + frame = sys._current_frames().get(ident) + if frame is None: + return f"No Python stack frame found for thread {thread.name!r} ident={ident}." + + stack = "".join(traceback.format_stack(frame)) + return f"Stack for thread {thread.name!r} ident={ident}:\n{stack}" + + @dataclass(frozen=True) class RecordingPlan: session_dir: Path diff --git a/pyproject.toml b/pyproject.toml index 265d9530c..da48c7820 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,6 +63,9 @@ test = [ "tox", "tox-gh-actions", ] +profiling = [ + "scalene", +] tf = [ "deeplabcut-live[tf]>=1.1", ] diff --git a/tests/cameras/backends/conftest.py b/tests/cameras/backends/conftest.py index 9459c35ef..4c09d26f0 100644 --- a/tests/cameras/backends/conftest.py +++ b/tests/cameras/backends/conftest.py @@ -6,6 +6,7 @@ import os import threading from dataclasses import dataclass +from types import SimpleNamespace from typing import Any import numpy as np @@ -381,32 +382,116 @@ def _make(buffers): # ----------------------------------------------------------------------------- +class FakePylonTimeoutException(RuntimeError): + pass + + class FakePylon: - """Minimal fake for 'from pypylon import pylon' usage in basler_backend.""" + """Fake for 'from pypylon import pylon' used by BaslerCameraBackend.""" - # Constants used by Basler backend GrabStrategy_LatestImageOnly = 1 + GrabStrategy_OneByOne = 2 TimeoutHandling_ThrowException = 1 - PixelType_BGR8packed = 0x02180014 # arbitrary token + PixelType_BGR8packed = 0x02180014 OutputBitAlignment_MsbAligned = 1 + class _EnumEntry: + def __init__(self, symbolic: str): + self._symbolic = symbolic + + def GetSymbolic(self): + return self._symbolic + class _Feature: - def __init__(self, value=0): + def __init__( + self, + value=0, + *, + symbolics: list[str] | None = None, + minimum=None, + maximum=None, + increment=1, + writable=True, + readable=True, + ): self._value = value + self._symbolics = list(symbolics or []) + self._min = minimum + self._max = maximum + self._inc = increment + self._writable = writable + self._readable = readable + self.set_calls: list[object] = [] def SetValue(self, v): + if not self._writable: + raise RuntimeError("feature is not writable") + if self._symbolics and v not in self._symbolics: + raise RuntimeError(f"unsupported symbolic {v!r}; available={self._symbolics}") self._value = v + self.set_calls.append(v) def GetValue(self): + if not self._readable: + raise RuntimeError("feature is not readable") return self._value + def GetSymbolics(self): + return list(self._symbolics) + + def GetEntries(self): + return [FakePylon._EnumEntry(s) for s in self._symbolics] + + def IsWritable(self): + return bool(self._writable) + + def IsReadable(self): + return bool(self._readable) + + def GetMin(self): + if self._min is None: + raise RuntimeError("no min") + return self._min + + def GetMax(self): + if self._max is None: + raise RuntimeError("no max") + return self._max + + def GetInc(self): + return self._inc + class _DeviceInfo: - def __init__(self, serial: str): + def __init__( + self, + serial: str, + *, + vendor: str = "Basler", + model: str = "FakeBasler", + friendly: str | None = None, + full_name: str | None = None, + ): self._serial = serial + self._vendor = vendor + self._model = model + self._friendly = friendly or f"{vendor} {model} ({serial})" + self._full_name = full_name or f"FakeFullName-{serial}" def GetSerialNumber(self): return self._serial + def GetVendorName(self): + return self._vendor + + def GetModelName(self): + return self._model + + def GetFriendlyName(self): + return self._friendly + + def GetFullName(self): + return self._full_name + class _Device: def __init__(self, info): self.info = info @@ -433,12 +518,16 @@ class _GrabResult: def __init__(self, ok=True, array=None): self._ok = ok self._array = array + self.released = False def GrabSucceeded(self): return bool(self._ok) def Release(self): - return None + self.released = True + + def GetTimeStamp(self): + return 123456789 class InstantCamera: def __init__(self, device): @@ -446,36 +535,107 @@ def __init__(self, device): self._open = False self._grabbing = False - # Feature nodes the backend uses + self.retrieve_calls: list[int] = [] + self.start_calls = 0 + self.stop_calls = 0 + self.close_calls = 0 + self.software_trigger_calls = 0 + self._software_trigger_pending = 0 + + # General camera controls. + self.ExposureAuto = FakePylon._Feature("Off", symbolics=["Off", "Once", "Continuous"]) self.ExposureTime = FakePylon._Feature(1000.0) + self.GainAuto = FakePylon._Feature("Off", symbolics=["Off", "Once", "Continuous"]) self.Gain = FakePylon._Feature(0.0) - self.Width = FakePylon._Feature(1920) - self.Height = FakePylon._Feature(1080) + + self.Width = FakePylon._Feature(1920, minimum=64, maximum=4096, increment=2) + self.Height = FakePylon._Feature(1080, minimum=64, maximum=4096, increment=2) self.AcquisitionFrameRateEnable = FakePylon._Feature(False) self.AcquisitionFrameRate = FakePylon._Feature(30.0) + self.GevTimestampTickFrequency = FakePylon._Feature(1_000_000_000.0) + + self.MaxNumBuffer = FakePylon._Feature(10) + + # Basler/pypylon trigger features. + self.AcquisitionMode = FakePylon._Feature("Continuous", symbolics=["Continuous", "SingleFrame"]) + self.TriggerSelector = FakePylon._Feature("FrameStart", symbolics=["FrameStart"]) + self.TriggerMode = FakePylon._Feature("Off", symbolics=["Off", "On"]) + self.TriggerSource = FakePylon._Feature( + "Software", + symbolics=[ + "Software", + "Line1", + "Line2", + "Line3", + "PeriodicSignal1", + "Action1", + ], + ) + self.TriggerActivation = FakePylon._Feature( + "RisingEdge", + symbolics=["RisingEdge", "FallingEdge", "AnyEdge", "LevelHigh", "LevelLow"], + ) + self.TriggerDelay = FakePylon._Feature(0.0) + + # Generic output line features. + self.LineSelector = FakePylon._Feature("Line1", symbolics=["Line1", "Line2", "Line3"]) + self.LineMode = FakePylon._Feature("Input", symbolics=["Input", "Output"]) + self.LineSource = FakePylon._Feature( + "Off", + symbolics=["Off", "ExposureActive", "AcquisitionActive"], + ) + self.LineInverter = FakePylon._Feature(False) + + # Test knobs. + self.allow_hardware_trigger_frame = False + self.force_failed_grab = False def Open(self): self._open = True def Close(self): + self.close_calls += 1 self._open = False def IsOpen(self): return bool(self._open) def StartGrabbing(self, *_args, **_kwargs): + self.start_calls += 1 self._grabbing = True def StopGrabbing(self): + self.stop_calls += 1 self._grabbing = False def IsGrabbing(self): return bool(self._grabbing) - def RetrieveResult(self, *_args, **_kwargs): - # Always succeed with a small dummy image (BGR) - import numpy as np + def ExecuteSoftwareTrigger(self): + self.software_trigger_calls += 1 + self._software_trigger_pending += 1 + + def RetrieveResult(self, timeout_ms, *_args, **_kwargs): + self.retrieve_calls.append(int(timeout_ms)) + + if not self._grabbing: + raise FakePylonTimeoutException("Grab timed out: acquisition not started") + + if self.force_failed_grab: + return FakePylon._GrabResult(ok=False, array=None) + + trigger_on = self.TriggerMode.GetValue() == "On" + source = self.TriggerSource.GetValue() + + if trigger_on: + if source == "Software": + if self._software_trigger_pending <= 0: + raise FakePylonTimeoutException("Grab timed out: waiting for software trigger") + self._software_trigger_pending -= 1 + else: + if not self.allow_hardware_trigger_frame: + raise FakePylonTimeoutException("Grab timed out: waiting for hardware trigger") frame = np.zeros((10, 10, 3), dtype=np.uint8) return FakePylon._GrabResult(ok=True, array=frame) @@ -498,25 +658,64 @@ def Convert(self, grab_result): @pytest.fixture() def fake_pylon_module(): - """ - Returns the FakePylon 'module' and resets singleton devices for isolation. - """ - # reset singleton factory so devices list resets per test + """Returns fake pylon module and resets fake device inventory.""" FakePylon.TlFactory._instance = None + factory = FakePylon.TlFactory.GetInstance() + factory._devices = [ + FakePylon._DeviceInfo("FAKE-BASLER-0"), + FakePylon._DeviceInfo("FAKE-BASLER-1"), + ] return FakePylon @pytest.fixture() def patch_basler_sdk(monkeypatch, fake_pylon_module): - """ - Patch Basler backend to behave as if pypylon is installed, using FakePylon. - """ + """Patch Basler backend to use FakePylon.""" import dlclivegui.cameras.backends.basler_backend as bb + fake_genicam = SimpleNamespace(TimeoutException=FakePylonTimeoutException) + monkeypatch.setattr(bb, "pylon", fake_pylon_module, raising=False) + monkeypatch.setattr(bb, "genicam", fake_genicam) return fake_pylon_module +@pytest.fixture() +def basler_settings_factory(): + from dlclivegui.config import CameraSettings + + def _make( + *, + index=0, + name="BaslerTestCam", + width=0, + height=0, + fps=0.0, + exposure=0, + gain=0.0, + enabled=True, + properties=None, + ): + props = properties if isinstance(properties, dict) else {} + props.setdefault("basler", {}) + props["basler"] = dict(props["basler"]) + + return CameraSettings( + name=name, + index=index, + backend="basler", + width=width, + height=height, + fps=fps, + exposure=exposure, + gain=gain, + enabled=enabled, + properties=props, + ) + + return _make + + # ----------------------------------------------------------------------------- # Fake GenTL / harvesters SDK (SDK-free) + fixtures for strict lifecycle tests # ----------------------------------------------------------------------------- @@ -599,6 +798,18 @@ def __init__( self.GainAuto = _FakeNode("Off") self.Gain = _FakeNode(float(gain)) + # Trigger input nodes + self.AcquisitionMode = _FakeNode("Continuous", symbolics=["Continuous", "SingleFrame"]) + self.TriggerSelector = _FakeNode("FrameStart", symbolics=["FrameStart"]) + self.TriggerMode = _FakeNode("Off", symbolics=["Off", "On"]) + self.TriggerSource = _FakeNode("Line1", symbolics=["Line0", "Line1", "Software"]) + self.TriggerActivation = _FakeNode("RisingEdge", symbolics=["RisingEdge", "FallingEdge"]) + + # GPIO output nodes for master/follower setups + self.LineSelector = _FakeNode("Line0", symbolics=["Line0", "Line1", "Line2"]) + self.LineMode = _FakeNode("Input", symbolics=["Input", "Output"]) + self.LineSource = _FakeNode("Off", symbolics=["Off", "ExposureActive", "AcquisitionActive"]) + class _FakeRemoteDevice: def __init__(self, node_map: _FakeNodeMap): diff --git a/tests/cameras/backends/test_aravis_backend.py b/tests/cameras/backends/test_aravis_backend.py index 797fd11cb..4f7ac55e8 100644 --- a/tests/cameras/backends/test_aravis_backend.py +++ b/tests/cameras/backends/test_aravis_backend.py @@ -243,7 +243,7 @@ def make_backend(settings, buffers): @pytest.mark.unit def test_device_name(): - be, cam, s = make_backend(Settings(), []) + be, _cam, s = make_backend(Settings(), []) assert be.device_name() == "FakeVendor FakeModel (12345)" @@ -253,9 +253,9 @@ def test_read_mono8(): data = (np.arange(w * h) % 256).astype(np.uint8).tobytes() buf = FakeAravis.Buffer(data, w, h, FakeAravis.PIXEL_FORMAT_MONO_8) - be, cam, s = make_backend(Settings(), [buf]) + be, _cam, s = make_backend(Settings(), [buf]) - frame, ts = be.read() + frame = be.read().frame assert frame.shape == (h, w, 3) assert frame.dtype == np.uint8 # Ensure grayscale expanded to 3 channels @@ -272,9 +272,9 @@ def test_read_rgb8_converts_to_bgr(): data = np.array([255, 0, 0, 0, 255, 0], dtype=np.uint8).tobytes() buf = FakeAravis.Buffer(data, w, h, FakeAravis.PIXEL_FORMAT_RGB_8_PACKED) - be, cam, s = make_backend(Settings(), [buf]) + be, _cam, s = make_backend(Settings(), [buf]) - frame, _ = be.read() + frame = be.read().frame assert frame.shape == (1, 2, 3) # BGR conversion: red → [0,0,255], green → [0,255,0] assert (frame[0, 0] == np.array([0, 0, 255])).all() @@ -288,9 +288,9 @@ def test_read_bgr8_passthrough(): data = np.array([10, 20, 30, 40, 50, 60], dtype=np.uint8).tobytes() buf = FakeAravis.Buffer(data, w, h, FakeAravis.PIXEL_FORMAT_BGR_8_PACKED) - be, cam, s = make_backend(Settings(), [buf]) + be, _cam, s = make_backend(Settings(), [buf]) - frame, _ = be.read() + frame = be.read().frame assert frame.shape == (1, 2, 3) assert (frame.flatten() == np.array([10, 20, 30, 40, 50, 60])).all() assert s.pushed >= 1 @@ -302,9 +302,9 @@ def test_read_mono16_scaling(): raw = np.array([0, 32768, 65535], dtype=np.uint16) buf = FakeAravis.Buffer(raw.tobytes(), w, h, FakeAravis.PIXEL_FORMAT_MONO_16) - be, cam, s = make_backend(Settings(), [buf]) + be, _cam, s = make_backend(Settings(), [buf]) - frame, _ = be.read() + frame = be.read().frame assert frame.shape == (1, 3, 3) # scaling: 0 → 0, max → 255, mid → ~128 @@ -320,9 +320,9 @@ def test_read_unknown_format_fallback_to_mono8(): data = (np.arange(w * h) % 256).astype(np.uint8).tobytes() # Unknown token buf = FakeAravis.Buffer(data, w, h, "SOME_UNKNOWN_FMT") - be, cam, s = make_backend(Settings(), [buf]) + be, _cam, s = make_backend(Settings(), [buf]) - frame, _ = be.read() + frame = be.read().frame assert frame.shape == (h, w, 3) assert np.all(frame[..., 0] == frame[..., 1]) assert np.all(frame[..., 1] == frame[..., 2]) @@ -331,7 +331,7 @@ def test_read_unknown_format_fallback_to_mono8(): @pytest.mark.unit def test_read_timeout_raises(): - be, cam, s = make_backend(Settings(), []) + be, _cam, s = make_backend(Settings(), []) with pytest.raises(TimeoutError): be.read() @@ -341,7 +341,7 @@ def test_read_status_error_raises_and_pushes_back(): w, h = 1, 1 data = b"\x00" buf = FakeAravis.Buffer(data, w, h, FakeAravis.PIXEL_FORMAT_MONO_8, status="ERROR") - be, cam, s = make_backend(Settings(), [buf]) + be, _cam, s = make_backend(Settings(), [buf]) with pytest.raises(TimeoutError): be.read() @@ -350,7 +350,7 @@ def test_read_status_error_raises_and_pushes_back(): @pytest.mark.unit def test_close_is_idempotent(): - be, cam, s = make_backend(Settings(), []) + be, _cam, s = make_backend(Settings(), []) be.close() be.close() # should not raise diff --git a/tests/cameras/backends/test_basler_backend.py b/tests/cameras/backends/test_basler_backend.py new file mode 100644 index 000000000..e17a305fd --- /dev/null +++ b/tests/cameras/backends/test_basler_backend.py @@ -0,0 +1,554 @@ +from __future__ import annotations + +import numpy as np +import pytest + +from dlclivegui.cameras.base import CapturedFrame +from dlclivegui.utils.timestamps import FrameTimestampMetadata + +# --------------------------------------------------------------------- +# Core lifecycle +# --------------------------------------------------------------------- + + +def test_basler_open_starts_grabbing_and_read_returns_frame(patch_basler_sdk, basler_settings_factory): + import dlclivegui.cameras.backends.basler_backend as bb + + settings = basler_settings_factory() + be = bb.BaslerCameraBackend(settings) + + be.open() + + assert be._camera is not None + assert be._camera.IsOpen() + assert be._camera.IsGrabbing() + assert be._converter is not None + + payload = be.read() + frame, ts = payload.frame, payload.software_timestamp + assert isinstance(ts, float) + assert isinstance(frame, np.ndarray) + assert frame.shape == (10, 10, 3) + + be.close() + assert be._camera is None + assert be._converter is None + + +def test_basler_fast_start_does_not_start_grabbing_and_read_raises( + patch_basler_sdk, + basler_settings_factory, +): + import dlclivegui.cameras.backends.basler_backend as bb + + settings = basler_settings_factory(properties={"basler": {"fast_start": True}}) + be = bb.BaslerCameraBackend(settings) + + be.open() + + assert be._camera is not None + assert be._camera.IsOpen() + assert not be._camera.IsGrabbing() + assert be._converter is None + + with pytest.raises(RuntimeError, match="fast-start"): + be.read() + + be.close() + + +def test_basler_close_is_idempotent(patch_basler_sdk, basler_settings_factory): + import dlclivegui.cameras.backends.basler_backend as bb + + be = bb.BaslerCameraBackend(basler_settings_factory()) + be.open() + be.close() + be.close() + + +def test_basler_stop_before_open_and_after_close_is_safe(patch_basler_sdk, basler_settings_factory): + import dlclivegui.cameras.backends.basler_backend as bb + + be = bb.BaslerCameraBackend(basler_settings_factory()) + + be.stop() + + be.open() + be.stop() + + assert be._camera is not None + assert not be._camera.IsGrabbing() + + be.close() + be.stop() + + +def test_basler_read_before_open_raises_runtimeerror(patch_basler_sdk, basler_settings_factory): + import dlclivegui.cameras.backends.basler_backend as bb + + be = bb.BaslerCameraBackend(basler_settings_factory()) + + with pytest.raises(RuntimeError, match="not opened"): + be.read() + + +# --------------------------------------------------------------------- +# Discovery / identity / rebind +# --------------------------------------------------------------------- + + +def test_basler_discover_devices_returns_serial_identity_and_label( + patch_basler_sdk, +): + import dlclivegui.cameras.backends.basler_backend as bb + + cams = bb.BaslerCameraBackend.discover_devices(max_devices=10) + + assert len(cams) == 2 + assert cams[0].device_id == "FAKE-BASLER-0" + assert "Basler" in cams[0].label + assert "FAKE-BASLER-0" in cams[0].label + assert cams[0].path + + +def test_basler_quick_ping_true_for_existing_false_for_missing(patch_basler_sdk): + import dlclivegui.cameras.backends.basler_backend as bb + + assert bb.BaslerCameraBackend.quick_ping(0) is True + assert bb.BaslerCameraBackend.quick_ping(1) is True + assert bb.BaslerCameraBackend.quick_ping(2) is False + + +def test_basler_rebind_settings_uses_serial_device_id( + patch_basler_sdk, + basler_settings_factory, +): + import dlclivegui.cameras.backends.basler_backend as bb + + settings = basler_settings_factory( + index=0, + properties={"basler": {"device_id": "FAKE-BASLER-1"}}, + ) + + out = bb.BaslerCameraBackend.rebind_settings(settings) + + assert int(out.index) == 1 + ns = out.properties["basler"] + assert ns["device_id"] == "FAKE-BASLER-1" + assert ns["device_name"] + + +def test_basler_open_selects_device_id_and_persists_identity( + patch_basler_sdk, + basler_settings_factory, +): + import dlclivegui.cameras.backends.basler_backend as bb + + settings = basler_settings_factory( + index=0, + properties={"basler": {"device_id": "FAKE-BASLER-1"}}, + ) + + be = bb.BaslerCameraBackend(settings) + be.open() + + ns = settings.properties["basler"] + assert ns["device_id"] == "FAKE-BASLER-1" + assert ns["device_name"] + + be.close() + + +def test_basler_open_index_out_of_range_raises(patch_basler_sdk, basler_settings_factory): + import dlclivegui.cameras.backends.basler_backend as bb + + settings = basler_settings_factory(index=99) + be = bb.BaslerCameraBackend(settings) + + with pytest.raises(RuntimeError, match="out of range"): + be.open() + + +# --------------------------------------------------------------------- +# Camera controls +# --------------------------------------------------------------------- + + +def test_basler_resolution_auto_does_not_modify_dimensions( + patch_basler_sdk, + basler_settings_factory, +): + import dlclivegui.cameras.backends.basler_backend as bb + + settings = basler_settings_factory(width=0, height=0) + be = bb.BaslerCameraBackend(settings) + + be.open() + + assert be._camera.Width.GetValue() == 1920 + assert be._camera.Height.GetValue() == 1080 + assert be.actual_resolution == (1920, 1080) + + be.close() + + +def test_basler_resolution_request_snaps_to_increment( + patch_basler_sdk, + basler_settings_factory, +): + import dlclivegui.cameras.backends.basler_backend as bb + + settings = basler_settings_factory(width=641, height=481) + be = bb.BaslerCameraBackend(settings) + + be.open() + + assert be._camera.Width.GetValue() == 640 + assert be._camera.Height.GetValue() == 480 + assert be.actual_resolution == (640, 480) + + be.close() + + +def test_basler_exposure_gain_fps_are_applied_when_nonzero( + patch_basler_sdk, + basler_settings_factory, +): + import dlclivegui.cameras.backends.basler_backend as bb + + settings = basler_settings_factory(exposure=20000, gain=2.5, fps=50.0) + be = bb.BaslerCameraBackend(settings) + + be.open() + + assert be._camera.ExposureAuto.GetValue() == "Off" + assert be._camera.ExposureTime.GetValue() == pytest.approx(20000.0) + assert be._camera.GainAuto.GetValue() == "Off" + assert be._camera.Gain.GetValue() == pytest.approx(2.5) + assert be._camera.AcquisitionFrameRateEnable.GetValue() is True + assert be._camera.AcquisitionFrameRate.GetValue() == pytest.approx(50.0) + + be.close() + + +# --------------------------------------------------------------------- +# Basler trigger behavior +# --------------------------------------------------------------------- + + +def test_basler_static_capabilities_advertises_hardware_trigger_best_effort_and_mono( + patch_basler_sdk, +): + import dlclivegui.cameras.backends.basler_backend as bb + from dlclivegui.cameras.base import SupportLevel + + caps = bb.BaslerCameraBackend.static_capabilities() + assert caps["hardware_trigger"] == SupportLevel.BEST_EFFORT + assert caps["preserve_mono"] == SupportLevel.SUPPORTED + + +def test_basler_default_trigger_is_off_and_free_runs( + patch_basler_sdk, + basler_settings_factory, +): + import dlclivegui.cameras.backends.basler_backend as bb + + settings = basler_settings_factory() + be = bb.BaslerCameraBackend(settings) + + be.open() + + assert be._camera.TriggerMode.GetValue() == "Off" + assert be.waits_for_hardware_trigger is False + + payload = be.read() + frame = payload.frame + assert frame.shape == (10, 10, 3) + + be.close() + + +def test_basler_follower_auto_selects_line1_and_times_out_waiting_for_trigger( + patch_basler_sdk, + basler_settings_factory, +): + import dlclivegui.cameras.backends.basler_backend as bb + + settings = basler_settings_factory( + properties={ + "basler": { + "trigger": { + "role": "follower", + "selector": "FrameStart", + "source": "auto", + "activation": "RisingEdge", + "timeout": 5.0, + "strict": False, + } + } + } + ) + + be = bb.BaslerCameraBackend(settings) + + # Timeout is configured in seconds but pypylon RetrieveResult uses ms; + # hardware-trigger waits should be capped for responsive shutdown. + assert be._retrieve_timeout_ms == 1000 + + be.open() + + assert be.waits_for_hardware_trigger is True + assert be._camera.TriggerSelector.GetValue() == "FrameStart" + assert be._camera.TriggerSource.GetValue() == "Line1" + assert be._camera.TriggerActivation.GetValue() == "RisingEdge" + assert be._camera.TriggerMode.GetValue() == "On" + + with pytest.raises(TimeoutError, match="waiting for hardware trigger"): + be.read() + + assert be._camera.retrieve_calls[-1] == 1000 + + be.close() + + +def test_basler_follower_strict_invalid_source_raises( + patch_basler_sdk, + basler_settings_factory, +): + import dlclivegui.cameras.backends.basler_backend as bb + + settings = basler_settings_factory( + properties={ + "basler": { + "trigger": { + "role": "follower", + "selector": "FrameStart", + "source": "NotARealSource", + "activation": "RisingEdge", + "strict": True, + } + } + } + ) + + be = bb.BaslerCameraBackend(settings) + + with pytest.raises(RuntimeError, match="TriggerSource"): + be.open() + + +def test_basler_follower_non_strict_invalid_source_disables_trigger( + patch_basler_sdk, + basler_settings_factory, +): + import dlclivegui.cameras.backends.basler_backend as bb + + settings = basler_settings_factory( + properties={ + "basler": { + "trigger": { + "role": "follower", + "source": "NotARealSource", + "strict": False, + } + } + } + ) + + be = bb.BaslerCameraBackend(settings) + be.open() + + assert be._camera.TriggerMode.GetValue() == "Off" + assert be.waits_for_hardware_trigger is False + + payload = be.read() + frame = payload.frame + assert frame.shape == (10, 10, 3) + + be.close() + + +def test_basler_master_configures_generic_line_output_and_restores_on_close( + patch_basler_sdk, + basler_settings_factory, +): + import dlclivegui.cameras.backends.basler_backend as bb + + settings = basler_settings_factory( + properties={ + "basler": { + "trigger": { + "role": "master", + "output_line": "Line2", + "output_source": "ExposureActive", + "strict": False, + } + } + } + ) + + be = bb.BaslerCameraBackend(settings) + be.open() + + cam = be._camera + assert cam.LineSelector.GetValue() == "Line2" + assert cam.LineMode.GetValue() == "Output" + assert cam.LineSource.GetValue() == "ExposureActive" + assert be.waits_for_hardware_trigger is False + + be.close() + + # Local reference remains valid after backend clears self._camera. + assert cam.LineSource.GetValue() == "Off" + assert cam.LineMode.GetValue() == "Input" + + +@pytest.mark.xfail(reason="Software trigger support is not implemented yet.") +def test_basler_software_trigger_requires_trigger_once_before_read( + patch_basler_sdk, + basler_settings_factory, +): + import dlclivegui.cameras.backends.basler_backend as bb + + settings = basler_settings_factory( + properties={ + "basler": { + "trigger": { + "role": "software", + "selector": "FrameStart", + "strict": False, + } + } + } + ) + + be = bb.BaslerCameraBackend(settings) + be.open() + + assert be._camera.TriggerMode.GetValue() == "On" + assert be._camera.TriggerSource.GetValue() == "Software" + assert be.waits_for_hardware_trigger is False + + # No software trigger has been fired yet. + with pytest.raises(RuntimeError, match="Failed to retrieve image"): + be.read() + + be.trigger_once() + assert be._camera.software_trigger_calls == 1 + + frame = be.read().frame + assert frame.shape == (10, 10, 3) + + be.close() + + +def test_basler_close_turns_input_trigger_off( + patch_basler_sdk, + basler_settings_factory, +): + import dlclivegui.cameras.backends.basler_backend as bb + + settings = basler_settings_factory( + properties={ + "basler": { + "trigger": { + "role": "external", + "source": "Line1", + "activation": "RisingEdge", + } + } + } + ) + + be = bb.BaslerCameraBackend(settings) + be.open() + + cam = be._camera + assert cam.TriggerMode.GetValue() == "On" + + be.close() + + assert cam.TriggerMode.GetValue() == "Off" + + +def test_basler_hardware_trigger_maps_pylon_timeout_to_timeout_error( + monkeypatch, + patch_basler_sdk, + basler_settings_factory, +): + import dlclivegui.cameras.backends.basler_backend as bb + + class FakePylonTimeout(Exception): + pass + + settings = basler_settings_factory( + properties={ + "basler": { + "trigger": { + "role": "follower", + "source": "Line1", + } + } + } + ) + backend = bb.BaslerCameraBackend(settings) + backend.open() + + pylon_to = bb.genicam.TimeoutException + + def raise_timeout(*_args, **_kwargs): + raise pylon_to("Simulated timeout") + + monkeypatch.setattr(backend._camera, "RetrieveResult", raise_timeout) + + try: + with pytest.raises( + TimeoutError, + match="waiting for hardware trigger", + ) as exc_info: + backend.read() + + assert isinstance(exc_info.value.__cause__, bb.genicam.TimeoutException) + + finally: + backend.close() +class TestBaslerFrameTimestamps: + @pytest.mark.unit + def test_read_returns_captured_frame_with_hardware_timestamp_metadata( + self, + patch_basler_sdk, + basler_settings_factory, + ): + import dlclivegui.cameras.backends.basler_backend as bb + + settings = basler_settings_factory() + be = bb.BaslerCameraBackend(settings) + be.open() + + captured = be.read() + + assert isinstance(captured, CapturedFrame) + assert captured.frame is not None + assert isinstance(captured.software_timestamp, float) + + meta = captured.timestamp_metadata + assert isinstance(meta, FrameTimestampMetadata) + + assert meta.backend == "basler" + assert meta.source == "grab_result.GetTimeStamp" + assert meta.kind == "camera_clock" + assert meta.raw_unit == "ticks" + assert meta.raw_value == 123456789 + assert meta.tick_frequency_hz == pytest.approx(1_000_000_000.0) + assert meta.seconds == pytest.approx(0.123456789) + assert meta.default_reported == "seconds" + + source_dict = meta.to_source_dict() + assert source_dict["backend"] == "basler" + assert source_dict["source"] == "grab_result.GetTimeStamp" + + frame_dict = meta.to_frame_dict() + assert frame_dict["seconds"] == pytest.approx(0.123456789) + assert frame_dict["raw_value"] == 123456789 + + be.close() diff --git a/tests/cameras/backends/test_gentl_backend.py b/tests/cameras/backends/test_gentl_backend.py index 3ffdab204..3cb7d9ea2 100644 --- a/tests/cameras/backends/test_gentl_backend.py +++ b/tests/cameras/backends/test_gentl_backend.py @@ -54,12 +54,12 @@ def test_open_starts_stream_and_read_returns_frame(patch_gentl_sdk, gentl_settin assert be._acquirer is not None # Strict model validated via behavior: read must succeed after normal open() - frame, ts = be.read() - assert isinstance(ts, float) - assert isinstance(frame, np.ndarray) - assert frame.size > 0 + captured = be.read() + assert isinstance(captured.software_timestamp, float) + assert isinstance(captured.frame, np.ndarray) + assert captured.frame.size > 0 # Backend converts to BGR; ensure 3-channel output - assert frame.ndim == 3 and frame.shape[2] == 3 + assert captured.frame.ndim == 3 and captured.frame.shape[2] == 3 be.close() assert be._harvester is None @@ -422,7 +422,7 @@ def test_pixel_format_unavailable_does_not_crash_open_and_streams(patch_gentl_sd be.open() # No fake-internal checks; just verify it can read - frame, _ = be.read() + frame = be.read().frame assert frame is not None and frame.size > 0 be.close() diff --git a/tests/cameras/backends/test_gentl_trigger.py b/tests/cameras/backends/test_gentl_trigger.py new file mode 100644 index 000000000..b445f4ea8 --- /dev/null +++ b/tests/cameras/backends/test_gentl_trigger.py @@ -0,0 +1,460 @@ +# tests/cameras/backends/test_gentl_trigger.py +from __future__ import annotations + +import pytest + +# --------------------------------------------------------------------- +# GenTL hardware trigger configuration +# --------------------------------------------------------------------- + + +def _gentl_trigger_settings(gentl_settings_factory, trigger: dict, **kwargs): + """Build CameraSettings with a GenTL trigger block.""" + return gentl_settings_factory(properties={"gentl": {"trigger": trigger}}, **kwargs) + + +def test_gentl_capabilities_advertise_hardware_trigger_best_effort(patch_gentl_sdk): + gb = patch_gentl_sdk + + caps = gb.GenTLCameraBackend.static_capabilities() + + assert caps.get("hardware_trigger") == gb.SupportLevel.BEST_EFFORT + + +def test_trigger_default_off_configures_trigger_mode_off(patch_gentl_sdk, gentl_settings_factory): + gb = patch_gentl_sdk + + settings = gentl_settings_factory() + be = gb.GenTLCameraBackend(settings) + + be.open() + nm = be._acquirer.remote_device.node_map + + assert nm.TriggerMode.value == "Off" + + ns = settings.properties.get("gentl", {}) + assert ns.get("trigger_actual", {}).get("role") == "off" + + be.close() + + +def test_trigger_explicit_off_configures_trigger_mode_off(patch_gentl_sdk, gentl_settings_factory): + gb = patch_gentl_sdk + + settings = _gentl_trigger_settings(gentl_settings_factory, {"role": "off"}) + be = gb.GenTLCameraBackend(settings) + + be.open() + nm = be._acquirer.remote_device.node_map + + assert nm.TriggerMode.value == "Off" + assert settings.properties["gentl"]["trigger_actual"]["role"] == "off" + + be.close() + + +def test_trigger_external_configures_input_line_and_timeout(patch_gentl_sdk, gentl_settings_factory): + gb = patch_gentl_sdk + + settings = _gentl_trigger_settings( + gentl_settings_factory, + { + "role": "external", + "selector": "FrameStart", + "source": "Line0", + "activation": "RisingEdge", + "timeout": gb.GenTLCameraBackend._MAX_HARDWARE_TRIGGER_FETCH_TIMEOUT, + }, + ) + be = gb.GenTLCameraBackend(settings) + + be.open() + nm = be._acquirer.remote_device.node_map + + assert nm.TriggerSelector.value == "FrameStart" + assert nm.TriggerSource.value == "Line0" + assert nm.TriggerActivation.value == "RisingEdge" + assert nm.TriggerMode.value == "On" + assert be.waits_for_hardware_trigger is True + assert be._timeout == pytest.approx(gb.GenTLCameraBackend._MAX_HARDWARE_TRIGGER_FETCH_TIMEOUT) + + ns = settings.properties["gentl"] + assert ns["trigger_actual"]["role"] == "external" + assert ns["trigger_actual"]["source"] == "Line0" + + be.close() + + +def test_trigger_follower_configures_input_line(patch_gentl_sdk, gentl_settings_factory): + gb = patch_gentl_sdk + + settings = _gentl_trigger_settings( + gentl_settings_factory, + { + "role": "follower", + "selector": "FrameStart", + "source": "Line1", + "activation": "FallingEdge", + }, + ) + be = gb.GenTLCameraBackend(settings) + + be.open() + nm = be._acquirer.remote_device.node_map + + assert nm.TriggerSelector.value == "FrameStart" + assert nm.TriggerSource.value == "Line1" + assert nm.TriggerActivation.value == "FallingEdge" + assert nm.TriggerMode.value == "On" + + ns = settings.properties["gentl"] + assert ns["trigger_actual"]["role"] == "follower" + + be.close() + + +def test_trigger_master_configures_output_line_and_keeps_trigger_off(patch_gentl_sdk, gentl_settings_factory): + gb = patch_gentl_sdk + + settings = _gentl_trigger_settings( + gentl_settings_factory, + { + "role": "master", + "output_line": "Line2", + "output_source": "ExposureActive", + }, + ) + be = gb.GenTLCameraBackend(settings) + + be.open() + nm = be._acquirer.remote_device.node_map + + assert nm.TriggerMode.value == "Off" + assert nm.LineSelector.value == "Line2" + assert nm.LineMode.value == "Output" + assert nm.LineSource.value == "ExposureActive" + + ns = settings.properties["gentl"] + assert ns["trigger_actual"]["role"] == "master" + assert ns["trigger_actual"]["output_line"] == "Line2" + + be.close() + + +def test_trigger_invalid_source_non_strict_disables_trigger(patch_gentl_sdk, gentl_settings_factory): + gb = patch_gentl_sdk + + settings = _gentl_trigger_settings( + gentl_settings_factory, + { + "role": "external", + "source": "LineDoesNotExist", + "strict": False, + }, + ) + be = gb.GenTLCameraBackend(settings) + + be.open() + nm = be._acquirer.remote_device.node_map + + # Source was unsupported, so the fake node should retain its default. + assert nm.TriggerSource.value == "Line1" + + # Safety behavior: do not arm TriggerMode on the previous/default source. + assert nm.TriggerMode.value == "Off" + + # Controller should not treat timeouts as expected trigger waits. + assert be.waits_for_hardware_trigger is False + + # trigger_actual is persisted after _configure_trigger(); since we reset + # self._trigger to off, the effective trigger state is off. + actual = settings.properties["gentl"]["trigger_actual"] + assert actual["role"] == "off" + + be.close() + + +def test_trigger_invalid_source_strict_raises_and_cleans_up(patch_gentl_sdk, gentl_settings_factory): + gb = patch_gentl_sdk + + settings = _gentl_trigger_settings( + gentl_settings_factory, + { + "role": "external", + "source": "LineDoesNotExist", + "strict": True, + }, + ) + be = gb.GenTLCameraBackend(settings) + + with pytest.raises(RuntimeError): + be.open() + + assert be._harvester is None + assert be._shared_entry is None + assert be._acquirer is None + + +def test_trigger_invalid_master_output_source_non_strict_does_not_crash(patch_gentl_sdk, gentl_settings_factory): + gb = patch_gentl_sdk + + settings = _gentl_trigger_settings( + gentl_settings_factory, + { + "role": "master", + "output_line": "Line2", + "output_source": "NotARealLineSource", + "strict": False, + }, + ) + be = gb.GenTLCameraBackend(settings) + + be.open() + nm = be._acquirer.remote_device.node_map + + assert nm.TriggerMode.value == "Off" + assert nm.LineSelector.value == "Line2" + assert nm.LineMode.value == "Output" + # Unsupported source should not be applied in non-strict mode. + assert nm.LineSource.value == "Off" + + be.close() + + +def test_trigger_invalid_master_output_source_strict_raises_and_cleans_up(patch_gentl_sdk, gentl_settings_factory): + gb = patch_gentl_sdk + + settings = _gentl_trigger_settings( + gentl_settings_factory, + { + "role": "master", + "output_line": "Line2", + "output_source": "NotARealLineSource", + "strict": True, + }, + ) + be = gb.GenTLCameraBackend(settings) + + with pytest.raises(RuntimeError): + be.open() + + assert be._harvester is None + assert be._shared_entry is None + assert be._acquirer is None + + +def test_trigger_alias_on_maps_to_external(patch_gentl_sdk, gentl_settings_factory): + gb = patch_gentl_sdk + + settings = _gentl_trigger_settings( + gentl_settings_factory, + { + "role": "on", + "source": "Line1", + }, + ) + be = gb.GenTLCameraBackend(settings) + + be.open() + nm = be._acquirer.remote_device.node_map + + assert nm.TriggerMode.value == "On" + assert nm.TriggerSource.value == "Line1" + assert settings.properties["gentl"]["trigger_actual"]["role"] == "external" + + be.close() + + +def test_trigger_timeout_is_capped_for_hardware_trigger_fetch_polling( + patch_gentl_sdk, + gentl_settings_factory, +): + gb = patch_gentl_sdk + expected_fetch_timeout = gb.GenTLCameraBackend._MAX_HARDWARE_TRIGGER_FETCH_TIMEOUT + + settings = _gentl_trigger_settings( + gentl_settings_factory, + { + "role": "external", + "timeout": 7.5, + }, + ) + be = gb.GenTLCameraBackend(settings) + + try: + be.open() + + # Hardware-trigger fetch calls are intentionally capped so stop(wait=True) + # is not blocked by a long user trigger timeout. + assert be._timeout == pytest.approx(expected_fetch_timeout) + + # Fake acquisition is started, so read should pass and record the capped timeout. + frame = be.read().frame + assert frame is not None + assert be._acquirer.fetch_calls[-1] == pytest.approx(expected_fetch_timeout) + + # The requested trigger timeout is still preserved in persisted trigger_actual. + actual = settings.properties["gentl"]["trigger_actual"] + assert actual["timeout"] == pytest.approx(7.5) + + finally: + be.close() + + +def test_trigger_timeout_error_mentions_hardware_trigger_when_waiting( + patch_gentl_sdk, + gentl_settings_factory, +): + gb = patch_gentl_sdk + + settings = _gentl_trigger_settings( + gentl_settings_factory, + { + "role": "external", + "timeout": 3.0, + }, + ) + # fast_start keeps acquisition stopped; fake fetch then raises timeout. + # This lets us assert the backend timeout message without hardware. + settings.properties["gentl"]["fast_start"] = True + + be = gb.GenTLCameraBackend(settings) + + try: + be.open() + + assert be._timeout == pytest.approx(gb.GenTLCameraBackend._MAX_HARDWARE_TRIGGER_FETCH_TIMEOUT) + + with pytest.raises(TimeoutError) as ei: + be.read() + + msg = str(ei.value).lower() + assert "gentl timeout" in msg + assert "hardware trigger" in msg or "trigger" in msg + + finally: + be.close() + + +def test_trigger_actual_is_persisted_for_debugging(patch_gentl_sdk, gentl_settings_factory): + gb = patch_gentl_sdk + + settings = _gentl_trigger_settings( + gentl_settings_factory, + { + "role": "follower", + "source": "Line1", + "activation": "FallingEdge", + "timeout": 9.0, + "strict": False, + }, + ) + be = gb.GenTLCameraBackend(settings) + + try: + be.open() + + # Requested timeout remains in trigger_actual for debugging/config visibility. + actual = settings.properties["gentl"].get("trigger_actual") + assert isinstance(actual, dict) + assert actual["role"] == "follower" + assert actual["source"] == "Line1" + assert actual["activation"] == "FallingEdge" + assert actual["timeout"] == pytest.approx(9.0) + + # But each blocking Harvester.fetch() call is capped for responsive shutdown. + assert be._timeout == pytest.approx(gb.GenTLCameraBackend._MAX_HARDWARE_TRIGGER_FETCH_TIMEOUT) + + finally: + be.close() + + +def test_trigger_invalid_selector_non_strict_disables_trigger(patch_gentl_sdk, gentl_settings_factory): + gb = patch_gentl_sdk + + settings = _gentl_trigger_settings( + gentl_settings_factory, + { + "role": "external", + "selector": "NotARealSelector", + "source": "Line1", + "strict": False, + }, + ) + be = gb.GenTLCameraBackend(settings) + + be.open() + nm = be._acquirer.remote_device.node_map + + # Selector was unsupported, so the fake node should retain its default. + assert nm.TriggerSelector.value == "FrameStart" + + # Source may have been applied, but trigger must not be armed because + # the required selector routing failed. + assert nm.TriggerSource.value == "Line1" + assert nm.TriggerMode.value == "Off" + assert be.waits_for_hardware_trigger is False + + actual = settings.properties["gentl"]["trigger_actual"] + assert actual["role"] == "off" + + be.close() + + +def test_trigger_timeout_not_capped_for_master_mode(patch_gentl_sdk, gentl_settings_factory): + gb = patch_gentl_sdk + + settings = _gentl_trigger_settings( + gentl_settings_factory, + { + "role": "master", + "timeout": 7.5, + }, + ) + be = gb.GenTLCameraBackend(settings) + + try: + be.open() + + # Master is free-running / trigger-generating, not waiting for hardware input. + assert be.waits_for_hardware_trigger is False + assert be._timeout == pytest.approx(7.5) + + finally: + be.close() + + +def test_resolve_trigger_source_auto_selects_supported_line( + patch_gentl_sdk, + gentl_settings_factory, +): + gb = patch_gentl_sdk + be = gb.GenTLCameraBackend(gentl_settings_factory()) + + class Node: + symbolics = ["Line1", "Software", "Any"] + + class NodeMap: + TriggerSource = Node() + + source, ok = be._resolve_trigger_source(NodeMap(), "auto", strict=False) + + assert ok is True + assert source == "Line1" + + +def test_resolve_trigger_source_strict_raises_for_unsupported_explicit_line( + patch_gentl_sdk, + gentl_settings_factory, +): + gb = patch_gentl_sdk + be = gb.GenTLCameraBackend(gentl_settings_factory()) + + class Node: + symbolics = ["Line1", "Software", "Any"] + + class NodeMap: + TriggerSource = Node() + + with pytest.raises(RuntimeError, match="TriggerSource.*Line0"): + be._resolve_trigger_source(NodeMap(), "Line0", strict=True) diff --git a/tests/cameras/backends/test_opencv_backend.py b/tests/cameras/backends/test_opencv_backend.py index 2f1557824..e2468297b 100644 --- a/tests/cameras/backends/test_opencv_backend.py +++ b/tests/cameras/backends/test_opencv_backend.py @@ -99,7 +99,7 @@ def fake_videocapture(index, flag): assert any(idx == 0 for idx, _ in calls) assert not any(idx == 1 for idx, _ in calls) # since alt index probe is commented out - assert "camera" in backend.device_name().lower() + assert "test" in backend.device_name().lower() def test_open_raises_when_unable_to_open(monkeypatch, fake_capture_factory): @@ -124,7 +124,8 @@ def test_read_returns_none_on_grab_failure(fake_capture_factory): cap.grab_ok = False backend._capture = cap - frame, ts = backend.read() + payload = backend.read() + frame, ts = payload.frame, payload.software_timestamp assert frame is None assert isinstance(ts, float) @@ -135,7 +136,8 @@ def test_read_returns_none_on_retrieve_failure(fake_capture_factory): cap.retrieve_ok = False backend._capture = cap - frame, ts = backend.read() + payload = backend.read() + frame, ts = payload.frame, payload.software_timestamp assert frame is None assert isinstance(ts, float) @@ -150,7 +152,8 @@ def boom(): cap.grab = boom backend._capture = cap - frame, ts = backend.read() + payload = backend.read() + frame, ts = payload.frame, payload.software_timestamp assert frame is None assert isinstance(ts, float) diff --git a/tests/cameras/backends/utils/test_gentl_discovery.py b/tests/cameras/backends/utils/test_gentl_discovery.py new file mode 100644 index 000000000..46ff8ca5c --- /dev/null +++ b/tests/cameras/backends/utils/test_gentl_discovery.py @@ -0,0 +1,361 @@ +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +from dlclivegui.cameras.backends.utils import gentl_discovery as gd + +pytestmark = pytest.mark.unit + + +@pytest.fixture(autouse=True) +def clear_shared_harvester_pool(): + gd.SharedHarvesterPool._entries.clear() + yield + gd.SharedHarvesterPool._entries.clear() + + +def test_cti_files_as_list_handles_none_strings_and_sequences(): + assert gd.cti_files_as_list(None) == [] + assert gd.cti_files_as_list("") == [] + assert gd.cti_files_as_list(" ") == [] + assert gd.cti_files_as_list("camera.cti") == ["camera.cti"] + assert gd.cti_files_as_list(["a.cti", None, "", " ", Path("b.cti")]) == ["a.cti", "b.cti"] + + +def test_discover_explicit_cti_file_without_harvester(tmp_path: Path): + cti = tmp_path / "producer.cti" + cti.write_text("", encoding="utf-8") + + candidates, diag = gd.discover_cti_files( + cti_file=str(cti), + include_env=False, + ) + + assert candidates == [str(cti.resolve())] + assert diag.explicit_files == [str(cti)] + assert diag.candidates == [str(cti.resolve())] + assert diag.rejected == [] + + +def test_discover_rejects_missing_explicit_file(tmp_path: Path): + missing = tmp_path / "missing.cti" + + candidates, diag = gd.discover_cti_files( + cti_file=str(missing), + include_env=False, + ) + + assert candidates == [] + assert diag.rejected == [(str(missing.resolve()), "not a file (explicit)")] + + +def test_discover_rejects_non_cti_file(tmp_path: Path): + not_cti = tmp_path / "producer.txt" + not_cti.write_text("", encoding="utf-8") + + candidates, diag = gd.discover_cti_files( + cti_file=str(not_cti), + include_env=False, + ) + + assert candidates == [] + assert diag.rejected == [(str(not_cti.resolve()), "not a .cti (explicit)")] + + +def test_discover_accepts_missing_cti_when_must_exist_false(tmp_path: Path): + missing = tmp_path / "missing.cti" + + candidates, diag = gd.discover_cti_files( + cti_file=str(missing), + include_env=False, + must_exist=False, + ) + + assert candidates == [str(missing.resolve())] + assert diag.rejected == [] + + +def test_discover_extra_dir_collects_cti_files_non_recursive(tmp_path: Path): + root = tmp_path / "ctis" + root.mkdir() + + a = root / "a.cti" + b = root / "b.cti" + ignored = root / "ignored.txt" + nested = root / "nested" + nested.mkdir() + nested_cti = nested / "nested.cti" + + a.write_text("", encoding="utf-8") + b.write_text("", encoding="utf-8") + ignored.write_text("", encoding="utf-8") + nested_cti.write_text("", encoding="utf-8") + + candidates, diag = gd.discover_cti_files( + include_env=False, + extra_dirs=[str(root)], + recursive_extra_search=False, + ) + + assert candidates == [str(a.resolve()), str(b.resolve())] + assert diag.extra_dirs == [str(root)] + + +def test_discover_extra_dir_collects_cti_files_recursive(tmp_path: Path): + root = tmp_path / "ctis" + nested = root / "nested" + nested.mkdir(parents=True) + + top = root / "top.cti" + child = nested / "child.cti" + + top.write_text("", encoding="utf-8") + child.write_text("", encoding="utf-8") + + candidates, _diag = gd.discover_cti_files( + include_env=False, + extra_dirs=[str(root)], + recursive_extra_search=True, + ) + + assert candidates == [str(top.resolve()), str(child.resolve())] + + +def test_discover_deduplicates_candidates_preserving_order(tmp_path: Path): + cti = tmp_path / "producer.cti" + cti.write_text("", encoding="utf-8") + + candidates, diag = gd.discover_cti_files( + cti_file=str(cti), + cti_files=[str(cti)], + extra_dirs=[str(tmp_path)], + include_env=False, + ) + + assert candidates == [str(cti.resolve())] + assert diag.candidates == [str(cti.resolve())] + + +def test_discover_env_var_direct_file(monkeypatch, tmp_path: Path): + cti = tmp_path / "producer.cti" + cti.write_text("", encoding="utf-8") + + monkeypatch.setenv("MY_GENTL_PATH", str(cti)) + + candidates, diag = gd.discover_cti_files( + include_env=True, + env_vars=("MY_GENTL_PATH",), + ) + + assert candidates == [str(cti.resolve())] + assert diag.env_vars_used == {"MY_GENTL_PATH": str(cti)} + assert diag.env_paths_expanded == [str(cti)] + + +def test_discover_env_var_directory(monkeypatch, tmp_path: Path): + cti = tmp_path / "producer.cti" + cti.write_text("", encoding="utf-8") + + monkeypatch.setenv("MY_GENTL_PATH", str(tmp_path)) + + candidates, diag = gd.discover_cti_files( + include_env=True, + env_vars=("MY_GENTL_PATH",), + ) + + assert candidates == [str(cti.resolve())] + assert diag.env_vars_used == {"MY_GENTL_PATH": str(tmp_path)} + assert diag.env_paths_expanded == [str(tmp_path)] + + +def test_discover_env_var_multiple_entries(monkeypatch, tmp_path: Path): + d1 = tmp_path / "one" + d2 = tmp_path / "two" + d1.mkdir() + d2.mkdir() + + cti1 = d1 / "one.cti" + cti2 = d2 / "two.cti" + cti1.write_text("", encoding="utf-8") + cti2.write_text("", encoding="utf-8") + + monkeypatch.setenv("MY_GENTL_PATH", os.pathsep.join([str(d1), str(d2)])) + + candidates, _diag = gd.discover_cti_files( + include_env=True, + env_vars=("MY_GENTL_PATH",), + ) + + assert candidates == [str(cti1.resolve()), str(cti2.resolve())] + + +def test_validate_glob_pattern_rejects_empty_pattern(): + ok, reason = gd._validate_glob_pattern("") + + assert ok is False + assert reason == "empty glob pattern" + + +def test_validate_glob_pattern_rejects_traversal(tmp_path: Path): + pattern = str(tmp_path / ".." / "*.cti") + + ok, reason = gd._validate_glob_pattern(pattern) + + assert ok is False + assert reason == "glob pattern contains '..' traversal" + + +def test_validate_glob_pattern_rejects_non_cti_pattern(tmp_path: Path): + pattern = str(tmp_path / "*.txt") + + ok, reason = gd._validate_glob_pattern(pattern) + + assert ok is False + assert reason == "glob pattern does not target .cti files" + + +def test_validate_glob_pattern_rejects_outside_allowed_roots(tmp_path: Path): + allowed = tmp_path / "allowed" + outside = tmp_path / "outside" + allowed.mkdir() + outside.mkdir() + + pattern = str(outside / "*.cti") + + ok, reason = gd._validate_glob_pattern( + pattern, + allowed_roots=[str(allowed)], + ) + + assert ok is False + assert reason == "glob pattern base is outside allowed roots" + + +def test_discover_glob_pattern_with_allowed_root(tmp_path: Path): + cti_dir = tmp_path / "ctis" + cti_dir.mkdir() + + cti = cti_dir / "producer.cti" + cti.write_text("", encoding="utf-8") + + candidates, diag = gd.discover_cti_files( + cti_search_paths=[str(cti_dir / "*.cti")], + include_env=False, + root_globs_allowed=[str(tmp_path)], + ) + + assert candidates == [str(cti.resolve())] + assert diag.rejected == [] + + +def test_choose_cti_files_first_policy(): + assert gd.choose_cti_files( + ["a.cti", "b.cti", "c.cti"], + policy=gd.GenTLDiscoveryPolicy.FIRST, + max_files=2, + ) == ["a.cti", "b.cti"] + + +def test_choose_cti_files_raise_if_multiple_policy_raises(): + with pytest.raises(RuntimeError, match="Multiple GenTL producers"): + gd.choose_cti_files( + ["a.cti", "b.cti"], + policy=gd.GenTLDiscoveryPolicy.RAISE_IF_MULTIPLE, + max_files=1, + ) + + +def test_choose_cti_files_raise_if_multiple_policy_allows_within_limit(): + assert gd.choose_cti_files( + ["a.cti"], + policy=gd.GenTLDiscoveryPolicy.RAISE_IF_MULTIPLE, + max_files=1, + ) == ["a.cti"] + + +def test_choose_cti_files_newest_policy(tmp_path: Path): + old = tmp_path / "old.cti" + new = tmp_path / "new.cti" + + old.write_text("", encoding="utf-8") + new.write_text("", encoding="utf-8") + + os.utime(old, (1000, 1000)) + os.utime(new, (2000, 2000)) + + assert gd.choose_cti_files( + [str(old), str(new)], + policy=gd.GenTLDiscoveryPolicy.NEWEST, + max_files=1, + ) == [str(new)] + + +def test_choose_cti_files_empty_candidates(): + assert gd.choose_cti_files([]) == [] + + +def test_choose_cti_files_unknown_policy_raises(): + with pytest.raises(ValueError, match="Unknown policy"): + gd.choose_cti_files( + ["a.cti"], + policy=object(), # type: ignore[arg-type] + ) + + +def test_shared_harvester_pool_reuses_entry_and_refcounts(monkeypatch, tmp_path: Path): + calls: list[tuple[str, str | None]] = [] + + class FakeHarvester: + def __init__(self): + calls.append(("init", None)) + + def add_file(self, path: str) -> None: + calls.append(("add_file", path)) + + def update(self) -> None: + calls.append(("update", None)) + + def reset(self) -> None: + calls.append(("reset", None)) + + cti = tmp_path / "producer.cti" + cti.write_text("", encoding="utf-8") + + monkeypatch.setattr(gd, "Harvester", FakeHarvester) + gd.SharedHarvesterPool._entries.clear() + + entry1 = gd.SharedHarvesterPool.acquire([str(cti)]) + entry2 = gd.SharedHarvesterPool.acquire([str(cti)]) + + assert entry1 is entry2 + assert gd.SharedHarvesterPool.get_refcount(entry1) == 2 + + gd.SharedHarvesterPool.release(entry1) + assert gd.SharedHarvesterPool.get_refcount(entry2) == 1 + + gd.SharedHarvesterPool.release(entry2) + assert gd.SharedHarvesterPool.get_refcount(entry2) == 0 + assert ("reset", None) in calls + + +def test_shared_harvester_entry_reports_failed_files(monkeypatch, tmp_path: Path): + class FakeHarvester: + def add_file(self, path: str) -> None: + raise RuntimeError("load failed") + + def update(self) -> None: + raise AssertionError("update should not be called") + + def reset(self) -> None: + pass + + cti = tmp_path / "bad.cti" + cti.write_text("", encoding="utf-8") + + monkeypatch.setattr(gd, "Harvester", FakeHarvester) + + with pytest.raises(RuntimeError, match="No GenTL producer"): + gd.SharedHarvesterEntry([str(cti)]) diff --git a/tests/cameras/test_backend_discovery.py b/tests/cameras/test_backend_discovery.py index 610a90c62..0b86e9520 100644 --- a/tests/cameras/test_backend_discovery.py +++ b/tests/cameras/test_backend_discovery.py @@ -26,7 +26,7 @@ def _write_temp_backend_package(tmp_path: Path, pkg_name: str = "test_backends_p # A backend module which registers itself as "lazyfake" backend_code = textwrap.dedent( """ - from dlclivegui.cameras.base import register_backend, CameraBackend + from dlclivegui.cameras.base import register_backend, CameraBackend, CapturedFrame from dlclivegui.config import CameraSettings import numpy as np import time @@ -44,7 +44,7 @@ def open(self) -> None: def read(self): # Small deterministic frame + timestamp frame = np.zeros((2, 3, 3), dtype=np.uint8) - return frame, time.time() + return CapturedFrame(frame, time.time(), None) def close(self) -> None: self._opened = False diff --git a/tests/cameras/test_factory.py b/tests/cameras/test_factory.py index cc1d798de..43516b487 100644 --- a/tests/cameras/test_factory.py +++ b/tests/cameras/test_factory.py @@ -3,6 +3,7 @@ import pytest from dlclivegui.cameras import CameraFactory, DetectedCamera, base +from dlclivegui.cameras.base import CapturedFrame from dlclivegui.config import CameraSettings @@ -69,7 +70,7 @@ def open(self): raise AssertionError("Probing path should not open when rich discovery returns a list") def read(self): - return None, 0.0 + return CapturedFrame(None, 0.0, None) def close(self): pass @@ -112,7 +113,7 @@ def open(self): pass def read(self): - return None, 0.0 + return CapturedFrame(None, 0.0, None) def close(self): pass @@ -150,7 +151,7 @@ def open(self): raise RuntimeError("no device") def read(self): - return None, 0.0 + return CapturedFrame(None, 0.0, None) def close(self): pass @@ -182,7 +183,7 @@ def open(self): raise RuntimeError("no device") def read(self): - return None, 0.0 + return CapturedFrame(None, 0.0, None) def close(self): pass @@ -220,7 +221,7 @@ def open(self): pass def read(self): - return None, 0.0 + return CapturedFrame(None, 0.0, None) def close(self): pass @@ -252,7 +253,7 @@ def open(self): pass def read(self): - return None, 0.0 + return CapturedFrame(None, 0.0, None) def close(self): pass @@ -280,7 +281,7 @@ def open(self): pass def read(self): - return None, 0.0 + return CapturedFrame(None, 0.0, None) def close(self): pass @@ -311,7 +312,7 @@ def open(self): pass def read(self): - return None, 0.0 + return CapturedFrame(None, 0.0, None) def close(self): pass @@ -341,7 +342,7 @@ def open(self): raise RuntimeError("no device") def read(self): - return None, 0.0 + return CapturedFrame(None, 0.0, None) def close(self): pass diff --git a/tests/cameras/test_fake_backend.py b/tests/cameras/test_fake_backend.py index d85616bcc..eac6e6015 100644 --- a/tests/cameras/test_fake_backend.py +++ b/tests/cameras/test_fake_backend.py @@ -26,7 +26,7 @@ def open(self): def read(self): assert self._opened img = np.zeros((10, 20, 3), dtype=np.uint8) - return img, 123.456 + return base.CapturedFrame(img, 123.456, None) def close(self): self._opened = False diff --git a/tests/conftest.py b/tests/conftest.py index 7d12a70a1..3aeb23d82 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -13,6 +13,7 @@ from dlclivegui.cameras import CameraFactory from dlclivegui.cameras.base import ( CameraBackend, + CapturedFrame, SupportLevel, register_backend_direct, unregister_backend, @@ -86,7 +87,7 @@ def read(self): raise RuntimeError("not opened") self._counter += 1 frame = np.zeros(frame_shape, dtype=np.uint8) - return frame, float(timestamp_fn()) + return CapturedFrame(frame=frame, software_timestamp=float(timestamp_fn()), timestamp_metadata=None) _TestBackend.__name__ = f"TestBackend_{name}" return _TestBackend @@ -153,6 +154,18 @@ def _factory(settings: CameraSettings): # --------------------------------------------------------------------- # Test doubles # --------------------------------------------------------------------- +class FakeRunner: + """Minimal fake DLCLive runner used by DLCLiveProcessor._process_frame.""" + + def __init__(self, parent): + self._parent = parent + self.device = "cpu" + self.model = None + self.net = None + + def get_pose(self, processed_frame): + self._parent.pose_calls += 1 + return np.ones((2, 3), dtype=float) class FakeDLCLive: @@ -162,14 +175,31 @@ def __init__(self, **opts): self.opts = opts self.init_called = False self.pose_calls = 0 + self.process_frame_calls = 0 + + self.processor = opts.get("processor") + self.cfg = {"fake": True} + self.runner = FakeRunner(self) + self.pose = None def init_inference(self, frame): self.init_called = True + def process_frame(self, frame): + self.process_frame_calls += 1 + return frame + def get_pose(self, frame, frame_time=None): + # Keep this for compatibility with older tests, but production code now + # uses self.runner.get_pose(...). self.pose_calls += 1 return np.ones((2, 3), dtype=float) + def _post_process_pose(self, processed_frame, frame_time=None): + if self.pose is None: + self.pose = self.runner.get_pose(processed_frame) + return self.pose + @pytest.fixture def fake_dlclive_factory(): @@ -239,6 +269,12 @@ def app_config_two_cams(tmp_path) -> ApplicationSettings: return make_app_config(tmp_path=tmp_path, num_cams=2, backend="fake", enabled=True, fps=30.0) +@pytest.fixture +def camera_worker_settings(app_config_two_cams) -> CameraSettings: + """Single enabled fake camera settings for SingleCameraWorker tests.""" + return app_config_two_cams.multi_camera.cameras[0].model_copy(deep=True) + + # --------------------------------------------------------------------- # Main window fixture # --------------------------------------------------------------------- @@ -324,7 +360,7 @@ def _fake_start_all(self, recording, active_cams, current_frames, **kwargs): run_dir.mkdir(parents=True, exist_ok=True) return run_dir - from dlclivegui.gui import recording_manager as rm_mod + from dlclivegui.services import recording_manager as rm_mod monkeypatch.setattr(rm_mod.RecordingManager, "start_all", _fake_start_all) return calls @@ -349,12 +385,28 @@ def fake_processor(): class FakeVideoRecorder: """Lightweight test double for VideoRecorder (no threads/ffmpeg).""" - def __init__(self, output, frame_size=None, frame_rate=None, codec="libx264", crf=23, **kwargs): + def __init__( + self, + output, + frame_size=None, + frame_rate=None, + codec="libx264", + crf=23, + buffer_size=240, + convert_grayscale_to_rgb=True, + writer_options=None, + **kwargs, + ): self.output = Path(output) self.frame_size = frame_size self.frame_rate = frame_rate self.codec = codec self.crf = crf + self.buffer_size = buffer_size + self.convert_grayscale_to_rgb = convert_grayscale_to_rgb + self.writer_options = dict(writer_options) if writer_options is not None else None + self.extra_kwargs = dict(kwargs) + self.started = False self.stopped = False self.write_calls = [] @@ -370,14 +422,15 @@ def start(self): if self.raise_on_start: raise RuntimeError("start failed") self.started = True + self.stopped = False def stop(self): self.stopped = True - def write(self, frame, timestamp=None): + def write(self, frame, timestamp=None, timestamp_metadata=None): if self.raise_on_write: raise RuntimeError("write failed") - self.write_calls.append((frame, timestamp)) + self.write_calls.append((frame, timestamp, timestamp_metadata)) return True def get_stats(self): @@ -391,7 +444,7 @@ def recording_settings(app_config_two_cams): @pytest.fixture def patch_video_recorder(monkeypatch): - import dlclivegui.gui.recording_manager as rm_mod + import dlclivegui.services.recording_manager as rm_mod monkeypatch.setattr(rm_mod, "VideoRecorder", FakeVideoRecorder) return FakeVideoRecorder @@ -401,7 +454,7 @@ def patch_video_recorder(monkeypatch): def recording_frame_spy(monkeypatch, window): captured = {} - def _fake_write_frame(cam_id, frame, timestamp=None): + def _fake_write_frame(cam_id, frame, timestamp=None, timestamp_metadata=None): captured[cam_id] = frame.copy() monkeypatch.setattr(window._rec_manager, "write_frame", _fake_write_frame) @@ -410,7 +463,7 @@ def _fake_write_frame(cam_id, frame, timestamp=None): @pytest.fixture def patch_build_run_dir(monkeypatch, tmp_path): - import dlclivegui.gui.recording_manager as rm_mod + import dlclivegui.services.recording_manager as rm_mod spy = {"session_dir": None, "use_timestamp": None} run_dir = tmp_path / "videos" / "Sess_SANITIZED" / "run_TEST" diff --git a/tests/custom_processors/test_base_processor.py b/tests/custom_processors/test_base_processor.py index d38749b34..a422c8fdc 100644 --- a/tests/custom_processors/test_base_processor.py +++ b/tests/custom_processors/test_base_processor.py @@ -1,11 +1,10 @@ -# tests/processors/test_dlc_processor_socket.py +# tests/custom_processors/test_base_processor.py from __future__ import annotations import importlib import pickle import sys import types -from pathlib import Path import numpy as np import pandas as pd @@ -13,15 +12,21 @@ def _mock_dlclive(monkeypatch): - """Provide a dummy dlclive.Processor so the module can import in tests.""" - fake = types.ModuleType("dlclive") - class Processor: def __init__(self, *args, **kwargs): pass - fake.Processor = Processor - monkeypatch.setitem(sys.modules, "dlclive", fake) + def process(self, pose, **kwargs): + return pose + + dlclive_mod = types.ModuleType("dlclive") + processor_mod = types.ModuleType("dlclive.processor") + + dlclive_mod.Processor = Processor + processor_mod.Processor = Processor + + monkeypatch.setitem(sys.modules, "dlclive", dlclive_mod) + monkeypatch.setitem(sys.modules, "dlclive.processor", processor_mod) @pytest.fixture @@ -37,9 +42,17 @@ def socket_mod(monkeypatch): return importlib.import_module(mod_name) -def _module_data_dir(socket_mod) -> Path: - """Compute the data/ directory where save() writes artifacts.""" - return Path(socket_mod.__file__).parent.parent.parent / "data" +@pytest.fixture +def example_processor_mod(monkeypatch): + """ + Import the example processor module with dlclive mocked. + Adjust module name if your file lives elsewhere. + """ + _mock_dlclive(monkeypatch) + mod_name = "dlclivegui.processors.examples" + if mod_name in sys.modules: + del sys.modules[mod_name] + return importlib.import_module(mod_name) def _mk_bodyparts(n: int) -> list[str]: @@ -52,7 +65,6 @@ def _mk_pose(n_keypoints: int = 5) -> np.ndarray: Base class does not interpret pose content—only broadcasts/logs it. """ pose = np.zeros((n_keypoints, 3), dtype=float) - # Fill with simple coordinates & confidence for i in range(n_keypoints): pose[i, :] = [10.0 + i, 20.0 + i, 0.9] return pose @@ -64,25 +76,25 @@ def test_base_init_and_stop(socket_mod): and ensure stop() is idempotent. """ BaseProcessorSocket = socket_mod.BaseProcessorSocket - proc = BaseProcessorSocket(bind=("127.0.0.1", 0), use_perf_counter=True, save_original=False) + proc = BaseProcessorSocket( + bind=("127.0.0.1", 0), + use_perf_counter=True, + save_original=False, + ) try: - # Core attributes exist assert hasattr(proc, "listener") assert callable(proc.timing_func) - # perf_counter chosen + import time as _t assert proc.timing_func is _t.perf_counter - # Initial flags & counters assert proc.recording is False assert proc.video_recording is False assert proc.curr_step == 0 assert isinstance(proc.conns, set) finally: - # stop must be safe and idempotent proc.stop() - proc.stop() # second call should be a no-op def test_base_recording_flags_and_session_name(socket_mod): @@ -92,23 +104,19 @@ def test_base_recording_flags_and_session_name(socket_mod): BaseProcessorSocket = socket_mod.BaseProcessorSocket proc = BaseProcessorSocket(bind=("127.0.0.1", 0)) try: - # Start recording proc._handle_client_message({"cmd": "start_recording"}) assert proc.recording is True assert proc.video_recording is True - assert proc.curr_step == 0 # reset + assert proc.curr_step == 0 - # Set a session name proc._handle_client_message({"cmd": "set_session_name", "session_name": "unit_test"}) assert proc.session_name == "unit_test" assert proc.filename == "unit_test_dlc_processor_data.pkl" - # Stop recording proc._handle_client_message({"cmd": "stop_recording"}) assert proc.recording is False assert proc.video_recording is False - # Unknown / invalid messages must not crash proc._handle_client_message(None) proc._handle_client_message({"cmd": "does_not_exist"}) finally: @@ -120,27 +128,27 @@ def test_base_process_without_and_with_recording(socket_mod): BaseProcessorSocket.process() should: - increment curr_step always, - when recording, append time/step/frame_time/pose_time, - - when save_original=True, store copies of pose arrays only while recording. + - when save_original=True, store copies of pose arrays only while recording. """ BaseProcessorSocket = socket_mod.BaseProcessorSocket proc = BaseProcessorSocket(bind=("127.0.0.1", 0), save_original=True) + try: pose = _mk_pose() - # Not recording yet: curr_step increments, no logs appended before_step = proc.curr_step ret = proc.process(pose, frame_time=0.012, pose_time=0.013) + assert ret is pose assert proc.curr_step == before_step + 1 assert len(proc.time_stamp) == 0 assert len(proc.step) == 0 assert len(proc.frame_time) == 0 assert len(proc.pose_time) == 0 - # Raw poses must stay aligned with recorded metadata. + assert proc.original_pose is not None assert len(proc.original_pose) == 0 - # Start recording and push two frames proc._handle_client_message({"cmd": "start_recording"}) for _ in range(2): proc.process(pose, frame_time=0.01, pose_time=0.011) @@ -150,25 +158,23 @@ def test_base_process_without_and_with_recording(socket_mod): assert len(proc.frame_time) == 2 assert len(proc.pose_time) == 2 assert len(proc.original_pose) == 2 + np.testing.assert_allclose(proc.original_pose[0], pose) np.testing.assert_allclose(proc.original_pose[1], pose) - # Data snapshot integrity data = proc.get_data() assert "start_time" in data assert isinstance(data["time_stamp"], np.ndarray) assert isinstance(data["step"], np.ndarray) assert isinstance(data["frame_time"], np.ndarray) - # pose_time can be None if never provided; here it is provided. assert isinstance(data["pose_time"], np.ndarray) - # original_pose is included when save_original=True assert isinstance(data["original_pose"], np.ndarray) finally: proc.stop() -def test_save_ignores_pre_recording_original_pose_frames(socket_mod): +def test_save_ignores_pre_recording_original_pose_frames(socket_mod, tmp_path): """ save_original data must stay aligned with recorded metadata even if process() is called before recording starts. @@ -194,18 +200,16 @@ def test_save_ignores_pre_recording_original_pose_frames(socket_mod): proc.process(pose, frame_time=0.01, pose_time=0.02) proc._handle_client_message({"cmd": "stop_recording"}) - filename = "unit_test_pre_recording_frames.pkl" - ret = proc.save(filename) - assert ret == 1 + pkl_path = tmp_path / "unit_test_pre_recording_frames.pkl" + h5_path = tmp_path / "unit_test_pre_recording_frames_DLC.hdf5" - data_dir = _module_data_dir(socket_mod) - pkl_path = data_dir / filename - h5_path = data_dir / (Path(filename).stem + "_DLC.hdf5") + ret = proc.save(pkl_path) + assert ret == 1 assert pkl_path.exists() assert h5_path.exists() - with open(pkl_path, "rb") as f: + with pkl_path.open("rb") as f: payload = pickle.load(f) assert len(payload["frame_time"]) == 2 @@ -219,11 +223,6 @@ def test_save_ignores_pre_recording_original_pose_frames(socket_mod): finally: proc.stop() - try: - pkl_path.unlink(missing_ok=True) - h5_path.unlink(missing_ok=True) - except Exception: - pass @pytest.mark.parametrize( @@ -233,12 +232,18 @@ def test_save_ignores_pre_recording_original_pose_frames(socket_mod): ("ExampleProcessorSocketFilterKeypoints", 10), ], ) -def test_subclass_save_ignores_pre_recording_original_pose_frames(socket_mod, class_name, n_keypoints): +def test_subclass_save_ignores_pre_recording_original_pose_frames( + socket_mod, + example_processor_mod, + class_name, + n_keypoints, + tmp_path, +): """ Concrete processors must keep original_pose aligned with recorded metadata even when process() is called before recording starts. """ - processor_class = getattr(socket_mod, class_name) + processor_class = getattr(example_processor_mod, class_name) proc = processor_class(bind=("127.0.0.1", 0), save_original=True) try: @@ -258,18 +263,16 @@ def test_subclass_save_ignores_pre_recording_original_pose_frames(socket_mod, cl proc.process(pose, frame_time=0.01, pose_time=0.02) proc._handle_client_message({"cmd": "stop_recording"}) - filename = f"unit_test_{class_name}.pkl" - ret = proc.save(filename) - assert ret == 1 + pkl_path = tmp_path / f"unit_test_{class_name}.pkl" + h5_path = tmp_path / f"unit_test_{class_name}_DLC.hdf5" - data_dir = _module_data_dir(socket_mod) - pkl_path = data_dir / filename - h5_path = data_dir / (Path(filename).stem + "_DLC.hdf5") + ret = proc.save(pkl_path) + assert ret == 1 assert pkl_path.exists() assert h5_path.exists() - with open(pkl_path, "rb") as f: + with pkl_path.open("rb") as f: payload = pickle.load(f) assert len(payload["frame_time"]) == 3 @@ -283,11 +286,6 @@ def test_subclass_save_ignores_pre_recording_original_pose_frames(socket_mod, cl finally: proc.stop() - try: - pkl_path.unlink(missing_ok=True) - h5_path.unlink(missing_ok=True) - except Exception: - pass def test_base_broadcast_handles_bad_connections(socket_mod): @@ -298,7 +296,6 @@ def test_base_broadcast_handles_bad_connections(socket_mod): class BadConn: def __init__(self): - # Minimal attributes to satisfy _close_conn class Sock: def shutdown(self, *_args, **_kwargs): raise RuntimeError("shutdown fail") @@ -312,7 +309,6 @@ def close(self): raise RuntimeError("close fail") def __hash__(self): - # allow put in a set return id(self) def __eq__(self, other): @@ -320,18 +316,20 @@ def __eq__(self, other): BaseProcessorSocket = socket_mod.BaseProcessorSocket proc = BaseProcessorSocket(bind=("127.0.0.1", 0)) + try: bad = BadConn() proc.conns.add(bad) - # Should not raise + proc.broadcast(["ts", "payload"]) - # bad conn should be discarded + assert bad not in proc.conns + finally: proc.stop() -def test_save_writes_pkl_and_hdf5_with_labels(socket_mod, caplog): +def test_save_writes_pkl_and_hdf5_with_labels(socket_mod, tmp_path, caplog): """ End-to-end save() with save_original=True and a matching dlc_cfg bodypart list. Verifies: @@ -348,56 +346,47 @@ def test_save_writes_pkl_and_hdf5_with_labels(socket_mod, caplog): dlc_cfg = {"metadata": {"bodyparts": bodyparts}} proc.set_dlc_cfg(dlc_cfg) - # create 3 frames pose = _mk_pose(n_keypoints=n_keypoints) + proc._handle_client_message({"cmd": "start_recording"}) for _ in range(3): proc.process(pose, frame_time=0.01, pose_time=0.011) proc._handle_client_message({"cmd": "stop_recording"}) - # deterministic relative filename - filename = "unit_test_session.pkl" - ret = proc.save(filename) - assert ret == 1 + pkl_path = tmp_path / "unit_test_session.pkl" + h5_path = tmp_path / "unit_test_session_DLC.hdf5" - data_dir = _module_data_dir(socket_mod) - pkl_path = data_dir / filename - h5_path = data_dir / (Path(filename).stem + "_DLC.hdf5") + ret = proc.save(pkl_path) + assert ret == 1 assert pkl_path.exists(), f"Missing {pkl_path}" assert h5_path.exists(), f"Missing {h5_path}" - # verify pkl payload - with open(pkl_path, "rb") as f: + with pkl_path.open("rb") as f: payload = pickle.load(f) - assert "original_pose" not in payload # popped out before pickling + assert "original_pose" not in payload assert "dlc_cfg" in payload assert payload["dlc_cfg"] == dlc_cfg - # verify HDF5 contents (skip if tables is not installed) pytest.importorskip("tables") df = pd.read_hdf(h5_path, key="df_with_missing") - # Expect rows == frames + assert df.shape[0] == 3 - # Confirm the labeled columns exist for all bodyparts x (x, y, likelihood) expected_cols = pd.MultiIndex.from_product( [bodyparts, ["x", "y", "likelihood"]], names=["bodyparts", "coords"], ) - # Some pandas versions will allow mixing multiindex + string cols; - # so just check presence of expected label tuples: + for col in expected_cols: assert col in df.columns - # frame_time & pose_time columns are present assert "frame_time" in df.columns assert "pose_time" in df.columns assert list(df["frame_time"]) == [0.01, 0.01, 0.01] assert list(df["pose_time"]) == list(payload["time_stamp"]) - # sanity check values for first row for i, bp in enumerate(bodyparts): assert np.isclose(df[(bp, "x")].iloc[0], 10.0 + i) assert np.isclose(df[(bp, "y")].iloc[0], 20.0 + i) @@ -405,61 +394,45 @@ def test_save_writes_pkl_and_hdf5_with_labels(socket_mod, caplog): finally: proc.stop() - # cleanup - try: - pkl_path.unlink(missing_ok=True) - h5_path.unlink(missing_ok=True) - except Exception: - pass -def test_save_without_dlc_cfg_unlabeled_columns(socket_mod, caplog): +def test_save_without_dlc_cfg_unlabeled_columns(socket_mod, tmp_path, caplog): """ Ensure that without dlc_cfg, save() still writes HDF5 with unlabeled columns - and logs a warning (no crash). + and logs a warning or at least succeeds without crashing. """ BaseProcessorSocket = socket_mod.BaseProcessorSocket proc = BaseProcessorSocket(bind=("127.0.0.1", 0), save_original=True) try: pose = _mk_pose(3) + proc._handle_client_message({"cmd": "start_recording"}) proc.process(pose, frame_time=0.01, pose_time=0.02) proc._handle_client_message({"cmd": "stop_recording"}) - filename = "unit_test_no_dlc_cfg.pkl" - ret = proc.save(filename) - assert ret == 1 + pkl_path = tmp_path / "unit_test_no_dlc_cfg.pkl" + h5_path = tmp_path / "unit_test_no_dlc_cfg_DLC.hdf5" - data_dir = _module_data_dir(socket_mod) - pkl_path = data_dir / filename - h5_path = data_dir / (Path(filename).stem + "_DLC.hdf5") + ret = proc.save(pkl_path) + assert ret == 1 assert pkl_path.exists() assert h5_path.exists() - # Check warning logged - # (Depending on logger config in tests, you may need to set level to capture warnings) + # Depending on logger config in tests, caplog may or may not catch this. [rec for rec in caplog.records if "saving without column labels" in rec.message] - # It's okay if caplog didn't catch it due to logger level; we mainly ensure no crash and files exist. - # Verify HDF5 loads (skip if tables not installed) pytest.importorskip("tables") df = pd.read_hdf(h5_path, key="df_with_missing") - assert df.shape[0] == 1 # 1 frame saved - # Expect unlabeled numeric columns for pose plus "frame_time" and "pose_time" - # We can't rely on a MultiIndex here; just ensure numeric columns exist + + assert df.shape[0] == 1 + numeric_cols = [c for c in df.columns if c not in ("frame_time", "pose_time")] - assert len(numeric_cols) == 3 * 3 # 3 keypoints * 3 coords + assert len(numeric_cols) == 3 * 3 finally: proc.stop() - # cleanup - try: - pkl_path.unlink(missing_ok=True) - h5_path.unlink(missing_ok=True) - except Exception: - pass def test_get_data_includes_dlc_cfg(socket_mod): @@ -468,38 +441,34 @@ def test_get_data_includes_dlc_cfg(socket_mod): """ BaseProcessorSocket = socket_mod.BaseProcessorSocket proc = BaseProcessorSocket(bind=("127.0.0.1", 0), save_original=False) + try: dlc_cfg = {"metadata": {"bodyparts": ["a", "b"]}} proc.set_dlc_cfg(dlc_cfg) + data = proc.get_data() + assert "dlc_cfg" in data assert data["dlc_cfg"] == dlc_cfg + finally: proc.stop() -def test_save_handles_empty_original_pose(socket_mod): +def test_save_handles_empty_original_pose(socket_mod, tmp_path): """ - With save_original=True but no process() calls, save() should not crash. - Depending on pandas behavior, HDF5 should exist with 0 rows or be created successfully. + With save_original=True but no process() calls, save() should not raise. + The exact return value is implementation-dependent for empty data. """ BaseProcessorSocket = socket_mod.BaseProcessorSocket proc = BaseProcessorSocket(bind=("127.0.0.1", 0), save_original=True) + try: - filename = "unit_test_empty_original.pkl" - ret = proc.save(filename) - # If nothing to save, your implementation returns 1 (saved) or could be 0; current code returns 1 - assert ret in (1, 0, -1) # accept current behavior; adjust if you standardize - data_dir = _module_data_dir(socket_mod) - pkl_path = data_dir / filename - h5_path = data_dir / (Path(filename).stem + "_DLC.hdf5") - # pkl exists if ret == 1; hdf5 may or may not depending on your final logic - # Leave assertions lenient; the main check is that no exception bubbles up. + pkl_path = tmp_path / "unit_test_empty_original.pkl" + + ret = proc.save(pkl_path) + + assert ret in (1, 0, -1) + finally: proc.stop() - # cleanup - try: - pkl_path.unlink(missing_ok=True) - h5_path.unlink(missing_ok=True) - except Exception: - pass diff --git a/tests/custom_processors/test_processor_rec_context.py b/tests/custom_processors/test_processor_rec_context.py new file mode 100644 index 000000000..16a1461d9 --- /dev/null +++ b/tests/custom_processors/test_processor_rec_context.py @@ -0,0 +1,292 @@ +# tests/processors/test_processor_recording_context.py +from __future__ import annotations + +import importlib +import pickle +import sys +import types +from types import SimpleNamespace + +import pytest + +# ----------------------------------------------------------------------------- +# Shared fixtures +# ----------------------------------------------------------------------------- + + +def _mock_dlclive(monkeypatch): + """Install a tiny dlclive.processor.Processor mock before importing processors.""" + + class Processor: + def __init__(self, *args, **kwargs): + pass + + def process(self, pose, **kwargs): + return pose + + dlclive_mod = types.ModuleType("dlclive") + processor_mod = types.ModuleType("dlclive.processor") + + dlclive_mod.Processor = Processor + processor_mod.Processor = Processor + + monkeypatch.setitem(sys.modules, "dlclive", dlclive_mod) + monkeypatch.setitem(sys.modules, "dlclive.processor", processor_mod) + + +@pytest.fixture +def socket_mod(monkeypatch): + """Import dlclivegui.processors.dlc_processor_socket with dlclive mocked.""" + _mock_dlclive(monkeypatch) + mod_name = "dlclivegui.processors.dlc_processor_socket" + sys.modules.pop(mod_name, None) + return importlib.import_module(mod_name) + + +class DummyLineEdit: + def __init__(self, value: str): + self._value = value + + def text(self) -> str: + return self._value + + +class HookProcessor: + def __init__(self): + self.started_contexts = [] + self.stopped_contexts = [] + self.save_calls = 0 + + def on_recording_started(self, context): + self.started_contexts.append(context) + + def on_recording_stopped(self, context): + self.stopped_contexts.append(context) + + def save(self): + self.save_calls += 1 + return 1 + + +class SaveOnlyProcessor: + def __init__(self): + self.save_calls = 0 + + def save(self): + self.save_calls += 1 + return 1 + + +# ----------------------------------------------------------------------------- +# BaseProcessorSocket generic recording-context API +# ----------------------------------------------------------------------------- + + +def test_base_processor_recording_context_sets_save_path(socket_mod, tmp_path): + BaseProcessorSocket = socket_mod.BaseProcessorSocket + proc = BaseProcessorSocket(bind=("127.0.0.1", 0)) + + try: + base_path = tmp_path / "MouseA_2026-07-10_1" + context = { + "run_dir": tmp_path, + "session_name": "MouseA", + "filename": "MouseA_2026-07-10_1.avi", + "filename_stem": "MouseA_2026-07-10_1", + "processor_base_path": base_path, + } + + proc.set_recording_context(context) + + assert proc.recording_context == context + assert proc.get_save_path() == base_path + assert proc.save_path == base_path + + finally: + proc.stop() + + +def test_base_processor_recording_hooks_update_context(socket_mod, tmp_path): + BaseProcessorSocket = socket_mod.BaseProcessorSocket + proc = BaseProcessorSocket(bind=("127.0.0.1", 0)) + + try: + started_context = {"processor_base_path": tmp_path / "started"} + stopped_context = {"processor_base_path": tmp_path / "stopped"} + + proc.on_recording_started(started_context) + assert proc.recording_context == started_context + assert proc.get_save_path() == tmp_path / "started" + + proc.on_recording_stopped(stopped_context) + assert proc.recording_context == stopped_context + assert proc.get_save_path() == tmp_path / "stopped" + + finally: + proc.stop() + + +def test_base_processor_save_uses_save_path_when_file_is_none(socket_mod, tmp_path): + BaseProcessorSocket = socket_mod.BaseProcessorSocket + proc = BaseProcessorSocket(bind=("127.0.0.1", 0), save_original=False) + + try: + save_path = tmp_path / "legacy" / "MouseA_2026-07-10_1_PROC" + proc.set_save_path(save_path) + + proc.start_recording() + proc.process([[1.0, 2.0, 0.9]], frame_time=12.34, pose_time=12.35) + proc.stop_recording() + + ret = proc.save() + assert ret == 1 + assert save_path.exists() + + with save_path.open("rb") as f: + payload = pickle.load(f) + + assert "time_stamp" in payload + assert "step" in payload + assert "frame_time" in payload + assert len(payload["frame_time"]) == 1 + + finally: + proc.stop() + + +def test_base_processor_save_writes_to_explicit_absolute_file(socket_mod, tmp_path): + BaseProcessorSocket = socket_mod.BaseProcessorSocket + proc = BaseProcessorSocket(bind=("127.0.0.1", 0), save_original=False) + + try: + explicit_path = tmp_path / "explicit" / "processor_data.pkl" + ret = proc.save(explicit_path) + + assert ret == 1 + assert explicit_path.exists() + + with explicit_path.open("rb") as f: + payload = pickle.load(f) + + assert "start_time" in payload + + finally: + proc.stop() + + +def test_base_processor_save_without_file_or_save_path_returns_zero(socket_mod): + BaseProcessorSocket = socket_mod.BaseProcessorSocket + proc = BaseProcessorSocket(bind=("127.0.0.1", 0), save_original=False) + + try: + proc.save_path = None + assert proc.save() == 0 + finally: + proc.stop() + + +def test_base_processor_stop_save_true_uses_save_path(socket_mod, tmp_path): + BaseProcessorSocket = socket_mod.BaseProcessorSocket + proc = BaseProcessorSocket(bind=("127.0.0.1", 0), save_original=False) + + save_path = tmp_path / "processor_on_stop.pkl" + proc.set_save_path(save_path) + + # stop(save=True) should save, close listener, and be idempotent. + proc.stop(save=True) + assert save_path.exists() + assert proc.listener is None + + proc.stop(save=True) # no crash + + +# ----------------------------------------------------------------------------- +# DLCLiveMainWindow recording-context helpers +# ----------------------------------------------------------------------------- + + +@pytest.fixture +def main_window_cls(): + pytest.importorskip("PySide6") + mod = importlib.import_module("dlclivegui.gui.main_window") + return mod.DLCLiveMainWindow + + +def make_window_shell(main_window_cls, processor=None, run_dir=None): + """Create a DLCLiveMainWindow shell without running QMainWindow.__init__.""" + win = main_window_cls.__new__(main_window_cls) + win._dlc = SimpleNamespace(_processor=processor, _dlc=None) + win._rec_manager = SimpleNamespace(run_dir=run_dir) + win.session_name_edit = DummyLineEdit("MouseA") + win.filename_edit = DummyLineEdit("MouseA_2026-07-10_1.avi") + return win + + +def test_main_window_build_processor_recording_context(main_window_cls, tmp_path): + win = make_window_shell(main_window_cls, run_dir=tmp_path) + + context = win._build_processor_recording_context(tmp_path) + + assert context["run_dir"] == tmp_path + assert context["session_name"] == "MouseA" + assert context["filename"] == "MouseA_2026-07-10_1.avi" + assert context["filename_stem"] == "MouseA_2026-07-10_1" + assert context["processor_base_path"] == tmp_path / "MouseA_2026-07-10_1" + + +def test_main_window_get_processor_instance_prefers_direct_processor(main_window_cls): + processor = HookProcessor() + win = make_window_shell(main_window_cls, processor=processor) + + assert win._get_dlc_processor_instance() is processor + + +def test_main_window_get_processor_instance_falls_back_to_dlclive_processor(main_window_cls): + processor = HookProcessor() + win = main_window_cls.__new__(main_window_cls) + win._dlc = SimpleNamespace(_processor=None, _dlc=SimpleNamespace(processor=processor)) + + assert win._get_dlc_processor_instance() is processor + + +def test_main_window_notify_processor_recording_started_calls_hook(main_window_cls, tmp_path): + processor = HookProcessor() + win = make_window_shell(main_window_cls, processor=processor, run_dir=tmp_path) + + win._notify_processor_recording_started(tmp_path) + + assert len(processor.started_contexts) == 1 + context = processor.started_contexts[0] + assert context["processor_base_path"] == tmp_path / "MouseA_2026-07-10_1" + + +def test_main_window_notify_processor_recording_stopped_calls_hook(main_window_cls, tmp_path): + processor = HookProcessor() + win = make_window_shell(main_window_cls, processor=processor, run_dir=tmp_path) + + win._notify_processor_recording_stopped() + + assert len(processor.stopped_contexts) == 1 + context = processor.stopped_contexts[0] + assert context["processor_base_path"] == tmp_path / "MouseA_2026-07-10_1" + + +def test_main_window_save_processor_data_calls_save(main_window_cls, tmp_path): + processor = SaveOnlyProcessor() + win = make_window_shell(main_window_cls, processor=processor, run_dir=tmp_path) + + win._save_processor_data_if_available() + + assert processor.save_calls == 1 + + +def test_main_window_processor_hooks_are_optional(main_window_cls, tmp_path): + class NoHooks: + pass + + win = make_window_shell(main_window_cls, processor=NoHooks(), run_dir=tmp_path) + + # Optional hooks/save absence should not crash. + win._notify_processor_recording_started(tmp_path) + win._notify_processor_recording_stopped() + win._save_processor_data_if_available() diff --git a/tests/gui/camera_config/test_cam_dialog_e2e.py b/tests/gui/camera_config/test_cam_dialog_e2e.py index df1c357e8..9556efc72 100644 --- a/tests/gui/camera_config/test_cam_dialog_e2e.py +++ b/tests/gui/camera_config/test_cam_dialog_e2e.py @@ -8,7 +8,7 @@ from PySide6.QtCore import Qt from PySide6.QtWidgets import QMessageBox -from dlclivegui.cameras.base import CameraBackend +from dlclivegui.cameras.base import CameraBackend, CapturedFrame from dlclivegui.cameras.factory import CameraFactory, DetectedCamera from dlclivegui.config import CameraSettings, MultiCameraSettings from dlclivegui.gui.camera_config.camera_config_dialog import CameraConfigDialog @@ -194,7 +194,7 @@ def close(self): self._opened = False def read(self): - return np.zeros((30, 40, 3), dtype=np.uint8), 0.1 + return CapturedFrame(np.zeros((30, 40, 3), dtype=np.uint8), 0.1, None) CountingBackend.opens = 0 monkeypatch.setattr(CameraFactory, "create", staticmethod(lambda s: CountingBackend(s))) @@ -238,7 +238,7 @@ def close(self): self._opened = False def read(self): - return np.zeros((30, 40, 3), dtype=np.uint8), 0.1 + return CapturedFrame(np.zeros((30, 40, 3), dtype=np.uint8), 0.1, None) CountingBackend.opens = 0 monkeypatch.setattr(CameraFactory, "create", staticmethod(lambda s: CountingBackend(s))) diff --git a/tests/gui/camera_config/test_trigger_config.py b/tests/gui/camera_config/test_trigger_config.py new file mode 100644 index 000000000..201817773 --- /dev/null +++ b/tests/gui/camera_config/test_trigger_config.py @@ -0,0 +1,287 @@ +# tests/gui/camera_config/test_trigger_config_dialog.py +from __future__ import annotations + +import pytest + +from dlclivegui.config import CameraSettings +from dlclivegui.gui.camera_config.trigger_config_dialog import ( + TriggerConfigDialog, + _backend_namespace, + trigger_ui_profile_for_backend, +) + + +class TestTriggerUiProfiles: + @pytest.mark.parametrize( + ("backend", "supports_input", "supports_master", "show_strobe", "show_line"), + [ + ("gentl", True, True, True, True), + ("basler", True, True, False, True), + ("opencv", False, False, False, False), + ("fake", False, False, False, False), + ], + ) + def test_profile_capabilities_by_backend( + self, + backend: str, + supports_input: bool, + supports_master: bool, + show_strobe: bool, + show_line: bool, + ): + profile = trigger_ui_profile_for_backend(backend) + + assert profile.supports_input is supports_input + assert profile.supports_master is supports_master + assert profile.show_strobe_fields is show_strobe + assert profile.show_line_output_fields is show_line + + def test_profile_backend_is_case_insensitive(self): + upper = trigger_ui_profile_for_backend("GeNtL") + lower = trigger_ui_profile_for_backend("gentl") + + assert upper == lower + + +class TestBackendNamespace: + def test_backend_namespace_creates_backend_dict(self): + cam = CameraSettings(backend="gentl", index=0, properties={}) + + ns = _backend_namespace(cam) + + assert ns == {} + assert cam.properties == {"gentl": {}} + + def test_backend_namespace_replaces_non_dict_properties(self): + cam = CameraSettings(backend="gentl", index=0, properties={}) + cam.properties = None + + ns = _backend_namespace(cam) + ns["trigger"] = {"role": "external"} + + assert cam.properties == {"gentl": {"trigger": {"role": "external"}}} + + def test_backend_namespace_replaces_non_dict_namespace(self): + cam = CameraSettings(backend="gentl", index=0, properties={"gentl": "bad"}) + + ns = _backend_namespace(cam) + + assert ns == {} + assert cam.properties == {"gentl": {}} + + +class TestTriggerConfigDialogPresentation: + @pytest.mark.gui + def test_unknown_backend_exposes_only_off_role_and_disables_trigger_fields(self, qtbot): + cam = CameraSettings(backend="opencv", index=0, properties={}) + dlg = TriggerConfigDialog(cam) + qtbot.addWidget(dlg) + + roles = [dlg.role_combo.itemData(i) for i in range(dlg.role_combo.count())] + + assert roles == ["off"] + assert not dlg.selector_edit.isVisible() + assert not dlg.source_combo.isVisible() + assert not dlg.activation_combo.isVisible() + assert not dlg.output_line_edit.isVisible() + assert not dlg.strobe_polarity_combo.isVisible() + + @pytest.mark.gui + def test_gentl_profile_shows_input_master_line_and_strobe_fields(self, qtbot): + cam = CameraSettings(backend="gentl", index=0, properties={}) + dlg = TriggerConfigDialog(cam) + qtbot.addWidget(dlg) + + roles = [dlg.role_combo.itemData(i) for i in range(dlg.role_combo.count())] + + assert roles == ["off", "external", "follower", "master"] + assert not dlg.selector_edit.isHidden() + assert not dlg.source_combo.isHidden() + assert not dlg.activation_combo.isHidden() + assert not dlg.output_line_edit.isHidden() + assert not dlg.output_source_edit.isHidden() + assert not dlg.strobe_polarity_combo.isHidden() + assert not dlg.strobe_operation_combo.isHidden() + assert not dlg.strobe_duration_spin.isHidden() + assert not dlg.strobe_delay_spin.isHidden() + + @pytest.mark.gui + def test_basler_profile_shows_line_fields_but_hides_strobe_fields(self, qtbot): + cam = CameraSettings(backend="basler", index=0, properties={}) + dlg = TriggerConfigDialog(cam) + qtbot.addWidget(dlg) + + roles = [dlg.role_combo.itemData(i) for i in range(dlg.role_combo.count())] + + assert roles == ["off", "external", "follower", "master"] + assert not dlg.output_line_edit.isHidden() + assert not dlg.output_source_edit.isHidden() + assert dlg.strobe_polarity_combo.isHidden() + assert dlg.strobe_operation_combo.isHidden() + assert dlg.strobe_duration_spin.isHidden() + assert dlg.strobe_delay_spin.isHidden() + + @pytest.mark.gui + def test_role_changes_enable_input_and_output_fields(self, qtbot): + cam = CameraSettings(backend="gentl", index=0, properties={}) + dlg = TriggerConfigDialog(cam) + qtbot.addWidget(dlg) + + dlg.role_combo.setCurrentIndex(dlg.role_combo.findData("off")) + assert not dlg.selector_edit.isEnabled() + assert not dlg.source_combo.isEnabled() + assert not dlg.output_line_edit.isEnabled() + + dlg.role_combo.setCurrentIndex(dlg.role_combo.findData("external")) + assert dlg.selector_edit.isEnabled() + assert dlg.source_combo.isEnabled() + assert dlg.activation_combo.isEnabled() + assert not dlg.output_line_edit.isEnabled() + + dlg.role_combo.setCurrentIndex(dlg.role_combo.findData("master")) + assert not dlg.selector_edit.isEnabled() + assert not dlg.source_combo.isEnabled() + assert dlg.output_line_edit.isEnabled() + assert dlg.output_source_edit.isEnabled() + assert dlg.strobe_polarity_combo.isEnabled() + assert dlg.strobe_delay_spin.isEnabled() + + @pytest.mark.gui + def test_fixed_duration_enables_strobe_duration_only_for_master(self, qtbot): + cam = CameraSettings(backend="gentl", index=0, properties={}) + dlg = TriggerConfigDialog(cam) + qtbot.addWidget(dlg) + + dlg.role_combo.setCurrentIndex(dlg.role_combo.findData("master")) + dlg.strobe_operation_combo.setCurrentIndex(dlg.strobe_operation_combo.findData("FixedDuration")) + + assert dlg.strobe_duration_spin.isEnabled() + + dlg.role_combo.setCurrentIndex(dlg.role_combo.findData("external")) + + assert not dlg.strobe_duration_spin.isEnabled() + + +class TestTriggerConfigDialogModelRoundtrip: + @pytest.mark.gui + def test_loads_existing_trigger_settings(self, qtbot): + cam = CameraSettings( + backend="gentl", + index=0, + properties={ + "gentl": { + "trigger": { + "role": "external", + "selector": "FrameStart", + "source": "Line1", + "activation": "FallingEdge", + "timeout": 0.25, + "strict": True, + } + } + }, + ) + + dlg = TriggerConfigDialog(cam) + qtbot.addWidget(dlg) + + assert dlg.role_combo.currentData() == "external" + assert dlg.selector_edit.text() == "FrameStart" + assert dlg.source_combo.currentText() == "Line1" + assert dlg.activation_combo.currentData() == "FallingEdge" + assert dlg.timeout_spin.value() == pytest.approx(0.25) + assert dlg.strict_checkbox.isChecked() + + @pytest.mark.gui + def test_accept_external_writes_trigger_payload_to_backend_namespace(self, qtbot): + cam = CameraSettings(backend="gentl", index=0, properties={}) + dlg = TriggerConfigDialog(cam) + qtbot.addWidget(dlg) + + dlg.role_combo.setCurrentIndex(dlg.role_combo.findData("external")) + dlg.selector_edit.setText("FrameStart") + dlg.source_combo.setCurrentText("Line1") + dlg.activation_combo.setCurrentIndex(dlg.activation_combo.findData("FallingEdge")) + dlg.timeout_spin.setValue(0.5) + dlg.strict_checkbox.setChecked(True) + + with qtbot.waitSignal(dlg.accepted, timeout=1000): + dlg._accept() + + trigger = dlg.camera_settings.properties["gentl"]["trigger"] + + assert trigger["role"] == "external" + assert trigger["selector"] == "FrameStart" + assert trigger["source"] == "Line1" + assert trigger["activation"] == "FallingEdge" + assert trigger["timeout"] == pytest.approx(0.5) + assert trigger["strict"] is True + + @pytest.mark.gui + def test_accept_off_clears_timeout(self, qtbot): + cam = CameraSettings( + backend="gentl", + index=0, + properties={"gentl": {"trigger": {"role": "external", "timeout": 2.0}}}, + ) + dlg = TriggerConfigDialog(cam) + qtbot.addWidget(dlg) + + dlg.role_combo.setCurrentIndex(dlg.role_combo.findData("off")) + dlg.timeout_spin.setValue(1.25) + + with qtbot.waitSignal(dlg.accepted, timeout=1000): + dlg._accept() + + trigger = dlg.camera_settings.properties["gentl"]["trigger"] + + assert trigger["role"] == "off" + assert trigger.get("timeout") is None + + @pytest.mark.gui + def test_accept_master_gentl_includes_strobe_values(self, qtbot): + cam = CameraSettings(backend="gentl", index=0, properties={}) + dlg = TriggerConfigDialog(cam) + qtbot.addWidget(dlg) + + dlg.role_combo.setCurrentIndex(dlg.role_combo.findData("master")) + dlg.output_line_edit.setText("Line2") + dlg.output_source_edit.setText("ExposureActive") + dlg.strobe_polarity_combo.setCurrentIndex(dlg.strobe_polarity_combo.findData("ActiveLow")) + dlg.strobe_operation_combo.setCurrentIndex(dlg.strobe_operation_combo.findData("FixedDuration")) + dlg.strobe_duration_spin.setValue(1200) + dlg.strobe_delay_spin.setValue(300) + + with qtbot.waitSignal(dlg.accepted, timeout=1000): + dlg._accept() + + trigger = dlg.camera_settings.properties["gentl"]["trigger"] + + assert trigger["role"] == "master" + assert trigger["output_line"] == "Line2" + assert trigger["output_source"] == "ExposureActive" + assert trigger["strobe_polarity"] == "ActiveLow" + assert trigger["strobe_operation"] == "FixedDuration" + assert trigger["strobe_duration"] == 1200 + assert trigger["strobe_delay"] == 300 + + @pytest.mark.gui + def test_accept_master_basler_does_not_add_strobe_values(self, qtbot): + cam = CameraSettings(backend="basler", index=0, properties={}) + dlg = TriggerConfigDialog(cam) + qtbot.addWidget(dlg) + + dlg.role_combo.setCurrentIndex(dlg.role_combo.findData("master")) + dlg.strobe_duration_spin.setValue(1200) + dlg.strobe_delay_spin.setValue(300) + + with qtbot.waitSignal(dlg.accepted, timeout=1000): + dlg._accept() + + trigger = dlg.camera_settings.properties["basler"]["trigger"] + + assert trigger["role"] == "master" + assert "strobe_duration" not in trigger + assert "strobe_delay" not in trigger + assert "strobe_polarity" not in trigger + assert "strobe_operation" not in trigger diff --git a/tests/gui/main_window/test_preview.py b/tests/gui/main_window/test_preview.py new file mode 100644 index 000000000..571813d79 --- /dev/null +++ b/tests/gui/main_window/test_preview.py @@ -0,0 +1,124 @@ +# tests/gui/main_window/test_preview.py +from __future__ import annotations + +from types import SimpleNamespace + +import numpy as np +import pytest +from PySide6.QtGui import QPixmap + +from dlclivegui.services.multi_camera_controller import get_camera_id + + +@pytest.mark.gui +class TestPreviewLifecycle: + def test_start_preview_with_no_active_cameras_shows_error(self, monkeypatch, window): + w = window + for cam in w._config.multi_camera.cameras: + cam.enabled = False + + messages: list[str] = [] + monkeypatch.setattr(w, "_show_error", messages.append) + + w._start_preview() + + assert messages == ["No cameras configured. Use 'Configure Cameras...' to add cameras."] + assert not w.multi_camera_controller.is_running() + assert w.preview_button.isEnabled() + assert not w.stop_preview_button.isEnabled() + + def test_on_multi_camera_stopped_clears_runtime_state(self, monkeypatch, window): + w = window + monkeypatch.setattr(w, "_stop_multi_camera_recording", lambda: None) + + w.preview_button.setEnabled(False) + w.stop_preview_button.setEnabled(True) + w._current_frame = np.zeros((4, 4, 3), dtype=np.uint8) + w._multi_camera_frames = {"fake:index:0": np.zeros((4, 4, 3), dtype=np.uint8)} + w._multi_camera_display_ids = {"fake:index:0": "Cam0"} + w._running_cams_ids = {"fake:index:0"} + w._display_dirty = True + w.video_label.setPixmap(QPixmap(8, 8)) + + w._on_multi_camera_stopped() + + assert w.preview_button.isEnabled() + assert not w.stop_preview_button.isEnabled() + assert w._current_frame is None + assert w._multi_camera_frames == {} + assert w._multi_camera_display_ids == {} + assert w._running_cams_ids == set() + assert w._display_dirty is False + assert w.video_label.text() == "Camera preview not started" + + def test_stop_preview_requests_orderly_shutdown(self, monkeypatch, window): + w = window + calls: list[str] = [] + + monkeypatch.setattr(w.multi_camera_controller, "is_running", lambda: True) + monkeypatch.setattr(w, "_stop_multi_camera_recording", lambda: calls.append("recording")) + monkeypatch.setattr(w, "_stop_inference", lambda show_message=False: calls.append("inference")) + monkeypatch.setattr( + w.multi_camera_controller, + "stop", + lambda *args, **kwargs: calls.append("controller"), + ) + + w.preview_button.setEnabled(True) + w.stop_preview_button.setEnabled(True) + w.start_inference_button.setEnabled(True) + w.stop_inference_button.setEnabled(True) + w._pending_recording_after_preview = True + + w._stop_preview() + + assert calls == ["recording", "inference", "controller"] + assert w._pending_recording_after_preview is False + assert not w.preview_button.isEnabled() + assert not w.stop_preview_button.isEnabled() + assert not w.start_inference_button.isEnabled() + assert not w.stop_inference_button.isEnabled() + assert w.camera_stats_label.text() == "Camera idle" + + def test_on_multi_camera_started_updates_primary_buttons(self, window): + w = window + + w.preview_button.setEnabled(True) + w.stop_preview_button.setEnabled(False) + + w._on_multi_camera_started() + + assert not w.preview_button.isEnabled() + assert w.stop_preview_button.isEnabled() + + def test_processing_runtime_fallback_does_not_overwrite_preferred_inference_camera(self, window): + w = window + + active_cams = w._config.multi_camera.get_active_cameras() + if len(active_cams) < 2: + pytest.skip("This regression test requires at least two active cameras.") + + fallback_cam = active_cams[0] + preferred_cam = active_cams[1] + + fallback_id = get_camera_id(fallback_cam) + preferred_id = get_camera_id(preferred_cam) + + w._inference_camera_id = preferred_id + w._active_inference_camera_id = preferred_id + w._running_cams_ids = set() + w._dlc_active = False + + frame = np.zeros((4, 4, 3), dtype=np.uint8) + frame_data = SimpleNamespace( + frames={fallback_id: frame}, + display_ids={fallback_id: "Fallback camera"}, + source_camera_id=fallback_id, + timestamps={fallback_id: 123.0}, + ) + + w._on_multi_frame_processing_ready(frame_data) + + assert w._inference_camera_id == preferred_id + assert w._active_inference_camera_id == fallback_id + assert w.dlc_camera_combo.currentData() == fallback_id diff --git a/tests/gui/main_window/test_recording.py b/tests/gui/main_window/test_recording.py new file mode 100644 index 000000000..0443adb67 --- /dev/null +++ b/tests/gui/main_window/test_recording.py @@ -0,0 +1,92 @@ +# tests/gui/main_window/test_recording.py +from __future__ import annotations + +import numpy as np +import pytest + +from dlclivegui.services.multi_camera_controller import get_camera_id + + +@pytest.mark.gui +class TestRecordingLifecycle: + def test_start_recording_auto_starts_preview_when_preview_is_not_running(self, monkeypatch, window): + w = window + calls: list[str] = [] + + monkeypatch.setattr(w.multi_camera_controller, "is_running", lambda: False) + monkeypatch.setattr(w, "_start_preview", lambda: calls.append("preview")) + + w._pending_recording_after_preview = False + w._start_recording() + + assert calls == ["preview"] + assert w._pending_recording_after_preview is True + + def test_start_recording_starts_immediately_when_preview_is_running(self, monkeypatch, window): + w = window + calls: list[str] = [] + + monkeypatch.setattr(w.multi_camera_controller, "is_running", lambda: True) + monkeypatch.setattr(w, "_start_multi_camera_recording", lambda: calls.append("recording")) + + w._pending_recording_after_preview = False + w._start_recording() + + assert calls == ["recording"] + assert w._pending_recording_after_preview is False + + def test_pending_recording_waits_until_all_expected_frames_are_available(self, monkeypatch, window): + w = window + calls: list[str] = [] + + monkeypatch.setattr(w.multi_camera_controller, "is_running", lambda: True) + monkeypatch.setattr(w, "_start_multi_camera_recording", lambda: calls.append("recording")) + + active = w._config.multi_camera.get_active_cameras() + assert len(active) >= 2 + first_id = get_camera_id(active[0]) + + w._pending_recording_after_preview = True + w._multi_camera_frames = {first_id: np.zeros((4, 4, 3), dtype=np.uint8)} + + w._try_start_pending_recording() + + assert calls == [] + assert w._pending_recording_after_preview is True + + def test_pending_recording_starts_when_all_expected_frames_are_available(self, monkeypatch, window): + w = window + calls: list[str] = [] + + monkeypatch.setattr(w.multi_camera_controller, "is_running", lambda: True) + monkeypatch.setattr(w, "_start_multi_camera_recording", lambda: calls.append("recording")) + + w._pending_recording_after_preview = True + w._multi_camera_frames = { + get_camera_id(cam): np.zeros((4, 4, 3), dtype=np.uint8) + for cam in w._config.multi_camera.get_active_cameras() + } + + w._try_start_pending_recording() + + assert calls == ["recording"] + assert w._pending_recording_after_preview is False + + def test_start_multi_camera_recording_success_sets_buttons_and_sink(self, window, start_all_spy): + w = window + active = w._config.multi_camera.get_active_cameras() + w._multi_camera_frames = {get_camera_id(cam): np.zeros((4, 4, 3), dtype=np.uint8) for cam in active} + + w._start_multi_camera_recording() + + assert start_all_spy["active_cams"] == active + assert not w.start_record_button.isEnabled() + assert w.stop_record_button.isEnabled() + + def test_stop_multi_camera_recording_when_idle_is_noop(self, window): + w = window + w._recording_stopping = False + + w._stop_multi_camera_recording() + + assert w._recording_stopping is False diff --git a/tests/gui/main_window/test_ui.py b/tests/gui/main_window/test_ui.py new file mode 100644 index 000000000..179086448 --- /dev/null +++ b/tests/gui/main_window/test_ui.py @@ -0,0 +1,53 @@ +# tests/gui/main_window/test_ui.py +from __future__ import annotations + +import pytest + +from dlclivegui.services.multi_camera_controller import get_camera_id + + +@pytest.mark.gui +class TestCameraLabels: + def test_update_active_cameras_label_uses_backend_device_name(self, window): + w = window + cam = w._config.multi_camera.cameras[0] + cam.name = "" + cam.properties = {"fake": {"device_name": "The Camera"}} + for extra in w._config.multi_camera.cameras[1:]: + extra.enabled = False + + w._update_active_cameras_label() + + assert "The Camera" in w.active_cameras_label.text() + assert "[fake:0]" in w.active_cameras_label.text() + + def test_refresh_dlc_camera_list_displays_friendly_label_but_stores_stable_id(self, window): + w = window + cam = w._config.multi_camera.cameras[0] + cam.name = "" + cam.properties = {"fake": {"device_name": "The Camera", "device_id": "stable-123"}} + for extra in w._config.multi_camera.cameras[1:]: + extra.enabled = False + + w._refresh_dlc_camera_list() + + assert w.dlc_camera_combo.count() == 1 + assert "The Camera" in w.dlc_camera_combo.itemText(0) + assert "stable-123" not in w.dlc_camera_combo.itemText(0) + assert w.dlc_camera_combo.itemData(0) == get_camera_id(cam) + + def test_label_for_cam_id_prefers_configured_friendly_label(self, window): + w = window + cam = w._config.multi_camera.cameras[0] + cam.name = "Top camera" + + assert w._label_for_cam_id(get_camera_id(cam)).startswith("Top camera") + + def test_label_for_cam_id_uses_runtime_display_id_fallback(self, window): + w = window + w._multi_camera_display_ids = {"runtime:id": "Runtime Camera"} + + assert w._label_for_cam_id("runtime:id") == "Runtime Camera" + + def test_label_for_cam_id_unknown_is_neutral(self, window): + assert window._label_for_cam_id("missing:id") == "Unknown camera" diff --git a/tests/gui/main_window/test_user_config.py b/tests/gui/main_window/test_user_config.py new file mode 100644 index 000000000..f42c4b9c9 --- /dev/null +++ b/tests/gui/main_window/test_user_config.py @@ -0,0 +1,142 @@ +# tests/gui/main_window/test_user_config.py +from __future__ import annotations + +from pathlib import Path + +import pytest + + +@pytest.mark.gui +class TestUserConfigPersistence: + def test_valid_config_file_path_accepts_existing_file(self, window, tmp_path: Path): + w = window + + config_path = tmp_path / "dlclive_config.json" + config_path.write_text("{}", encoding="utf-8") + + assert w._valid_config_file_path(str(config_path)) == config_path.resolve() + + def test_valid_config_file_path_rejects_missing_file(self, window, tmp_path: Path): + w = window + + missing = tmp_path / "missing_config.json" + + assert w._valid_config_file_path(str(missing)) is None + assert w._valid_config_file_path(None) is None + assert w._valid_config_file_path("") is None + + def test_suggest_config_dialog_path_prefers_current_config_path(self, window, tmp_path: Path): + w = window + + config_path = tmp_path / "current_config.json" + config_path.write_text("{}", encoding="utf-8") + + w._config_path = config_path + + assert w._suggest_config_dialog_path() == str(config_path) + + def test_suggest_config_dialog_path_uses_last_config_path_when_current_path_missing( + self, + monkeypatch, + window, + tmp_path: Path, + ): + w = window + + config_path = tmp_path / "last_config.json" + config_path.write_text("{}", encoding="utf-8") + + w._config_path = None + monkeypatch.setattr(w._settings_store, "get_last_config_path", lambda: str(config_path)) + + assert w._suggest_config_dialog_path() == str(config_path.resolve()) + + def test_suggest_config_dialog_path_uses_parent_of_missing_last_config( + self, + monkeypatch, + window, + tmp_path: Path, + ): + w = window + + missing_config_path = tmp_path / "missing_config.json" + + w._config_path = None + monkeypatch.setattr(w._settings_store, "get_last_config_path", lambda: str(missing_config_path)) + + assert w._suggest_config_dialog_path() == str(missing_config_path) + + def test_save_config_to_path_persists_last_path_snapshot_and_syncs( + self, + monkeypatch, + window, + tmp_path: Path, + ): + w = window + + calls: list[tuple[str, object]] = [] + + class FakeConfig: + def save(self, path: Path | str) -> None: + Path(path).write_text("{}", encoding="utf-8") + + class FakeSettings: + def sync(self) -> None: + calls.append(("sync", True)) + + config_path = tmp_path / "saved_config.json" + fake_config = FakeConfig() + + monkeypatch.setattr(w, "_current_config", lambda allow_empty_model_path=False: fake_config) + monkeypatch.setattr( + w._settings_store, + "set_last_config_path", + lambda path: calls.append(("last_path", path)), + ) + monkeypatch.setattr( + w._settings_store, + "save_full_config_snapshot", + lambda cfg: calls.append(("snapshot", cfg)), + ) + monkeypatch.setattr(w, "settings", FakeSettings()) + + assert w._save_config_to_path(config_path) is True + assert config_path.exists() + assert ("last_path", str(config_path.resolve())) in calls + assert ("snapshot", fake_config) in calls + assert ("sync", True) in calls + + def test_save_config_to_path_returns_false_without_persisting_after_failure( + self, + monkeypatch, + window, + tmp_path: Path, + ): + w = window + + calls: list[tuple[str, object]] = [] + errors: list[str] = [] + + class FakeConfig: + def save(self, path: Path | str) -> None: + raise OSError("cannot save") + + config_path = tmp_path / "failed_config.json" + + monkeypatch.setattr(w, "_current_config", lambda allow_empty_model_path=False: FakeConfig()) + monkeypatch.setattr( + w._settings_store, + "set_last_config_path", + lambda path: calls.append(("last_path", path)), + ) + monkeypatch.setattr( + w._settings_store, + "save_full_config_snapshot", + lambda cfg: calls.append(("snapshot", cfg)), + ) + monkeypatch.setattr(w, "_show_error", errors.append) + + assert w._save_config_to_path(config_path) is False + assert not config_path.exists() + assert calls == [] + assert errors diff --git a/tests/gui/test_app_entrypoint.py b/tests/gui/test_app_entrypoint.py index 0a68bb2ef..b6b1c6051 100644 --- a/tests/gui/test_app_entrypoint.py +++ b/tests/gui/test_app_entrypoint.py @@ -31,6 +31,7 @@ def set_use_splash_false(monkeypatch): @pytest.mark.gui def test_main_with_splash(monkeypatch, set_use_splash_true): appmod = _import_fresh() + monkeypatch.setattr(appmod, "_maybe_allow_keyboard_interrupt", MagicMock(name="_maybe_allow_keyboard_interrupt")) # --- Patch Qt app & icon in the entry module's namespace --- QApplication_cls = MagicMock(name="QApplication") @@ -101,6 +102,7 @@ def immediate_single_shot(ms, fn): @pytest.mark.gui def test_main_without_splash(monkeypatch, set_use_splash_false): appmod = _import_fresh() + monkeypatch.setattr(appmod, "_maybe_allow_keyboard_interrupt", MagicMock(name="_maybe_allow_keyboard_interrupt")) # Patch Qt app creation & window icon QApplication_cls = MagicMock(name="QApplication") diff --git a/tests/gui/test_main.py b/tests/gui/test_main.py index b9ed7daf0..8c60e933d 100644 --- a/tests/gui/test_main.py +++ b/tests/gui/test_main.py @@ -1,3 +1,4 @@ +# tests/gui/test_main.py import pytest from PySide6.QtCore import Qt from PySide6.QtGui import QImage diff --git a/tests/gui/test_pose_overlay.py b/tests/gui/test_pose_overlay.py index 369baf846..bef210b3c 100644 --- a/tests/gui/test_pose_overlay.py +++ b/tests/gui/test_pose_overlay.py @@ -9,6 +9,7 @@ def stop(self): @pytest.mark.gui @pytest.mark.timeout(10) +@pytest.mark.skip("Removed functionality.") def test_record_overlay_uses_identity_transform_for_per_camera_recording(window, draw_pose_stub): # Disable event timers to avoid GUI rendering pipelines interfering with test window._display_timer.stop() @@ -47,6 +48,7 @@ def test_record_overlay_uses_identity_transform_for_per_camera_recording(window, @pytest.mark.gui @pytest.mark.timeout(10) +@pytest.mark.skip("Removed functionality.") def test_record_overlay_toggle_affects_frames_sent_to_recorder(window, recording_frame_spy, draw_pose_stub): # Disable event timers to avoid GUI rendering pipelines interfering with test window._display_timer.stop() @@ -65,18 +67,9 @@ 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 - from dlclivegui.services.multi_camera_controller import MultiFrameData - - frame_data = MultiFrameData( - frames={cam_id: raw}, - timestamps={cam_id: 1.0}, - source_camera_id=cam_id, - ) - # 1) toggle OFF: should record raw window.record_with_overlays_checkbox.setChecked(False) - window._on_multi_frame_ready(frame_data) + window._on_recording_frame_ready(cam_id, raw, 1.0) assert cam_id in recording_frame_spy recorded_off = recording_frame_spy[cam_id] @@ -84,7 +77,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_recording_frame_ready(cam_id, raw, 2.0) recorded_on = recording_frame_spy[cam_id] assert not np.array_equal(recorded_on, raw) diff --git a/tests/gui/test_rec_manager.py b/tests/gui/test_rec_manager.py index c789078b0..df36f08bf 100644 --- a/tests/gui/test_rec_manager.py +++ b/tests/gui/test_rec_manager.py @@ -4,9 +4,10 @@ import pytest from dlclivegui.config import CameraSettings -from dlclivegui.gui.recording_manager import RecordingManager from dlclivegui.services.multi_camera_controller import get_camera_id, get_display_id -from dlclivegui.services.video_recorder import RecorderStats +from dlclivegui.services.recording_manager import RecordingManager +from dlclivegui.utils.stats import RecorderStats +from dlclivegui.utils.timestamps import FrameTimestampMetadata @pytest.fixture @@ -42,37 +43,40 @@ def test_start_all_creates_recorders_and_returns_run_dir( spy, expected_run_dir = patch_build_run_dir mgr = RecordingManager() - run_dir = mgr.start_all( - recording_settings, - _active_cams_two, - current_frames, - session_name="Sess", - use_timestamp=True, - all_or_nothing=False, - ) - - assert run_dir == expected_run_dir - assert mgr.is_active is True - assert mgr.run_dir == expected_run_dir - assert mgr.session_dir is not None - assert len(mgr.recorders) == 2 - - # build_run_dir called with correct use_timestamp - assert spy["use_timestamp"] is True - assert spy["session_dir"] is not None + try: + run_dir = mgr.start_all( + recording_settings, + _active_cams_two, + current_frames, + session_name="Sess", + use_timestamp=True, + all_or_nothing=False, + ) - # Validate per-cam recorder construction - for cam in _active_cams_two: - cam_id = get_camera_id(cam) - rec = mgr.recorders[cam_id] - assert rec.codec == recording_settings.codec - assert rec.crf == recording_settings.crf - assert rec.frame_rate == float(cam.fps) - assert rec.is_running is True - # output file should be inside run dir - assert rec.output.parent == expected_run_dir - # filename should include backend + cam index - assert f"_{cam.backend}_cam{cam.index}" in rec.output.name + assert run_dir == expected_run_dir + assert mgr.is_active is True + assert mgr.run_dir == expected_run_dir + assert mgr.session_dir is not None + assert len(mgr.recorders) == 2 + + # build_run_dir called with correct use_timestamp + assert spy["use_timestamp"] is True + assert spy["session_dir"] is not None + + # Validate per-cam recorder construction + for cam in _active_cams_two: + cam_id = get_camera_id(cam) + rec = mgr.recorders[cam_id] + assert rec.codec == recording_settings.codec + assert rec.crf == recording_settings.crf + assert rec.frame_rate == float(cam.fps) + assert rec.is_running is True + # output file should be inside run dir + assert rec.output.parent == expected_run_dir + # filename should include backend + cam index + assert f"_{cam.backend}_cam{cam.index}" in rec.output.name + finally: + mgr.stop_all() @pytest.mark.unit @@ -82,8 +86,11 @@ def test_start_all_passes_use_timestamp_flag( spy, _expected_run_dir = patch_build_run_dir mgr = RecordingManager() - mgr.start_all(recording_settings, _active_cams_two, current_frames, session_name="Sess", use_timestamp=False) - assert spy["use_timestamp"] is False + try: + mgr.start_all(recording_settings, _active_cams_two, current_frames, session_name="Sess", use_timestamp=False) + assert spy["use_timestamp"] is False + finally: + mgr.stop_all() @pytest.mark.unit @@ -91,14 +98,18 @@ def test_frame_size_is_inferred_from_current_frames( recording_settings, _active_cams_two, current_frames, patch_video_recorder, patch_build_run_dir ): mgr = RecordingManager() - mgr.start_all(recording_settings, _active_cams_two, current_frames, session_name="Sess") - # cam0 -> 480x640, cam1 -> 720x1280 - for cam in _active_cams_two: - cam_id = get_camera_id(cam) - rec = mgr.recorders[cam_id] - frame = current_frames[cam_id] - assert rec.frame_size == (frame.shape[0], frame.shape[1]) + try: + mgr.start_all(recording_settings, _active_cams_two, current_frames, session_name="Sess") + + # cam0 -> 480x640, cam1 -> 720x1280 + for cam in _active_cams_two: + cam_id = get_camera_id(cam) + rec = mgr.recorders[cam_id] + frame = current_frames[cam_id] + assert rec.frame_size == (frame.shape[0], frame.shape[1]) + finally: + mgr.stop_all() @pytest.mark.unit @@ -110,10 +121,14 @@ def test_missing_frame_results_in_none_frame_size( current_frames.pop(cam1_id) mgr = RecordingManager() - mgr.start_all(recording_settings, _active_cams_two, current_frames, session_name="Sess") - rec1 = mgr.recorders[cam1_id] - assert rec1.frame_size is None + try: + mgr.start_all(recording_settings, _active_cams_two, current_frames, session_name="Sess") + + rec1 = mgr.recorders[cam1_id] + assert rec1.frame_size is None + finally: + mgr.stop_all() @pytest.mark.unit @@ -174,6 +189,7 @@ def start_with_failure(self): assert mgr.session_dir is None finally: patch_video_recorder.start = original_start + mgr.stop_all() @pytest.mark.unit @@ -196,14 +212,19 @@ def test_write_frame_uses_given_timestamp( recording_settings, _active_cams_two, current_frames, patch_video_recorder, patch_build_run_dir ): mgr = RecordingManager() - mgr.start_all(recording_settings, _active_cams_two, current_frames, session_name="Sess") - cam0_id = get_camera_id(_active_cams_two[0]) - frame = current_frames[cam0_id] - mgr.write_frame(cam0_id, frame, timestamp=123.0) + try: + mgr.start_all(recording_settings, _active_cams_two, current_frames, session_name="Sess") + + cam0_id = get_camera_id(_active_cams_two[0]) + frame = current_frames[cam0_id] + mgr.write_frame(cam0_id, frame, timestamp=123.0) + assert mgr.flush(timeout=2.0) - rec = mgr.recorders[cam0_id] - assert rec.write_calls[-1][1] == 123.0 + rec = mgr.recorders[cam0_id] + assert rec.write_calls[-1][1] == 123.0 + finally: + mgr.stop_all() @pytest.mark.unit @@ -211,18 +232,23 @@ def test_write_frame_uses_time_when_timestamp_missing( recording_settings, _active_cams_two, current_frames, patch_video_recorder, patch_build_run_dir, monkeypatch ): mgr = RecordingManager() - mgr.start_all(recording_settings, _active_cams_two, current_frames, session_name="Sess") - import dlclivegui.gui.recording_manager as rm_mod # noqa: E402 + try: + mgr.start_all(recording_settings, _active_cams_two, current_frames, session_name="Sess") + + import dlclivegui.services.recording_manager as rm_mod # noqa: E402 - monkeypatch.setattr(rm_mod.time, "time", lambda: 999.0) + monkeypatch.setattr(rm_mod.time, "time", lambda: 999.0) - cam0_id = get_camera_id(_active_cams_two[0]) - frame = current_frames[cam0_id] - mgr.write_frame(cam0_id, frame, timestamp=None) + cam0_id = get_camera_id(_active_cams_two[0]) + frame = current_frames[cam0_id] + mgr.write_frame(cam0_id, frame, timestamp=None) + assert mgr.flush(timeout=2.0) - rec = mgr.recorders[cam0_id] - assert rec.write_calls[-1][1] == 999.0 + rec = mgr.recorders[cam0_id] + assert rec.write_calls[-1][1] == 999.0 + finally: + mgr.stop_all() @pytest.mark.unit @@ -230,14 +256,20 @@ def test_write_frame_removes_recorder_on_exception( recording_settings, _active_cams_two, current_frames, patch_video_recorder, patch_build_run_dir ): mgr = RecordingManager() - mgr.start_all(recording_settings, _active_cams_two, current_frames, session_name="Sess") - cam0_id = get_camera_id(_active_cams_two[0]) - rec = mgr.recorders[cam0_id] - rec.raise_on_write = True + try: + mgr.start_all(recording_settings, _active_cams_two, current_frames, session_name="Sess") - mgr.write_frame(cam0_id, current_frames[cam0_id], timestamp=1.0) - assert cam0_id not in mgr.recorders + cam0_id = get_camera_id(_active_cams_two[0]) + rec = mgr.recorders[cam0_id] + rec.raise_on_write = True + + mgr.write_frame(cam0_id, current_frames[cam0_id], timestamp=1.0) + assert mgr.flush(timeout=2.0) + + assert cam0_id not in mgr.recorders + finally: + mgr.stop_all() @pytest.mark.unit @@ -245,17 +277,21 @@ def test_get_stats_summary_single_recorder_uses_formatter( recording_settings, _active_cams_two, current_frames, patch_video_recorder, patch_build_run_dir, monkeypatch ): mgr = RecordingManager() - mgr.start_all(recording_settings, [_active_cams_two[0]], current_frames, session_name="Sess") - cam0_id = get_camera_id(_active_cams_two[0]) - mgr.recorders[cam0_id]._stats = RecorderStats(frames_written=10, frames_enqueued=12) + try: + mgr.start_all(recording_settings, [_active_cams_two[0]], current_frames, session_name="Sess") - # Patch formatter to avoid depending on formatting implementation - import dlclivegui.utils.stats as stats_mod + cam0_id = get_camera_id(_active_cams_two[0]) + mgr.recorders[cam0_id]._stats = RecorderStats(frames_written=10, frames_enqueued=12) - monkeypatch.setattr(stats_mod, "format_recorder_stats", lambda s: "OK_SINGLE") + # Patch formatter to avoid depending on formatting implementation + import dlclivegui.utils.stats as stats_mod - assert mgr.get_stats_summary() == "OK_SINGLE" + monkeypatch.setattr(stats_mod, "format_recorder_stats", lambda s: "OK_SINGLE") + + assert mgr.get_stats_summary() == "OK_SINGLE" + finally: + mgr.stop_all() @pytest.mark.unit @@ -263,21 +299,43 @@ def test_get_stats_summary_multi_aggregates( recording_settings, _active_cams_two, current_frames, patch_video_recorder, patch_build_run_dir ): mgr = RecordingManager() - mgr.start_all(recording_settings, _active_cams_two, current_frames, session_name="Sess") - ids = [get_camera_id(c) for c in _active_cams_two] - mgr.recorders[ids[0]]._stats = RecorderStats( - frames_written=10, dropped_frames=1, queue_size=2, average_latency=0.01, last_latency=0.02 - ) - mgr.recorders[ids[1]]._stats = RecorderStats( - frames_written=20, dropped_frames=3, queue_size=4, average_latency=0.03, last_latency=0.05 - ) + try: + mgr.start_all(recording_settings, _active_cams_two, current_frames, session_name="Sess") + + ids = [get_camera_id(c) for c in _active_cams_two] + + mgr.recorders[ids[0]]._stats = RecorderStats( + frames_enqueued=12, + frames_written=10, + dropped_frames=1, + queue_size=2, + buffer_size=10, + average_latency=0.01, + last_latency=0.02, + write_fps=25.0, + ) + mgr.recorders[ids[1]]._stats = RecorderStats( + frames_enqueued=24, + frames_written=20, + dropped_frames=3, + queue_size=4, + buffer_size=10, + average_latency=0.03, + last_latency=0.05, + write_fps=30.0, + ) - summary = mgr.get_stats_summary() - assert "2 cams" in summary - assert "30 frames" in summary # 10 + 20 - assert "dropped 4" in summary # 1 + 3 - assert "queue 6" in summary # 2 + 4 + summary = mgr.get_stats_summary() + + assert "2 cams" in summary + assert "30/36 frames" in summary + assert "writer 55.0 fps" in summary + assert "dropped 4" in summary + assert "queue 6/20" in summary + assert "backlog 6" in summary + finally: + mgr.stop_all() @pytest.mark.unit @@ -288,51 +346,58 @@ def test_recording_manager_uses_stable_camera_id_not_display_id( ): mgr = RecordingManager() - cam = CameraSettings( - name="GenTL cam", - backend="gentl", - index=0, - fps=30.0, - enabled=True, - properties={ - "gentl": { - "device_id": "serial:SER0", - "serial_number": "SER0", - } - }, - ).apply_defaults() - - stable_id = get_camera_id(cam) - display_id = get_display_id(cam) - - assert stable_id == "gentl:serial:SER0" - assert display_id == "gentl:0" - assert stable_id != display_id - - frame = np.zeros((480, 640, 3), dtype=np.uint8) - current_frames = {stable_id: frame} - - run_dir = mgr.start_all( - recording_settings, - [cam], - current_frames, - session_name="Sess", - ) + try: + cam = CameraSettings( + name="GenTL cam", + backend="gentl", + index=0, + fps=30.0, + enabled=True, + properties={ + "gentl": { + "device_id": "serial:SER0", + "serial_number": "SER0", + } + }, + ).apply_defaults() + + stable_id = get_camera_id(cam) + display_id = get_display_id(cam) + + assert stable_id == "gentl:serial:SER0" + assert display_id == "GenTL cam" + assert stable_id != display_id + + frame = np.zeros((480, 640, 3), dtype=np.uint8) + current_frames = {stable_id: frame} + + run_dir = mgr.start_all( + recording_settings, + [cam], + current_frames, + session_name="Sess", + ) - assert run_dir is not None - assert stable_id in mgr.recorders - assert display_id not in mgr.recorders + assert run_dir is not None + assert stable_id in mgr.recorders + assert display_id not in mgr.recorders - rec = mgr.recorders[stable_id] - assert rec.frame_size == (480, 640) + rec = mgr.recorders[stable_id] + assert rec.frame_size == (480, 640) - mgr.write_frame(stable_id, frame, timestamp=123.0) - assert len(rec.write_calls) == 1 - assert rec.write_calls[-1][1] == 123.0 + mgr.write_frame(stable_id, frame, timestamp=123.0) + assert mgr.flush(timeout=2.0) - # Display ID is GUI-only and must not route frames internally. - mgr.write_frame(display_id, frame, timestamp=456.0) - assert len(rec.write_calls) == 1 + assert len(rec.write_calls) == 1 + assert rec.write_calls[-1][1] == 123.0 + + # Display ID is GUI-only and must not route frames internally. + mgr.write_frame(display_id, frame, timestamp=456.0) + assert mgr.flush(timeout=2.0) + + assert len(rec.write_calls) == 1 + finally: + mgr.stop_all() @pytest.mark.unit @@ -343,38 +408,114 @@ def test_start_all_does_not_infer_frame_size_from_display_id( ): mgr = RecordingManager() - cam = CameraSettings( - name="GenTL cam", - backend="gentl", - index=0, - fps=30.0, - enabled=True, - properties={ - "gentl": { - "device_id": "serial:SER0", - "serial_number": "SER0", - } - }, - ).apply_defaults() - - stable_id = get_camera_id(cam) - display_id = get_display_id(cam) - - frame = np.zeros((480, 640, 3), dtype=np.uint8) - - # Simulate the buggy situation: frames are keyed by display ID. - current_frames = {display_id: frame} - - mgr.start_all( - recording_settings, - [cam], - current_frames, - session_name="Sess", - ) + try: + cam = CameraSettings( + name="GenTL cam", + backend="gentl", + index=0, + fps=30.0, + enabled=True, + properties={ + "gentl": { + "device_id": "serial:SER0", + "serial_number": "SER0", + } + }, + ).apply_defaults() + + stable_id = get_camera_id(cam) + display_id = get_display_id(cam) + + frame = np.zeros((480, 640, 3), dtype=np.uint8) + + # Simulate the buggy situation: frames are keyed by display ID. + current_frames = {display_id: frame} + + mgr.start_all( + recording_settings, + [cam], + current_frames, + session_name="Sess", + ) + + assert stable_id in mgr.recorders + assert display_id not in mgr.recorders + + # Since RecordingManager uses stable IDs internally, it should not find this frame. + rec = mgr.recorders[stable_id] + assert rec.frame_size is None + finally: + mgr.stop_all() - assert stable_id in mgr.recorders - assert display_id not in mgr.recorders - # Since RecordingManager uses stable IDs internally, it should not find this frame. - rec = mgr.recorders[stable_id] - assert rec.frame_size is None +@pytest.mark.unit +def test_start_all_passes_writegear_options( + recording_settings, + _active_cams_two, + current_frames, + patch_video_recorder, + patch_build_run_dir, +): + recording_settings.codec = "libx264" + recording_settings.crf = 23 + recording_settings.fast_encoding = True + + mgr = RecordingManager() + + try: + mgr.start_all(recording_settings, _active_cams_two, current_frames, session_name="Sess") + + for cam in _active_cams_two: + cam_id = get_camera_id(cam) + rec = mgr.recorders[cam_id] + + assert rec.writer_options is not None + assert rec.writer_options["-vcodec"] == "libx264" + assert rec.writer_options["-crf"] == "23" + assert rec.writer_options["-preset"] == "ultrafast" + assert rec.writer_options["-tune"] == "zerolatency" + finally: + mgr.stop_all() + + +class TestRecordingManagerTimestampMetadata: + @pytest.mark.unit + def test_write_frame_passes_timestamp_metadata( + self, + recording_settings, + _active_cams_two, + current_frames, + patch_video_recorder, + patch_build_run_dir, + ): + mgr = RecordingManager() + + try: + mgr.start_all(recording_settings, _active_cams_two, current_frames, session_name="Sess") + + cam0_id = get_camera_id(_active_cams_two[0]) + frame = current_frames[cam0_id] + + meta = FrameTimestampMetadata( + source="grab_result.GetTimeStamp", + backend="basler", + default_reported="seconds", + seconds=0.001, + raw_value=1_000_000, + raw_unit="ticks", + tick_frequency_hz=1_000_000_000.0, + kind="camera_clock", + ) + + mgr.write_frame(cam0_id, frame, timestamp=123.0, timestamp_metadata=meta) + assert mgr.flush(timeout=2.0) + + rec = mgr.recorders[cam0_id] + assert len(rec.write_calls) == 1 + + written_frame, written_timestamp, written_metadata = rec.write_calls[0] + assert written_frame is frame + assert written_timestamp == 123.0 + assert written_metadata is meta + finally: + mgr.stop_all() diff --git a/tests/gui/test_recording_gui.py b/tests/gui/test_recording_gui.py new file mode 100644 index 000000000..9ef4c4c32 --- /dev/null +++ b/tests/gui/test_recording_gui.py @@ -0,0 +1,173 @@ +import numpy as np +import pytest + +from dlclivegui.services.multi_camera_controller import MultiFrameData, get_camera_id + + +@pytest.mark.gui +class TestPendingRecordingAfterPreview: + def test_start_recording_when_preview_stopped_defers_until_preview_frames( + self, + window, + monkeypatch, + ): + calls = { + "start_preview": 0, + "start_recording": 0, + } + + monkeypatch.setattr( + window.multi_camera_controller, + "is_running", + lambda: False, + ) + + def fake_start_preview(): + calls["start_preview"] += 1 + + def fake_start_multi_camera_recording(): + calls["start_recording"] += 1 + + monkeypatch.setattr(window, "_start_preview", fake_start_preview) + monkeypatch.setattr(window, "_start_multi_camera_recording", fake_start_multi_camera_recording) + + window._pending_recording_after_preview = False + + window._start_recording() + + assert calls["start_preview"] == 1 + assert calls["start_recording"] == 0 + assert window._pending_recording_after_preview is True + + def test_pending_recording_waits_until_all_active_cameras_have_frames( + self, + window, + monkeypatch, + ): + active_cams = window._config.multi_camera.get_active_cameras() + assert len(active_cams) >= 2 + + cam0_id = get_camera_id(active_cams[0]) + cam1_id = get_camera_id(active_cams[1]) + + calls = { + "start_recording": 0, + } + + monkeypatch.setattr( + window.multi_camera_controller, + "is_running", + lambda: True, + ) + + def fake_start_multi_camera_recording(): + calls["start_recording"] += 1 + + monkeypatch.setattr(window, "_start_multi_camera_recording", fake_start_multi_camera_recording) + + window._pending_recording_after_preview = True + window._multi_camera_frames = { + cam0_id: np.zeros((10, 10, 3), dtype=np.uint8), + } + + window._try_start_pending_recording() + + assert calls["start_recording"] == 0 + assert window._pending_recording_after_preview is True + + window._multi_camera_frames[cam1_id] = np.zeros((10, 10, 3), dtype=np.uint8) + + window._try_start_pending_recording() + + assert calls["start_recording"] == 1 + assert window._pending_recording_after_preview is False + + def test_pending_recording_is_triggered_from_multi_frame_processing_ready( + self, + window, + monkeypatch, + ): + active_cams = window._config.multi_camera.get_active_cameras() + assert len(active_cams) >= 2 + + cam0_id = get_camera_id(active_cams[0]) + cam1_id = get_camera_id(active_cams[1]) + + calls = { + "start_recording": 0, + } + + monkeypatch.setattr( + window.multi_camera_controller, + "is_running", + lambda: True, + ) + + def fake_start_multi_camera_recording(): + calls["start_recording"] += 1 + + monkeypatch.setattr(window, "_start_multi_camera_recording", fake_start_multi_camera_recording) + + window._pending_recording_after_preview = True + + frame0 = np.zeros((10, 10, 3), dtype=np.uint8) + frame1 = np.zeros((10, 10, 3), dtype=np.uint8) + + frame_data = MultiFrameData( + frames={ + cam0_id: frame0, + cam1_id: frame1, + }, + timestamps={ + cam0_id: 1.0, + cam1_id: 1.0, + }, + source_camera_id=cam0_id, + display_ids={ + cam0_id: "Cam0", + cam1_id: "Cam1", + }, + ) + + window._on_multi_frame_processing_ready(frame_data) + + assert calls["start_recording"] == 1 + assert window._pending_recording_after_preview is False + + def test_pending_recording_does_not_start_twice( + self, + window, + monkeypatch, + ): + active_cams = window._config.multi_camera.get_active_cameras() + assert len(active_cams) >= 2 + + cam0_id = get_camera_id(active_cams[0]) + cam1_id = get_camera_id(active_cams[1]) + + calls = { + "start_recording": 0, + } + + monkeypatch.setattr( + window.multi_camera_controller, + "is_running", + lambda: True, + ) + + def fake_start_multi_camera_recording(): + calls["start_recording"] += 1 + + monkeypatch.setattr(window, "_start_multi_camera_recording", fake_start_multi_camera_recording) + + window._pending_recording_after_preview = True + window._multi_camera_frames = { + cam0_id: np.zeros((10, 10, 3), dtype=np.uint8), + cam1_id: np.zeros((10, 10, 3), dtype=np.uint8), + } + + window._try_start_pending_recording() + window._try_start_pending_recording() + + assert calls["start_recording"] == 1 + assert window._pending_recording_after_preview is False diff --git a/tests/services/test_camera_controller.py b/tests/services/test_camera_controller.py new file mode 100644 index 000000000..cc0194238 --- /dev/null +++ b/tests/services/test_camera_controller.py @@ -0,0 +1,307 @@ +from __future__ import annotations + +import numpy as np + +from dlclivegui.cameras.base import CapturedFrame +from dlclivegui.config import CameraSettings +from dlclivegui.services.camera_controller import SingleCameraWorker + + +def _capture_signals(worker: SingleCameraWorker) -> dict[str, list[tuple]]: + """Collect worker Qt signal emissions synchronously.""" + seen: dict[str, list[tuple]] = { + "runtime_info": [], + "started": [], + "frame_captured": [], + "error_occurred": [], + "stopped": [], + } + + worker.runtime_info.connect(lambda *args: seen["runtime_info"].append(args)) + worker.started.connect(lambda *args: seen["started"].append(args)) + worker.frame_captured.connect(lambda *args: seen["frame_captured"].append(args)) + worker.error_occurred.connect(lambda *args: seen["error_occurred"].append(args)) + worker.stopped.connect(lambda *args: seen["stopped"].append(args)) + + return seen + + +def test_worker_fake_backend(qtbot, patch_factory, camera_worker_settings: CameraSettings): + worker = SingleCameraWorker("fake:index:0", camera_worker_settings) + seen = _capture_signals(worker) + + # Stop after first frame so worker.run() returns synchronously. + worker.frame_captured.connect(lambda *_args: worker.stop()) + + worker.run() + + assert len(seen["error_occurred"]) == 0 + assert len(seen["runtime_info"]) == 1 + assert len(seen["started"]) == 1 + assert len(seen["frame_captured"]) == 1 + assert len(seen["stopped"]) == 1 + + runtime_camera_id, runtime = seen["runtime_info"][0] + assert runtime_camera_id == "fake:index:0" + assert set(runtime) == { + "actual_fps", + "actual_resolution", + "actual_pixel_format", + "actual_output_format", + } + + assert seen["started"][0] == ("fake:index:0",) + + frame_camera_id, frame, timestamp, timestamp_metadata = seen["frame_captured"][0] + assert frame_camera_id == "fake:index:0" + assert isinstance(frame, np.ndarray) + assert frame.shape == (48, 64, 3) + assert frame.dtype == np.uint8 + assert isinstance(timestamp, float) + assert timestamp_metadata is None + + assert seen["stopped"][0] == ("fake:index:0",) + + +def test_worker_recording_sink_receives_frame(qtbot, patch_factory, camera_worker_settings: CameraSettings): + worker = SingleCameraWorker("fake:index:0", camera_worker_settings) + seen = _capture_signals(worker) + + recorded: list[tuple] = [] + + def recording_sink(camera_id, frame, timestamp, timestamp_metadata): + recorded.append((camera_id, frame.copy(), timestamp, timestamp_metadata)) + + worker.set_recording_sink(recording_sink) + worker.set_recording_enabled(True) + + worker.frame_captured.connect(lambda *_args: worker.stop()) + + worker.run() + + assert len(seen["error_occurred"]) == 0 + assert len(seen["frame_captured"]) == 1 + assert len(recorded) == 1 + + rec_camera_id, rec_frame, rec_timestamp, rec_metadata = recorded[0] + frame_camera_id, emitted_frame, emitted_timestamp, emitted_metadata = seen["frame_captured"][0] + + assert rec_camera_id == "fake:index:0" + assert frame_camera_id == "fake:index:0" + + np.testing.assert_array_equal(rec_frame, emitted_frame) + assert rec_timestamp == emitted_timestamp + assert rec_metadata == emitted_metadata + + +def test_worker_recording_sink_disabled_does_not_receive_frame(qtbot, camera_worker_settings: CameraSettings): + worker = SingleCameraWorker("fake:index:0", camera_worker_settings) + + recorded: list[tuple] = [] + + def recording_sink(*args): + recorded.append(args) + + worker.set_recording_sink(recording_sink) + worker.set_recording_enabled(False) + + worker.frame_captured.connect(lambda *_args: worker.stop()) + + worker.run() + + assert recorded == [] + + +def test_worker_backend_creation_failure_emits_error(monkeypatch, qtbot, camera_worker_settings: CameraSettings): + from dlclivegui.services import camera_controller as controller_mod + + def fail_create(_settings): + raise RuntimeError("error") + + monkeypatch.setattr(controller_mod.CameraFactory, "create", staticmethod(fail_create)) + + worker = SingleCameraWorker("fake:index:0", camera_worker_settings) + seen = _capture_signals(worker) + + worker.run() + + assert len(seen["runtime_info"]) == 0 + assert len(seen["started"]) == 0 + assert len(seen["frame_captured"]) == 0 + + assert len(seen["error_occurred"]) == 1 + camera_id, message = seen["error_occurred"][0] + assert camera_id == "fake:index:0" + assert "Failed to initialize camera" in message + assert "error" in message + + assert seen["stopped"] == [("fake:index:0",)] + + +class _EmptyFrameBackend: + def __init__(self, settings: CameraSettings): + self.settings = settings + self.open_called = False + self.close_called = False + + def open(self): + self.open_called = True + + def read(self): + return CapturedFrame(frame=None, software_timestamp=123.0, timestamp_metadata=None) + + def close(self): + self.close_called = True + + +def test_worker_too_many_empty_frames_emits_error(monkeypatch, qtbot, camera_worker_settings: CameraSettings): + from dlclivegui.services import camera_controller as controller_mod + + backend = _EmptyFrameBackend(camera_worker_settings) + + monkeypatch.setattr( + controller_mod.CameraFactory, + "create", + staticmethod(lambda _settings: backend), + ) + + worker = SingleCameraWorker("fake:index:0", camera_worker_settings) + worker._max_consecutive_errors = 3 + worker._retry_delay = 0.0 + + seen = _capture_signals(worker) + + worker.run() + + assert backend.open_called + assert backend.close_called + + assert seen["started"] == [("fake:index:0",)] + assert len(seen["frame_captured"]) == 0 + + assert len(seen["error_occurred"]) == 1 + camera_id, message = seen["error_occurred"][0] + assert camera_id == "fake:index:0" + assert "Too many empty frames" in message + + assert seen["stopped"] == [("fake:index:0",)] + + +class _ReadExceptionBackend: + waits_for_hardware_trigger = False + + def __init__(self, settings: CameraSettings): + self.settings = settings + self.open_called = False + self.close_called = False + self.read_count = 0 + + def open(self): + self.open_called = True + + def read(self): + self.read_count += 1 + raise RuntimeError(f"read failed {self.read_count}") + + def close(self): + self.close_called = True + + +def test_worker_read_exception_emits_error_after_retries( + monkeypatch, + qtbot, + camera_worker_settings: CameraSettings, +): + from dlclivegui.services import camera_controller as controller_mod + + backend = _ReadExceptionBackend(camera_worker_settings) + + monkeypatch.setattr( + controller_mod.CameraFactory, + "create", + staticmethod(lambda _settings: backend), + ) + + worker = SingleCameraWorker("fake:index:0", camera_worker_settings) + worker._max_consecutive_errors = 3 + worker._retry_delay = 0.0 + + seen = _capture_signals(worker) + + worker.run() + + assert backend.open_called + assert backend.close_called + assert backend.read_count == 3 + + assert seen["started"] == [("fake:index:0",)] + assert len(seen["frame_captured"]) == 0 + + assert len(seen["error_occurred"]) == 1 + camera_id, message = seen["error_occurred"][0] + assert camera_id == "fake:index:0" + assert "Camera read error" in message + assert "read failed 3" in message + + assert seen["stopped"] == [("fake:index:0",)] + + +class _TimeoutTriggerBackend: + waits_for_hardware_trigger = True + + def __init__(self, settings: CameraSettings): + self.settings = settings + self.open_called = False + self.close_called = False + self.read_count = 0 + + def open(self): + self.open_called = True + + def read(self): + self.read_count += 1 + raise TimeoutError("waiting for trigger") + + def close(self): + self.close_called = True + + +def test_worker_hardware_trigger_timeouts_do_not_emit_error( + monkeypatch, + qtbot, + camera_worker_settings: CameraSettings, +): + from dlclivegui.services import camera_controller as controller_mod + + backend = _TimeoutTriggerBackend(camera_worker_settings) + + monkeypatch.setattr( + controller_mod.CameraFactory, + "create", + staticmethod(lambda _settings: backend), + ) + + worker = SingleCameraWorker("fake:index:0", camera_worker_settings) + worker._trigger_timeout_delay = 0.0 + + seen = _capture_signals(worker) + + original_read = backend.read + + def read_then_stop(): + if backend.read_count >= 3: + worker.stop() + return original_read() + + backend.read = read_then_stop + + worker.run() + + assert backend.open_called + assert backend.close_called + assert backend.read_count >= 3 + + assert seen["started"] == [("fake:index:0",)] + assert seen["error_occurred"] == [] + assert seen["frame_captured"] == [] + assert seen["stopped"] == [("fake:index:0",)] diff --git a/tests/services/test_dlc_processor.py b/tests/services/test_dlc_processor.py index 3f5e0cbc6..a58490e3e 100644 --- a/tests/services/test_dlc_processor.py +++ b/tests/services/test_dlc_processor.py @@ -1,13 +1,25 @@ +from __future__ import annotations + +import queue + import numpy as np import pytest -# from dlclivegui.config import DLCProcessorSettings from dlclivegui.config import DLCProcessorSettings + +# from dlclivegui.config import DLCProcessorSettings from dlclivegui.services.dlc_processor import ( DLCLiveProcessor, ProcessorStats, + WorkerState, ) + +class _AliveThread: + def is_alive(self) -> bool: + return True + + # --------------------------------------------------------------------- # Tests # --------------------------------------------------------------------- @@ -73,28 +85,36 @@ def test_worker_processes_frames(qtbot, monkeypatch_dlclive, settings_model): proc.reset() -@pytest.mark.unit -def test_queue_full_drops_frames(qtbot, monkeypatch_dlclive, settings_model): +def test_enqueue_frame_drops_stale_when_queue_is_full(settings_model): proc = DLCLiveProcessor() proc.configure(settings_model) try: - frame = np.zeros((32, 32, 3), dtype=np.uint8) + proc._queue = queue.Queue(maxsize=1) + proc._worker_thread = _AliveThread() + proc._state = WorkerState.RUNNING + proc._stop_event.clear() - # Start the worker with the first frame - with qtbot.waitSignal(proc.initialized, timeout=1500): - proc.enqueue_frame(frame, 1.0) + frame1 = np.zeros((32, 32, 3), dtype=np.uint8) + frame2 = np.ones((32, 32, 3), dtype=np.uint8) - # Flood the 1-slot queue to force drops - for _ in range(50): - proc.enqueue_frame(frame, 2.0) + proc.enqueue_frame(frame1, 1.0) + proc.enqueue_frame(frame2, 2.0) - # Wait until we observe dropped frames - qtbot.waitUntil(lambda: proc._frames_dropped > 0, timeout=1500) - assert proc._frames_dropped > 0 + stats = proc.get_stats() + assert stats.frames_enqueued == 2 + assert stats.frames_dropped == 1 + assert stats.queue_size == 1 + + queued_frame, queued_timestamp, _queued_at = proc._queue.get_nowait() + assert queued_timestamp == 2.0 + np.testing.assert_array_equal(queued_frame, frame2) finally: - proc.reset() + proc._queue = None + proc._worker_thread = None + proc._state = WorkerState.STOPPED + proc._stop_event.clear() @pytest.mark.unit diff --git a/tests/services/test_multicam_controller.py b/tests/services/test_multicam_controller.py index 7c9e1f051..1f8d0f17a 100644 --- a/tests/services/test_multicam_controller.py +++ b/tests/services/test_multicam_controller.py @@ -1,11 +1,19 @@ # tests/services/test_multicam_controller.py +import numpy as np import pytest from dlclivegui.cameras.factory import CameraFactory # from dlclivegui.config import CameraSettings from dlclivegui.config import CameraSettings -from dlclivegui.services.multi_camera_controller import MultiCameraController, get_camera_id, get_display_id +from dlclivegui.services.multi_camera_controller import ( + MultiCameraController, + _camera_start_priority, + _trigger_role_from_settings, + get_camera_id, + get_display_id, +) +from dlclivegui.utils.timestamps import FrameTimestampMetadata @pytest.mark.unit @@ -115,6 +123,217 @@ def _create(_settings): mc.start([cam]) +@pytest.mark.unit +def test_get_camera_id_prefers_stable_device_id(): + cam = CameraSettings( + name="GenTL Cam", + backend="gentl", + index=0, + properties={ + "gentl": { + "device_id": "serial:30220469", + "serial_number": "30220469", + } + }, + ).apply_defaults() + + assert get_camera_id(cam) == "gentl:serial:30220469" + + +@pytest.mark.unit +def test_get_camera_id_falls_back_to_index_without_stable_identity(): + cam = CameraSettings( + name="Cam", + backend="opencv", + index=2, + ).apply_defaults() + + assert get_camera_id(cam) == "opencv:index:2" + + +@pytest.mark.unit +def test_get_display_id_is_human_index_label(): + cam = CameraSettings( + name="GenTL Cam", + backend="gentl", + index=3, + properties={ + "gentl": { + "device_id": "serial:30220469", + "serial_number": "30220469", + } + }, + ).apply_defaults() + + assert get_camera_id(cam) == "gentl:serial:30220469" + assert get_display_id(cam) == "GenTL Cam" + assert get_camera_id(cam) != get_display_id(cam) + + +@pytest.mark.unit +@pytest.mark.parametrize( + ("role", "expected"), + [ + ("off", "off"), + ("disabled", "off"), + ("on", "external"), + ("triggered", "external"), + ("external", "external"), + ("follower", "follower"), + ("slave", "follower"), + ("master", "master"), + ("main", "master"), + ], +) +def test_trigger_role_from_settings_aliases(role, expected): + cam = CameraSettings( + name="C", + backend="gentl", + index=0, + properties={ + "gentl": { + "trigger": { + "role": role, + } + } + }, + ).apply_defaults() + + assert _trigger_role_from_settings(cam) == expected + + +@pytest.mark.unit +def test_get_display_id_falls_back_to_backend_index_without_name(): + cam = CameraSettings( + name="", + backend="gentl", + index=3, + properties={ + "gentl": { + "device_id": "serial:30220469", + "serial_number": "30220469", + } + }, + ).apply_defaults() + + assert get_display_id(cam) == "gentl:3" + + +@pytest.mark.unit +def test_camera_start_priority_orders_trigger_roles(): + external = CameraSettings( + name="External", + backend="gentl", + index=0, + properties={"gentl": {"trigger": {"role": "external"}}}, + ).apply_defaults() + + normal = CameraSettings( + name="Normal", + backend="gentl", + index=1, + properties={"gentl": {"trigger": {"role": "off"}}}, + ).apply_defaults() + + master = CameraSettings( + name="Master", + backend="gentl", + index=2, + properties={"gentl": {"trigger": {"role": "master"}}}, + ).apply_defaults() + + assert _camera_start_priority(external) == 0 + assert _camera_start_priority(normal) == 1 + assert _camera_start_priority(master) == 2 + + +@pytest.mark.unit +def test_start_preserves_user_display_order_even_when_trigger_start_order_differs(qtbot, patch_factory): + mc = MultiCameraController() + + # User wants master first in tiled view, follower second. + # Startup order should still be follower first internally. + master = CameraSettings( + name="Master", + backend="opencv", + index=0, + enabled=True, + properties={ + "opencv": { + "device_id": "master-cam", + "trigger": {"role": "master"}, + } + }, + ).apply_defaults() + + follower = CameraSettings( + name="Follower", + backend="opencv", + index=1, + enabled=True, + properties={ + "opencv": { + "device_id": "follower-cam", + "trigger": {"role": "follower"}, + } + }, + ).apply_defaults() + + expected_display_order = [get_camera_id(master), get_camera_id(follower)] + + try: + with qtbot.waitSignal(mc.all_started, timeout=1500): + mc.start([master, follower]) + + assert mc._camera_display_order == expected_display_order + + finally: + with qtbot.waitSignal(mc.all_stopped, timeout=2000): + mc.stop(wait=True) + + +@pytest.mark.unit +def test_frame_ready_emits_frames_in_user_configured_order(qtbot, patch_factory): + mc = MultiCameraController() + + cam_a = CameraSettings( + name="A", + backend="opencv", + index=0, + enabled=True, + properties={"opencv": {"device_id": "cam-a"}}, + ).apply_defaults() + + cam_b = CameraSettings( + name="B", + backend="opencv", + index=1, + enabled=True, + properties={"opencv": {"device_id": "cam-b"}}, + ).apply_defaults() + + expected_order = [get_camera_id(cam_a), get_camera_id(cam_b)] + seen_orders: list[list[str]] = [] + + def on_ready(mfd): + if len(mfd.frames) >= 2: + seen_orders.append(list(mfd.frames.keys())) + + mc.frame_ready.connect(on_ready) + + try: + with qtbot.waitSignal(mc.all_started, timeout=1500): + mc.start([cam_a, cam_b]) + + qtbot.waitUntil(lambda: bool(seen_orders), timeout=2500) + + assert seen_orders[-1] == expected_order + + finally: + with qtbot.waitSignal(mc.all_stopped, timeout=2000): + mc.stop(wait=True) + + @pytest.mark.unit def test_controller_uses_stable_camera_id_not_display_id(qtbot, patch_factory): mc = MultiCameraController() @@ -137,15 +356,11 @@ def test_controller_uses_stable_camera_id_not_display_id(qtbot, patch_factory): display_id = get_display_id(cam) assert stable_id == "gentl:serial:SER0" - assert display_id == "gentl:0" + assert display_id == "C1" assert stable_id != display_id seen = [] - - def on_ready(mfd): - seen.append(mfd) - - mc.frame_ready.connect(on_ready) + mc.frame_ready.connect(seen.append) try: with qtbot.waitSignal(mc.all_started, timeout=1500): @@ -158,7 +373,6 @@ def on_ready(mfd): assert mfd.source_camera_id == stable_id assert stable_id in mfd.frames assert stable_id in mfd.timestamps - assert display_id not in mfd.frames assert display_id not in mfd.timestamps @@ -168,3 +382,244 @@ def on_ready(mfd): finally: with qtbot.waitSignal(mc.all_stopped, timeout=2000): mc.stop(wait=True) + + +@pytest.mark.unit +def test_display_order_is_cleared_on_stop(qtbot, patch_factory): + mc = MultiCameraController() + + cam = CameraSettings( + name="C", + backend="opencv", + index=0, + enabled=True, + properties={"opencv": {"device_id": "cam-0"}}, + ).apply_defaults() + + try: + with qtbot.waitSignal(mc.all_started, timeout=1500): + mc.start([cam]) + + assert mc._camera_display_order == [get_camera_id(cam)] + + finally: + with qtbot.waitSignal(mc.all_stopped, timeout=2000): + mc.stop(wait=True) + + assert mc._camera_display_order == [] + + +@pytest.mark.unit +def test_hardware_trigger_timeouts_are_not_fatal(qtbot, monkeypatch): + class WaitingTriggerBackend: + waits_for_hardware_trigger = True + + def __init__(self, settings): + self.settings = settings + self.opened = False + self.closed = False + + def open(self): + self.opened = True + + def read(self): + raise TimeoutError("waiting for hardware trigger") + + def close(self): + self.closed = True + + def _create(settings): + return WaitingTriggerBackend(settings) + + monkeypatch.setattr(CameraFactory, "create", staticmethod(_create)) + + mc = MultiCameraController() + cam = CameraSettings( + name="Triggered", + backend="gentl", + index=0, + enabled=True, + properties={ + "gentl": { + "device_id": "serial:30220469", + "trigger": {"role": "external", "timeout": 0.1}, + } + }, + ).apply_defaults() + + errors: list[tuple[str, str]] = [] + mc.camera_error.connect(lambda cam_id, msg: errors.append((cam_id, msg))) + + try: + with qtbot.waitSignal(mc.all_started, timeout=1500): + mc.start([cam]) + + # Let several timeout cycles happen. + qtbot.wait(500) + + assert mc.is_running() + assert errors == [] + + finally: + with qtbot.waitSignal(mc.all_stopped, timeout=2000): + mc.stop(wait=True) + + +@pytest.mark.unit +def test_non_trigger_timeouts_are_fatal_after_retries(qtbot, monkeypatch): + class TimeoutBackend: + waits_for_hardware_trigger = False + + def __init__(self, settings): + self.settings = settings + + def open(self): + pass + + def read(self): + raise TimeoutError("camera timeout") + + def close(self): + pass + + def _create(settings): + return TimeoutBackend(settings) + + monkeypatch.setattr(CameraFactory, "create", staticmethod(_create)) + + mc = MultiCameraController() + cam = CameraSettings(name="TimeoutCam", backend="opencv", index=0, enabled=True).apply_defaults() + + with qtbot.waitSignal(mc.camera_error, timeout=3000) as blocker: + mc.start([cam]) + + cam_id, msg = blocker.args + assert cam_id == get_camera_id(cam) + assert "Camera read timeout" in msg + + # Cleanup if still running. + if mc.is_running(): + with qtbot.waitSignal(mc.all_stopped, timeout=2000): + mc.stop(wait=True) + + +@pytest.mark.unit +def test_recording_sink_receives_frames_when_enabled(qtbot, patch_factory): + mc = MultiCameraController() + + cam = CameraSettings( + name="C", + backend="opencv", + index=0, + enabled=True, + properties={"opencv": {"device_id": "cam-0"}}, + ).apply_defaults() + + cam_id = get_camera_id(cam) + seen: list[tuple[str, tuple, float, object]] = [] + + def sink(camera_id, frame, timestamp, timestamp_metadata=None): + seen.append((camera_id, frame.shape, timestamp, timestamp_metadata)) + + try: + with qtbot.waitSignal(mc.all_started, timeout=1500): + mc.start([cam]) + + # Disabled by default. + qtbot.wait(300) + assert seen == [] + + mc.set_recording_sink(sink) + mc.set_recording_frame_do_emit(True) + + qtbot.waitUntil(lambda: bool(seen), timeout=2000) + + camera_id, shape, timestamp, timestamp_metadata = seen[-1] + assert camera_id == cam_id + assert isinstance(timestamp, float) + assert len(shape) in (2, 3) + + mc.set_recording_frame_do_emit(False) + count_after_disable = len(seen) + + qtbot.wait(300) + assert len(seen) == count_after_disable + + finally: + with qtbot.waitSignal(mc.all_stopped, timeout=2000): + mc.stop(wait=True) + + +@pytest.mark.unit +def test_recording_sink_forwards_timestamp_metadata(qtbot, monkeypatch): + from dlclivegui.cameras.base import CapturedFrame + from dlclivegui.cameras.factory import CameraFactory + + meta = FrameTimestampMetadata( + source="grab_result.GetTimeStamp", + backend="basler", + default_reported="seconds", + seconds=0.001, + raw_value=1_000_000, + raw_unit="ticks", + tick_frequency_hz=1_000_000_000.0, + kind="camera_clock", + ) + + class TimestampBackend: + waits_for_hardware_trigger = False + + def __init__(self, settings): + self.settings = settings + self._count = 0 + + def open(self): + pass + + def read(self): + self._count += 1 + return CapturedFrame( + frame=np.zeros((10, 10), dtype=np.uint8), + software_timestamp=123.0 + self._count, + timestamp_metadata=meta, + ) + + def close(self): + pass + + monkeypatch.setattr(CameraFactory, "create", staticmethod(lambda settings: TimestampBackend(settings))) + + mc = MultiCameraController() + cam = CameraSettings( + name="C", + backend="basler", + index=0, + enabled=True, + properties={"basler": {"device_id": "0815-0000"}}, + ).apply_defaults() + + cam_id = get_camera_id(cam) + seen = [] + + def sink(camera_id, frame, timestamp, timestamp_metadata=None): + seen.append((camera_id, frame, timestamp, timestamp_metadata)) + + try: + with qtbot.waitSignal(mc.all_started, timeout=1500): + mc.start([cam]) + + # Recording is disabled by start(); enable the new sink path after cameras are running. + mc.set_recording_sink(sink) + mc.set_recording_frame_do_emit(True) + + qtbot.waitUntil(lambda: bool(seen), timeout=2000) + + camera_id, frame, timestamp, timestamp_metadata = seen[-1] + assert camera_id == cam_id + assert frame.shape == (10, 10) + assert isinstance(timestamp, float) + assert timestamp_metadata is meta + + finally: + with qtbot.waitSignal(mc.all_stopped, timeout=2000): + mc.stop(wait=True) diff --git a/tests/services/test_video_recorder.py b/tests/services/test_video_recorder.py index 28bb85646..8389fbbb4 100644 --- a/tests/services/test_video_recorder.py +++ b/tests/services/test_video_recorder.py @@ -9,6 +9,7 @@ import pytest import dlclivegui.services.video_recorder as vr_mod +from dlclivegui.utils.timestamps import FrameTimestampMetadata # ---------------------------- # Helpers @@ -228,10 +229,13 @@ def test_stop_writes_timestamps_sidecar_json(patch_writegear, output_path, rgb_f data = json.loads(ts_path.read_text()) assert data["video_file"] == output_path.name assert data["num_frames"] == 2 - assert data["timestamps"] == [10.0, 12.0] assert data["start_time"] == 10.0 assert data["end_time"] == 12.0 assert data["duration_seconds"] == 2.0 + assert data["schema_version"] == 2 + assert data["timestamp_sources"]["hardware_timestamp"] is None + assert data["frame_timestamps"][0]["software_timestamp"] == 10.0 + assert data["frame_timestamps"][1]["software_timestamp"] == 12.0 def test_encoder_write_error_sets_encode_error_and_future_writes_raise(patch_writegear, output_path, rgb_frame): @@ -372,3 +376,155 @@ def test_stop_timeout_marks_abandoned_and_prevents_restart( assert rec._abandoned is False rec.start() rec.stop() + + +def test_video_recorder_preserves_gray_when_requested(monkeypatch, tmp_path): + written = [] + + class FakeWriter: + def write(self, frame): + written.append(frame) + + def close(self): + pass + + monkeypatch.setattr("dlclivegui.services.video_recorder.WriteGear", lambda *a, **k: FakeWriter()) + + rec = vr_mod.VideoRecorder( + tmp_path / "out.mp4", + frame_size=(10, 20), + frame_rate=100, + convert_grayscale_to_rgb=False, + ) + rec.start() + rec.write(np.zeros((10, 20), dtype=np.uint8)) + rec.stop() + + assert written + assert written[0].shape == (10, 20) + + +def test_video_recorder_expands_gray_by_default(monkeypatch, tmp_path): + written = [] + + class FakeWriter: + def write(self, frame): + written.append(frame) + + def close(self): + pass + + monkeypatch.setattr("dlclivegui.services.video_recorder.WriteGear", lambda *a, **k: FakeWriter()) + + rec = vr_mod.VideoRecorder(tmp_path / "out.mp4", frame_size=(10, 20), frame_rate=100) + rec.start() + rec.write(np.zeros((10, 20), dtype=np.uint8)) + rec.stop() + + assert written[0].shape == (10, 20, 3) + + +class TestVideoRecorderTimestampSidecar: + def test_stop_writes_software_only_timestamp_sidecar_json( + self, + patch_writegear, + output_path, + rgb_frame, + ): + rec = vr_mod.VideoRecorder(output_path, buffer_size=10) + rec.start() + + rec.write(rgb_frame, timestamp=10.0) + rec.write(rgb_frame, timestamp=12.0) + + wait_until(lambda: len(FakeWriteGear.instances[0].frames) >= 2) + rec.stop() + + ts_path = output_path.with_suffix("").with_suffix(output_path.suffix + "_timestamps.json") + assert ts_path.exists() + + data = json.loads(ts_path.read_text()) + + assert data["schema_version"] == 2 + assert data["video_file"] == output_path.name + assert data["num_frames"] == 2 + + assert data["timestamp_sources"]["software_timestamp"]["kind"] == "software_wall_clock" + assert data["timestamp_sources"]["hardware_timestamp"] is None + + assert data["frame_timestamps"] == [ + { + "frame_index": 0, + "software_timestamp": 10.0, + }, + { + "frame_index": 1, + "software_timestamp": 12.0, + }, + ] + + def test_stop_writes_hardware_timestamp_metadata_sidecar_json( + self, + patch_writegear, + output_path, + rgb_frame, + ): + rec = vr_mod.VideoRecorder(output_path, buffer_size=10) + rec.start() + + meta = FrameTimestampMetadata( + source="grab_result.GetTimeStamp", + backend="basler", + default_reported="seconds", + seconds=0.001, + raw_value=1_000_000, + raw_unit="ticks", + tick_frequency_hz=1_000_000_000.0, + timebase="Basler camera timestamp counter", + kind="camera_clock", + ) + + rec.write(rgb_frame, timestamp=10.0, timestamp_metadata=meta) + + wait_until(lambda: len(FakeWriteGear.instances[0].frames) >= 1) + rec.stop() + + ts_path = output_path.with_suffix("").with_suffix(output_path.suffix + "_timestamps.json") + assert ts_path.exists() + + data = json.loads(ts_path.read_text()) + + assert data["schema_version"] == 2 + assert data["video_file"] == output_path.name + assert data["num_frames"] == 1 + + # Backward-compatible software timestamp list. + assert data["start_time"] == 10.0 + assert data["end_time"] == 10.0 + assert data["duration_seconds"] == 0.0 + + # Static hardware source metadata is written once. + hw_source = data["timestamp_sources"]["hardware_timestamp"] + assert hw_source == { + "source": "grab_result.GetTimeStamp", + "backend": "basler", + "default_reported": "seconds", + "raw_unit": "ticks", + "tick_frequency_hz": 1_000_000_000.0, + "timebase": "Basler camera timestamp counter", + "kind": "camera_clock", + "extra": {}, + } + + # Per-frame records contain only per-frame values. + frame_ts = data["frame_timestamps"] + assert len(frame_ts) == 1 + + rec0 = frame_ts[0] + assert rec0["frame_index"] == 0 + assert rec0["software_timestamp"] == 10.0 + assert rec0["hardware_timestamp"] == { + "seconds": 0.001, + "raw_value": 1_000_000, + } + assert rec0["hardware_timestamp_default"] == 0.001 diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 000000000..9cb00e776 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,91 @@ +import pytest + +from dlclivegui.config import ( + ApplicationSettings, + CameraSettings, + CameraTriggerSettings, + MultiCameraSettings, + RecordingSettings, +) + + +@pytest.mark.unit +def test_save_applies_gentl_trigger_defaults_to_top_level_camera(): + cam = CameraSettings( + backend="gentl", + properties={"gentl": {}}, + ) + + settings = ApplicationSettings( + camera=cam, + multi_camera=MultiCameraSettings(cameras=[cam]), + ) + + data = settings.to_dict() + + assert "trigger" in data["camera"]["properties"]["gentl"] + + +@pytest.mark.unit +def test_save_applies_gentl_trigger_defaults_to_multi_camera(): + cam = CameraSettings( + backend="gentl", + properties={"gentl": {}}, + ) + + settings = ApplicationSettings( + multi_camera=MultiCameraSettings(cameras=[cam]), + ) + + data = settings.to_dict() + + assert "trigger" in data["multi_camera"]["cameras"][0]["properties"]["gentl"] + + +@pytest.mark.unit +def test_trigger_source_defaults_to_auto(): + trigger = CameraTriggerSettings() + + assert trigger.source == "auto" + + +def test_recording_settings_writegear_options_default(): + settings = RecordingSettings(codec="libx264", crf=23, fast_encoding=False) + + opts = settings.writegear_options(100.0) + + assert opts["-input_framerate"] == 100.0 + assert opts["-vcodec"] == "libx264" + assert opts["-crf"] == "23" + assert "-preset" not in opts + assert "-tune" not in opts + + +def test_recording_settings_writegear_options_fast_encoding_x264(): + settings = RecordingSettings(codec="libx264", crf=23, fast_encoding=True) + + opts = settings.writegear_options(100.0) + + assert opts["-input_framerate"] == 100.0 + assert opts["-vcodec"] == "libx264" + assert opts["-crf"] == "23" + assert opts["-preset"] == "ultrafast" + assert opts["-tune"] == "zerolatency" + + +def test_recording_settings_writegear_options_fast_encoding_nvenc_no_x264_options(): + settings = RecordingSettings(codec="h264_nvenc", crf=23, fast_encoding=True) + + opts = settings.writegear_options(100.0) + + assert opts["-vcodec"] == "h264_nvenc" + assert "-preset" not in opts + assert "-tune" not in opts + + +def test_recording_settings_writegear_options_invalid_fps_falls_back_to_30(): + settings = RecordingSettings(codec="libx264", crf=23) + + opts = settings.writegear_options(None) + + assert opts["-input_framerate"] == 30.0 diff --git a/tests/utils/test_display.py b/tests/utils/test_display.py index 9ce8d49e7..559aa1522 100644 --- a/tests/utils/test_display.py +++ b/tests/utils/test_display.py @@ -41,7 +41,9 @@ def test_compute_tiling_geometry_single_frame_respects_max_canvas_and_min_tile() def test_compute_tiling_geometry_two_frames_is_1x2(): frames = {"camB": _frame(480, 640, 3), "camA": _frame(480, 640, 3)} cam_ids, rows, cols, tile_w, tile_h = compute_tiling_geometry(frames, max_canvas=(1200, 800)) - assert cam_ids == ["camA", "camB"] # sorted + + # Preserve insertion/display order, do not sort by camera ID. + assert cam_ids == ["camB", "camA"] assert (rows, cols) == (1, 2) assert tile_w >= 160 and tile_h >= 120 @@ -49,25 +51,106 @@ def test_compute_tiling_geometry_two_frames_is_1x2(): def test_compute_tiling_geometry_three_frames_is_2x2(): frames = {"c3": _frame(480, 640, 3), "c1": _frame(480, 640, 3), "c2": _frame(480, 640, 3)} cam_ids, rows, cols, tile_w, tile_h = compute_tiling_geometry(frames, max_canvas=(1200, 800)) - assert cam_ids == ["c1", "c2", "c3"] + + # Preserve insertion/display order. + assert cam_ids == ["c3", "c1", "c2"] assert (rows, cols) == (2, 2) assert tile_w >= 160 and tile_h >= 120 -def test_compute_tiling_geometry_reference_aspect_is_first_sorted_cam(): - # camA has aspect 2.0 (w/h), camB has aspect 0.5 +def test_compute_tiling_geometry_reference_aspect_is_first_display_order_cam(): + # camB is first in insertion/display order and has aspect 0.5. + # camA has aspect 2.0. frames = { - "camB": _frame(400, 200, 3), - "camA": _frame(200, 400, 3), + "camB": _frame(400, 200, 3), # aspect = 200 / 400 = 0.5 + "camA": _frame(200, 400, 3), # aspect = 400 / 200 = 2.0 } + cam_ids, rows, cols, tile_w, tile_h = compute_tiling_geometry(frames, max_canvas=(1200, 800)) - assert cam_ids == ["camA", "camB"] + + assert cam_ids == ["camB", "camA"] # For 2 cams, rows=1 cols=2 => initial tile_w=600 tile_h=800 => tile_aspect=0.75 - # frame_aspect for camA = 400/200 = 2.0 > 0.75 => tile_h adjusted to tile_w/frame_aspect = 600/2 = 300 + # frame_aspect for camB = 0.5 <= 0.75 => tile_w adjusted to tile_h * frame_aspect = 800 * 0.5 = 400 + assert (rows, cols) == (1, 2) + assert tile_w == 400 + assert tile_h == 800 + + +def test_compute_tiling_geometry_preserves_frame_insertion_order(): + frames = { + "gentl:serial:30220469": np.zeros((10, 20, 3), dtype=np.uint8), + "gentl:serial:10620051": np.zeros((10, 20, 3), dtype=np.uint8), + } + + cam_ids, rows, cols, tile_w, tile_h = compute_tiling_geometry(frames) + + assert cam_ids == ["gentl:serial:30220469", "gentl:serial:10620051"] + assert rows == 1 + assert cols == 2 + assert tile_w > 0 + assert tile_h > 0 + + +def test_compute_tiling_geometry_preserves_reversed_insertion_order(): + frames = { + "gentl:serial:10620051": np.zeros((10, 20, 3), dtype=np.uint8), + "gentl:serial:30220469": np.zeros((10, 20, 3), dtype=np.uint8), + } + + cam_ids, *_ = compute_tiling_geometry(frames) + + assert cam_ids == ["gentl:serial:10620051", "gentl:serial:30220469"] + + +def test_compute_tile_info_uses_display_order_for_offsets(): + cam0 = "gentl:serial:30220469" + cam1 = "gentl:serial:10620051" + + frames = { + cam0: np.zeros((100, 200, 3), dtype=np.uint8), + cam1: np.zeros((100, 200, 3), dtype=np.uint8), + } + + cam_ids, rows, cols, tile_w, tile_h = compute_tiling_geometry(frames) + + offset0, scale0 = compute_tile_info(cam0, frames[cam0], frames) + offset1, scale1 = compute_tile_info(cam1, frames[cam1], frames) + + assert cam_ids == [cam0, cam1] + assert offset0 == (0, 0) + assert offset1 == (tile_w, 0) + assert scale0[0] > 0 + assert scale0[1] > 0 + assert scale1[0] > 0 + assert scale1[1] > 0 + + +def test_create_tiled_frame_preserves_display_order_by_tile_content(): + # First frame is blue-ish, second is red-ish. + first = np.zeros((100, 100, 3), dtype=np.uint8) + first[:, :] = (255, 0, 0) # BGR blue + + second = np.zeros((100, 100, 3), dtype=np.uint8) + second[:, :] = (0, 0, 255) # BGR red + + frames = { + "gentl:serial:30220469": first, + "gentl:serial:10620051": second, + } + + cam_ids, rows, cols, tile_w, tile_h = compute_tiling_geometry(frames, max_canvas=(400, 200)) + out = create_tiled_frame(frames, max_canvas=(400, 200)) + + assert cam_ids == ["gentl:serial:30220469", "gentl:serial:10620051"] assert (rows, cols) == (1, 2) - assert tile_w == 600 - assert tile_h == 300 + + # Sample away from text label area. + left_sample = out[tile_h // 2, tile_w // 2] + right_sample = out[tile_h // 2, tile_w + tile_w // 2] + + assert left_sample[0] > left_sample[2] # blue tile first + assert right_sample[2] > right_sample[0] # red tile second def test_create_tiled_frame_empty_returns_default_canvas(): @@ -110,16 +193,18 @@ def test_create_tiled_frame_canvas_shape_matches_geometry(): def test_compute_tile_info_offset_and_scale_matches_tiling(): - # 2 frames => 1x2 tiling, cam ids sorted: ["cam1", "cam2"] + # 2 frames => 1x2 tiling, preserving insertion/display order: ["cam2", "cam1"] frames = {"cam2": _frame(200, 400, 3), "cam1": _frame(200, 400, 3)} cam_ids, rows, cols, tile_w, tile_h = compute_tiling_geometry(frames, max_canvas=(1200, 800)) original = _frame(200, 400, 3) (ox, oy), (sx, sy) = compute_tile_info("cam2", original, frames, max_canvas=(1200, 800)) - # cam2 is index 1 -> row 0 col 1 + assert cam_ids == ["cam2", "cam1"] assert (rows, cols) == (1, 2) - assert ox == tile_w + + # cam2 is first in display order => row 0 col 0 + assert ox == 0 assert oy == 0 assert sx == pytest.approx(tile_w / 400) assert sy == pytest.approx(tile_h / 200) diff --git a/tests/utils/test_settings_store.py b/tests/utils/test_settings_store.py index 7eba56aef..702382429 100644 --- a/tests/utils/test_settings_store.py +++ b/tests/utils/test_settings_store.py @@ -11,10 +11,11 @@ class InMemoryQSettings: - """Stand-in for QSettings""" + """Small stand-in for QSettings.""" def __init__(self): self._d = {} + self.synced = False def value(self, key: str, default=None): return self._d.get(key, default) @@ -22,6 +23,12 @@ def value(self, key: str, default=None): def setValue(self, key: str, value): self._d[key] = value + def remove(self, key: str): + self._d.pop(key, None) + + def sync(self): + self.synced = True + # ----------------------------- # QtSettingsStore @@ -95,6 +102,17 @@ def model_validate_json(raw: str): assert settstore.load_full_config_snapshot() is None +def test_qt_settings_store_fast_encoding_roundtrip(): + s = InMemoryQSettings() + settstore = store.DLCLiveGUISettingsStore(qsettings=s) + + settstore.set_fast_encoding(True) + assert settstore.get_fast_encoding(default=False) is True + + settstore.set_fast_encoding(False) + assert settstore.get_fast_encoding(default=True) is False + + # ----------------------------- # ModelPathStore helpers # ----------------------------- @@ -337,3 +355,160 @@ def test_model_path_store_suggest_selected_file_returns_none_when_missing(tmp_pa settings.setValue("dlc/last_model_path", str(missing)) assert mps.suggest_selected_file() is None + + +# ----------------------------- +# Inference camera ID +# ----------------------------- +def test_settings_store_inference_camera_id_roundtrip(): + s = InMemoryQSettings() + settstore = store.DLCLiveGUISettingsStore(qsettings=s) + + assert settstore.get_inference_camera_id() is None + + settstore.set_inference_camera_id("opencv:0") + assert settstore.get_inference_camera_id() == "opencv:0" + + settstore.set_inference_camera_id(None) + assert settstore.get_inference_camera_id() is None + + settstore.set_inference_camera_id("") + assert settstore.get_inference_camera_id() is None + + +# ----------------------------- +# Processor settings +# ----------------------------- +def test_settings_store_processor_folder_roundtrip_when_valid(tmp_path: Path): + s = InMemoryQSettings() + settstore = store.DLCLiveGUISettingsStore(qsettings=s) + + folder = tmp_path / "processors" + folder.mkdir() + + settstore.set_processor_folder(str(folder)) + + assert settstore.get_processor_folder(default="fallback") == str(folder.resolve()) + + +def test_settings_store_processor_folder_ignores_invalid_value(tmp_path: Path): + s = InMemoryQSettings() + settstore = store.DLCLiveGUISettingsStore(qsettings=s) + + missing = tmp_path / "missing" + + settstore.set_processor_folder(str(missing)) + + assert settstore.get_processor_folder(default="fallback") == "fallback" + + +def test_settings_store_get_processor_folder_returns_default_if_stored_missing(tmp_path: Path): + s = InMemoryQSettings() + settstore = store.DLCLiveGUISettingsStore(qsettings=s) + + missing = tmp_path / "missing" + s.setValue("dlc/processor_folder", str(missing)) + + assert settstore.get_processor_folder(default="fallback") == "fallback" + + +def test_settings_store_processor_key_roundtrip(): + s = InMemoryQSettings() + settstore = store.DLCLiveGUISettingsStore(qsettings=s) + + assert settstore.get_processor_key() is None + + settstore.set_processor_key("my_processor") + assert settstore.get_processor_key() == "my_processor" + + settstore.set_processor_key(None) + assert settstore.get_processor_key() is None + + settstore.set_processor_key("") + assert settstore.get_processor_key() is None + + +@pytest.mark.parametrize( + ("stored", "expected"), + [ + (True, True), + (False, False), + ("true", True), + ("false", False), + ("1", True), + ("0", False), + ("yes", True), + ("no", False), + ("on", True), + ("off", False), + (1, True), + (0, False), + ], +) +def test_settings_store_processor_control_bool_parsing(stored, expected): + s = InMemoryQSettings() + s.setValue("dlc/processor_control_enabled", stored) + + settstore = store.DLCLiveGUISettingsStore(qsettings=s) + + assert settstore.get_processor_control_enabled(default=not expected) is expected + + +def test_settings_store_processor_control_enabled_roundtrip(): + s = InMemoryQSettings() + settstore = store.DLCLiveGUISettingsStore(qsettings=s) + + assert settstore.get_processor_control_enabled(default=False) is False + + settstore.set_processor_control_enabled(True) + assert settstore.get_processor_control_enabled(default=False) is True + + settstore.set_processor_control_enabled(False) + assert settstore.get_processor_control_enabled(default=True) is False + + +# ----------------------------- +# Recording +# ----------------------------- +@pytest.mark.parametrize( + ("stored", "expected"), + [ + ("true", True), + ("false", False), + ("1", True), + ("0", False), + ("yes", True), + ("no", False), + ("on", True), + ("off", False), + ], +) +def test_settings_store_use_timestamp_bool_variants(stored, expected): + s = InMemoryQSettings() + s.setValue("recording/use_timestamp", stored) + + settstore = store.DLCLiveGUISettingsStore(qsettings=s) + + assert settstore.get_use_timestamp(default=not expected) is expected + + +@pytest.mark.parametrize( + ("stored", "expected"), + [ + ("true", True), + ("false", False), + ("1", True), + ("0", False), + ("yes", True), + ("no", False), + ("on", True), + ("off", False), + ], +) +def test_settings_store_fast_encoding_bool_variants(stored, expected): + s = InMemoryQSettings() + s.setValue("recording/fast_encoding", stored) + + settstore = store.DLCLiveGUISettingsStore(qsettings=s) + + assert settstore.get_fast_encoding(default=not expected) is expected diff --git a/tests/utils/test_stats.py b/tests/utils/test_stats.py index 1fa12400f..bc1ae31f2 100644 --- a/tests/utils/test_stats.py +++ b/tests/utils/test_stats.py @@ -4,7 +4,7 @@ from hypothesis import given, settings from hypothesis import strategies as st -from dlclivegui.utils.stats import format_dlc_stats, format_recorder_stats +from dlclivegui.utils.stats import RecorderStats, format_dlc_stats, format_recorder_stats pytestmark = pytest.mark.unit @@ -14,19 +14,20 @@ def test_format_recorder_stats_exact(): - stats = SimpleNamespace( + stats = RecorderStats( frames_written=10, frames_enqueued=12, write_fps=29.94, - last_latency=0.01234, # 12.34 ms -> 12.3 - average_latency=0.05678, # 56.78 ms -> 56.8 - buffer_seconds=0.4321, # 432.1 ms -> 432 + last_latency=0.01234, + average_latency=0.05678, + buffer_seconds=0.4321, queue_size=3, + buffer_size=0, dropped_frames=2, ) assert format_recorder_stats(stats) == ( - "10/12 frames | write 29.9 fps | latency 12.3 ms (avg 56.8 ms) | queue 3 (~432 ms) | dropped 2" + "10/12 frames | write 29.9 fps | latency 12.3 ms (avg 56.8 ms) | queue 3 (~432 ms) | backlog 2 | dropped 2" ) @@ -115,6 +116,7 @@ def _fmt0(x: float) -> str: average_latency=finite_seconds_small, buffer_seconds=finite_seconds, queue_size=queue_size_int, + buffer_size=queue_size_int, dropped_frames=nonneg_int, ) def test_format_recorder_stats_properties( @@ -125,9 +127,10 @@ def test_format_recorder_stats_properties( average_latency, buffer_seconds, queue_size, + buffer_size, dropped_frames, ): - stats = SimpleNamespace( + stats = RecorderStats( frames_written=frames_written, frames_enqueued=frames_enqueued, write_fps=write_fps, @@ -135,28 +138,17 @@ def test_format_recorder_stats_properties( average_latency=average_latency, buffer_seconds=buffer_seconds, queue_size=queue_size, + buffer_size=buffer_size, dropped_frames=dropped_frames, ) s = format_recorder_stats(stats) - # Required structural tokens - assert " frames | write " in s - assert " fps | latency " in s - assert " ms (avg " in s - assert " ms) | queue " in s - assert " (~" in s - assert " ms) | dropped " in s - - # Exact numeric formatting expectations (substrings) - latency_ms = last_latency * 1000.0 - avg_ms = average_latency * 1000.0 - buffer_ms = buffer_seconds * 1000.0 - assert f"{frames_written}/{frames_enqueued} frames" in s - assert f"write {_fmt1(write_fps)} fps" in s - assert f"latency {_fmt1(latency_ms)} ms (avg {_fmt1(avg_ms)} ms)" in s - assert f"queue {queue_size} (~{_fmt0(buffer_ms)} ms)" in s + assert "write " in s + assert "latency " in s + assert "queue " in s + assert "backlog " in s assert f"dropped {dropped_frames}" in s @@ -251,3 +243,26 @@ def test_format_dlc_stats_profile_properties(stats): assert f"(GPU:{_fmt1(gpu_ms)}ms+proc:{_fmt1(proc_ms)}ms)" in s else: assert "GPU:" not in s + + +def test_format_recorder_stats_exact_with_buffer_capacity(): + stats = RecorderStats( + frames_written=10, + frames_enqueued=12, + write_fps=29.94, + last_latency=0.01234, + average_latency=0.05678, + buffer_seconds=0.4321, + queue_size=3, + buffer_size=10, + dropped_frames=2, + ) + + assert format_recorder_stats(stats) == ( + "10/12 frames | " + "write 29.9 fps | " + "latency 12.3 ms (avg 56.8 ms) | " + "queue 3/10 (30%, ~432 ms) | " + "backlog 2 | " + "dropped 2" + ) diff --git a/tests/utils/test_timestamps.py b/tests/utils/test_timestamps.py new file mode 100644 index 000000000..560872930 --- /dev/null +++ b/tests/utils/test_timestamps.py @@ -0,0 +1,63 @@ +import pytest + +from dlclivegui.utils.timestamps import FrameTimestampMetadata + + +class TestFrameTimestampMetadata: + def test_splits_source_and_frame_values(self): + meta = FrameTimestampMetadata( + source="grab_result.GetTimeStamp", + backend="basler", + default_reported="seconds", + seconds=0.123456789, + wall_clock_time=None, + raw_value=123456789, + raw_unit="ticks", + tick_frequency_hz=1_000_000_000.0, + timebase="Basler camera timestamp counter", + kind="camera_clock", + ) + + assert meta.to_source_dict() == { + "source": "grab_result.GetTimeStamp", + "backend": "basler", + "default_reported": "seconds", + "raw_unit": "ticks", + "tick_frequency_hz": 1_000_000_000.0, + "timebase": "Basler camera timestamp counter", + "kind": "camera_clock", + "extra": {}, + } + + frame_dict = meta.to_frame_dict() + assert frame_dict["seconds"] == pytest.approx(0.123456789) + assert frame_dict["raw_value"] == 123456789 + assert "wall_clock_time" not in frame_dict + + assert meta.get_default_reported() == pytest.approx(0.123456789) + + def test_default_reported_raw_value(self): + meta = FrameTimestampMetadata( + source="device_counter", + backend="some_backend", + default_reported="raw_value", + raw_value=42, + raw_unit="frames", + kind="frame_counter", + ) + + assert meta.to_frame_dict() == {"raw_value": 42} + assert meta.get_default_reported() == 42 + + def test_unknown_default_field_returns_none(self): + meta = FrameTimestampMetadata( + source="device_counter", + backend="some_backend", + default_reported="seconds", + raw_value=42, + raw_unit="frames", + kind="frame_counter", + ) + + assert meta.to_frame_dict() == {"raw_value": 42} + assert meta.get_default_reported() is None