Skip to content

Commit d5cc3f8

Browse files
C-AchardCopilot
andauthored
Feature: triggered cameras, master and follower scheme (#96)
* Add GenTL hardware trigger support Add generic hardware-trigger support for GenTL cameras and expose trigger settings in CameraSettings. - Introduce CameraTriggerSettings model (config.py) with role/input/output/timeout/strict and helpers to coerce and serialize. - Allow CameraSettings to read/write backend-specific trigger options. - GenTLCameraBackend: import and use trigger settings, apply trigger timeout override, persist actual trigger config, and mark hardware_trigger capability as BEST_EFFORT. - Implement trigger configuration paths: off, external/follower (input), and master (output). Add helper methods (_set_enum_node, _node, _node_symbolics, _trigger_attr, _trigger_to_dict) to safely interact with GenICam nodes. - Improve lifecycle handling: configure trigger after other settings, restore a safe non-triggering state on stop, and provide waits_for_hardware_trigger property and clearer timeout messages when waiting for hardware triggers. - Add DEFAULT_CAPABILITIES entry for hardware_trigger in base defaults. These changes enable safer and configurable hardware-trigger operation for GenTL backends and improve error reporting and shutdown behavior. * Handle camera trigger defaults & trigger-aware startup Populate gentl trigger defaults on save and allow saving configs with empty DLCLive model path. Add _with_camera_defaults_for_save to ensure CameraTriggerSettings are present for gentl cameras and thread through allow_empty_model_path in _dlc_settings_from_ui/_current_config so configs can be saved without a model while preserving existing DLC fields. Improve multi-camera runtime robustness: treat TimeoutError from hardware-trigger backends as an expected 'no trigger' event (don't count as a camera failure) and add _trigger_role_from_settings/_camera_start_priority helpers. Start active cameras sorted by trigger role so trigger-waiting (external/follower) devices are armed before masters. Also extend DLCLive configuration error handling to include RuntimeError. * Improve signal handling and graceful shutdown Enhance Qt app signal handling to support SIGTERM and SIGBREAK, and make Ctrl+C shutdown more robust. Adds a quitting flag and a two-stage interrupt: first interrupt triggers window close and schedules app.quit, second interrupt forces immediate exit (os._exit(130)). Parents the keepalive QTimer to the QApplication, sets a 100ms interval, and safely stops any previous timer to avoid duplicates. Adds logging and exception handling around window close and timer cleanup, and uses QTimer.singleShot to ensure Qt leaves its event loop even if closeEvent cleanup is asynchronous. * Add GenTL trigger tests and fake node map Add a comprehensive test suite for GenTL hardware trigger handling (tests/cameras/backends/test_gentl_trigger.py). Tests cover trigger roles (off/external/follower/master), selector/source/activation settings, strict vs non-strict behavior for invalid sources, master output configuration, alias mapping, timeout handling and error messaging, and persistence of trigger_actual for debugging. Update the test conftest fake node map (tests/cameras/backends/conftest.py) to include Trigger* and Line* nodes (AcquisitionMode, TriggerSelector, TriggerMode, TriggerSource, TriggerActivation, LineSelector, LineMode, LineSource) so the tests can exercise trigger and GPIO-related configuration. * Respect user camera order for display/tiling Use the user-configured camera order for display/tiling and ensure tiling geometry matches displayed frames. Changes: - GUI: populate the inference camera dropdown from active cameras in the configured order and only add entries for cameras that are actually running. - MultiCameraController: store the user display order, derive startup order by sorting for trigger safety (followers/external first, master last), emit frames/timestamps in display order (with any unexpected IDs appended deterministically), clear display order on stop, and handle no-active-cameras early. - Utils: compute_tiling_geometry now uses the frames' display order (insertion order) rather than sorted keys so tile dimensions and overlays align with the tiled frame; updated docstrings to reflect this. These changes ensure consistent tiling, overlay transforms, and UI behavior that follow the user's configured ordering. * tests: preserve display order and add MultiCamera tests Update display tests to assert that tiling and tile computations preserve frame insertion/display order (no longer sorting by camera ID) and add coverage for tile offsets, scaling, and tiled frame content. Add a suite of unit tests for MultiCameraController utilities and behavior: get_camera_id, trigger role aliasing, camera start priority, preserving user display order on start, frame_ready emission order, clearing display order on stop, hardware trigger timeouts (non-fatal), and non-trigger timeouts (fatal). Also import newly-tested helper functions from multi_camera_controller. * Mock _maybe_allow_keyboard_interrupt in GUI tests Patch tests/gui/test_app_entrypoint.py to monkeypatch appmod._maybe_allow_keyboard_interrupt with a MagicMock in both test_main_with_splash and test_main_without_splash. This prevents the real interrupt-handling helper from running during GUI tests and avoids side effects on global keyboard/signal handling. * Support strict GenTL trigger and defaults GenTLCameraBackend: add handling for a 'strict' flag when parsing GenTL trigger config so invalid configs raise in strict mode but fall back with a warning otherwise. Preserve the original exception when raising, and improve the warning text to mention strict mode. Return whether LineSelector was actually set and skip configuring trigger output if selection failed to avoid driving an unintended GPIO line. DLCLiveMainWindow: introduce _with_camera_trigger_defaults_for_save to ensure gentl trigger defaults are stored per camera, refactor _with_camera_defaults_for_save to apply that per-camera, and adjust _current_config to use the first camera with defaults applied. This ensures trigger settings are persisted and validated consistently. * Apply gentl trigger defaults when saving Add with_save_defaults helpers to CameraSettings and MultiCameraSettings and use them when serializing ApplicationSettings so gentl "trigger" defaults are applied to saved configs. Remove duplicated camera-default helper logic from DLCLiveMainWindow and simplify _current_config to rely on model methods. Add unit tests to verify gentl trigger defaults are included for top-level and multi-camera cases. * Don't sort available camera IDs Remove sorting when building available_ids so the original frame key order is preserved. The DLC camera selection relies on the first active camera in frame_data.frames; using list(...) keeps the insertion/order semantics (dict order) instead of reordering keys with sorted(). This avoids unintended changes to camera priority caused by alphabetical/numeric sorting. * Preserve DLC config using model_copy Initialize an existing DLC config (falling back to DEFAULT_CONFIG if unset) and use its model_copy(update=...) to return settings. This replaces explicit field-by-field construction so only model_path (and model_type when set) are changed, preserving other DLC options and avoiding duplication. * Warn when GenTL TriggerMode fails to enable Add a warning log when setting GenTL TriggerMode to 'On' fails in non-strict mode. Previously the code only raised an exception in strict mode; now it emits a warning to inform users that the trigger mode may not be correctly configured when continuing without strict enforcement. * Improve GenTL trigger routing safety and tests Make GenTLCameraBackend trigger configuration more robust and adjust tests. - Use the waits_for_hardware_trigger property when converting GenTL timeouts to user-facing errors instead of inferring from the role string. - Read and record the configured trigger role early in _configure_trigger_input. - Check results when setting TriggerSelector, TriggerSource and TriggerActivation (selector_ok, source_ok, activation_ok). If selector/source routing fails in non-strict mode, disable the trigger, reset internal trigger state and log a warning to avoid arming the camera on a previous/default input line. If activation fails, warn and continue using the camera default. - If enabling TriggerMode=On fails, disable the trigger and reset internal state instead of only warning. - Validate LineMode and LineSource when configuring master output and log if configuration is incomplete. Tests updated to reflect safety changes: - Expect waits_for_hardware_trigger to be set for external input configuration. - Rename and change a non-strict invalid-source test to assert the trigger is disabled, waits_for_hardware_trigger is False, and trigger_actual is persisted as off. - Add a new test ensuring an invalid selector in non-strict mode disables the trigger and persists the off state. These changes prevent the camera from being left armed on an unintended/default input if routing nodes could not be applied, improving safety and predictability. * Cap hardware-trigger fetch timeout and update tests Introduce a MAX_HARDWARE_TRIGGER_FETCH_TIMEOUT and cap Harvester.fetch() timeouts when the trigger role waits for external hardware (roles: external, follower) to keep individual fetch calls short and allow prompt shutdown. Preserve legacy behavior for non-waiting roles (e.g. master). Remove a noisy Trigger input LOG.info call. Update tests to expect the capped fetch timeout, verify the original requested timeout is still persisted in trigger_actual, add a test that master mode is not capped, and make test resource cleanup more robust (use try/finally around open/close). * Interruptible camera waits; simplify config save Make SingleCameraWorker sleep calls interruptible by using self._stop_event.wait() instead of time.sleep(), and add a small _trigger_timeout_delay to allow early exit during trigger/wait cycles. This prevents the worker from being unresponsive to stop requests during retry and trigger waits. Also simplify ApplicationSettings.to_dict() to always start from self.camera.with_save_defaults() and stop overriding it with active multi-camera selection, ensuring consistent camera settings are saved. * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Fix broken suggestion * Resolve 'auto' trigger source in GenTL backend Add resolution logic for trigger source: implement _resolve_trigger_source to map the model-level default 'auto' to the first supported camera line (Line0/Line1/Line2/Any), return whether the resolved value is supported, and log/warn or raise depending on strict mode. Integrate this into _configure_pixel_format/_configure trigger flow so TriggerSource is only set if resolution succeeded. Change CameraTriggerSettings default source to 'auto' and add a field validator to coerce common aliases (e.g. 'default', 'automatic', 'device', 'camera') to 'auto'. Update tests and fixtures: adjust fake node default TriggerSource to Line1, update expectations, and add unit tests for auto-selection behavior and strict-mode error. This makes trigger configuration more robust across cameras with differing GenICam enum symbolics. * Fix display tests * Update test_multicam_controller.py * Fix duplication * Refine GenTL trigger timeout handling Refactors GenTL timeout logic to keep a base fetch timeout separate from trigger-requested timeout, then computes the effective timeout based on trigger role. External/follower roles remain capped to avoid long hardware-trigger waits, master uses the trigger timeout directly, and non-trigger modes fall back to the base timeout. Also updates trigger persistence to store both requested and actual trigger settings, including `fetch_timeout` and `requested_timeout`, and improves trigger serialization by supporting objects that expose `to_properties()`. * Use CameraTriggerSettings for trigger role Replace manual trigger-role normalization in `multi_camera_controller` with `CameraTriggerSettings.from_any(...)` and normalize via its parsed role. This centralizes alias handling in config parsing and keeps a safe fallback to `"off"` when trigger settings are invalid. * Fix incorrect test expectations * Update multi_camera_controller.py * Add trigger settings dialog and UI button Introduce a TriggerConfigDialog to edit per-camera hardware trigger settings (CameraTriggerSettings). The dialog provides fields for role, selector, source, activation, output line/source, timeout and strict mode, and persists settings into camera.properties[<backend>]['trigger']. Also add a "Trigger Settings…" button to the camera settings UI (disabled by default) with tooltip and icon; wiring to open the dialog can be connected elsewhere. * Add per-camera hardware trigger settings Introduce support for per-camera hardware trigger configuration in the camera config dialog. Imports TriggerConfigDialog and CameraTriggerSettings, wires the trigger settings button to open a modal, and adds _open_trigger_settings_dialog to commit edits, show the dialog, apply updates, and restart the preview if needed. Adds helpers: _ensure_default_trigger_config to initialize gentl.trigger defaults, _trigger_role_for_label to show trigger role in camera list labels, and _trigger_dict_for_cam to compare trigger settings when deciding to restart previews. Also integrates the hardware trigger field into the settings summary and ensures new/loaded cameras get a default trigger config. * Update trigger_config_dialog.py * Use _is_preview_live and clear trigger timeout Replace direct checks of self._preview.state == PreviewState.ACTIVE with self._is_preview_live() in camera_config_dialog to centralize preview-active logic and ensure consistent behavior when restarting the preview after trigger or camera setting changes. In trigger_config_dialog, only set payload["timeout"] for external/follower roles when timeout > 0, and explicitly set payload["timeout"] = None when role == "off" to clear any stale timeout when disabling the trigger. * Restart active previews; ignore bad trigger Allow pending preview restarts to proceed when the preview is ACTIVE (previously only IDLE) and return early to avoid double UI sync in camera_config_dialog.py. Also wrap CameraTriggerSettings loading in a try/except and fall back to a default instance when parsing fails, making trigger_config_dialog.py tolerant of malformed or missing trigger data. * Improve trigger dialog defaults and error handling Make trigger dialog friendlier and more robust: recommend using 'auto' for trigger source and switch default/fallback source from "Line0" to "auto", update source placeholder and info/timeout tooltips, and import QMessageBox. Wrap CameraTriggerSettings.from_any in a try/except to show a critical error dialog on failure and abort apply. Store trigger settings via trigger.to_properties() instead of model_dump(exclude_none=True). These changes provide clearer defaults and better error feedback when applying trigger settings. * Add worker timing logs to camera worker Introduce WorkerTimingStats (utils/stats.py) to collect simple timing counters (per-named section totals, frame/timeouts/errors) and periodically emit debug logs. Integrate it into SingleCameraWorker: create a timing instance (configurable via new SINGLE_CAMERA_WORKER_DO_LOG_TIMING in config.py), wrap backend.read and frame emit calls with timed sections, and call note_frame/note_timeout/note_error + maybe_log to accumulate and flush stats. Also update imports accordingly and use a 1s default log interval. * Add per-camera timing to MultiCameraController Introduce MULTI_CAMERA_WORKER_DO_LOG_TIMING and disable single-worker timing by default (SINGLE_CAMERA_WORKER_DO_LOG_TIMING=false). Add per-camera WorkerTimingStats storage and a _timing_for_camera factory that respects the new config. Wrap _on_frame_captured processing in timed sections (total, apply_transforms, update_latest), call note_frame/maybe_log per camera, and emit frames as before. Also adjust SingleCameraWorker timing labels from GenTL.* to Single.* for clearer logs. * Add pretty/str/repr to CameraSettings Introduce a human-readable representation for CameraSettings by adding a pretty() method and overriding __str__ and __repr__. The pretty output formats key fields (name, index, backend, enabled, fps, size, exposure, gain, rotation) and displays a readable crop region (showing 'none' or coordinate range with 'edge' fallbacks). This aids debugging and logging without changing existing validation or behavior. * Reduce lock scope and refactor frame handling Refactor _on_frame_captured to minimize time spent under _frame_lock and improve timing granularity. Introduces a frame_data local, renames timing labels (e.g. Multi.apply_transforms, Multi.store_latest, Multi.build_ordered, Multi.construct_frame_data), and moves frame_data construction inside measured blocks. The frame_ready emit is now performed outside the lock and guarded by a None check to avoid holding the lock during signal emission. Also small whitespace and cleanup changes. * Add GenTL strobe support and trigger debugging Add debugging helpers for GenTL trigger and frame-rate nodes and log their values during camera configuration. Improve GenTL trigger input handling by treating TriggerSource as best-effort (supporting read-only/auto cases) and emitting clearer warnings. Implement a dedicated master/output path for TIS/DMK 37U cameras using Strobe* nodes (StrobeEnable/Polarity/Operation/Duration/Delay) with a fallback to generic Line* configuration; respect strict mode and surface informative errors. Extend CameraTriggerSettings with strobe fields (polarity, operation, duration, delay) and validation/coercion. Update the trigger configuration dialog to expose strobe controls, tooltips, UI sync logic, and include strobe values in the saved payload. * Add _node_value helper for safe node reads Introduce a static _node_value(node_map, name, default) helper that performs a best-effort read of GenICam node values. It looks up the node, returns default if missing, then tries node.value and falls back to node.GetValue() while swallowing exceptions. This prevents debug helpers and open() from failing when test/SDK nodes expose different accessors or raise errors. * Refactor GenTL trigger source handling and logging Replace manual TriggerSource symbol checks with _resolve_trigger_source and only set TriggerSource when supported. Delay setting TriggerActivation until after source resolution and use non-strict writes for activation. Add safety check: do not arm TriggerMode=On unless both TriggerSelector and TriggerSource succeeded (avoids waiting on a previous/default input). Improve log messages to include selector_ok, source_ok, requested/ resolved source and activation for clearer diagnostics. * Update multi_camera_controller.py * pre-commit * Add GenTL telemetry and frame-rate debugging Add helpers and telemetry to better read and debug GenTL/GenICam cameras. - New debug helper _debug_frame_rate_nodes to log many common frame-rate/exposure/throughput nodes. - Added robust node accessors: _node_value, _node_float, _node_str to safely read various GenICam node types and try multiple node names. - Enhance frame-rate configuration to log before/after values when enabling rate control and when setting AcquisitionFrameRate/AcquisitionFrameRateAbs; record any accepted frame-rate as _actual_fps. - After starting acquisition, attempt to read telemetry and run FPS debug logging; warn (but continue) if telemetry read fails. - Improve _read_telemetry to prefer resulting frame-rate nodes over requested ones, and to populate actual_fps, actual_exposure, actual_gain and other useful properties (pixel format, throughput, resolution, etc.) into the settings namespace for GUI/debugging. These changes make frame-rate/exposure behavior more observable and more tolerant across different camera implementations. * Allow zero node values and reduce log level Change LOG.info to LOG.debug to reduce noise when printing node information. Add an allow_zero parameter to _node_float so callers can accept zero as a valid value (previously only positive values were returned). Update callers for exposure, gain, and DeviceLinkThroughputLimit to pass allow_zero=True so reported zeros are treated as real readings while preserving the original behavior by default. * Split multi-camera frame signals; throttle display Introduce a separate, throttled UI/display path for multi-camera frames. Add GUI_MAX_DISPLAY_FPS config and a new display_ready signal that is emitted at most at that rate, while frame_ready remains full-rate for recording/inference. Implement _should_emit_display_ready, wire the MultiCameraController to emit both signals, and reset throttling state when starting. Update the main window to handle processing and display paths via _on_multi_frame_processing_ready and _on_multi_frame_display_ready, and adjust tests accordingly. Also add GUI/debug timing config keys and a temporary timing-only early path in SingleCameraWorker (marked as FIXME). * Remove temp debug block * Fix missing signal wiring Fix signal hookup and disable main-window timing flag. Replace the second connection of multi_camera_controller.frame_ready to _on_multi_frame_display_ready with multi_camera_controller.display_ready to separate processing-ready and display-ready events. Also comment out MAIN_WINDOW_DO_LOG_TIMING in config.py to disable main-window timing logging. * Revert debug logging to disabled * Update main_window.py * Fix DLC model_type regression from detected backend Update `_dlc_settings_from_ui` to rebuild settings via `model_dump` + `model_validate` instead of `model_copy(update=...)`, ensuring Pydantic validation/coercion runs on UI-derived updates. This fixes cases where detected backends (e.g., `Engine.PYTORCH`) were not stored as validated string values. Added a GUI test that mocks backend detection and asserts `model_type` is normalized to the string `"pytorch"`. * Add Basler trigger support and timeout handling Implement comprehensive trigger support for the Basler backend: import CameraTriggerSettings, parse trigger config (roles: off, external/follower, software, master), and persist trigger_actual into the namespace. Add trigger configuration helpers (_configure_trigger*, _resolve_trigger_source, _restore_trigger_idle), software trigger execution (trigger_once), and many feature/enum/numeric helper methods with debug logging. Make RetrieveResult use a configurable _retrieve_timeout_ms (derived from trigger.timeout) and limit it for hardware-triggered cameras to allow prompt shutdown; raise a TimeoutError when waiting for hardware triggers. Expose hardware_trigger capability as BEST_EFFORT and add an env var-based pylon emulation toggle for testing. Misc: add debug dumps of trigger-related nodes and best-effort restore of trigger state on close. * Remove PYLON_CAMEMU test comment Delete commented-out code that forced pypylon to create emulation virtual cameras (PYLON_CAMEMU), which was only intended for testing and should not be enabled for release. Also remove an extraneous blank line to tidy up the file. * Make trigger config dialog backend-aware Introduce a TriggerUiProfile dataclass and trigger_ui_profile_for_backend() to drive dialog presentation per backend. Replace free-text source field with an editable QComboBox providing backend suggestions and defaults. Add profile-driven visibility/enabling for input, master, software and strobe/line output fields, plus helper methods to manage form rows and combo text. Only include strobe-related payload fields when the backend profile exposes them. Misc: expand info/help text, show backend in group title, increase dialog min-width, refine tooltips, and improve model↔UI mapping and payload construction. * Support basler backend in trigger defaults Previously the default trigger configuration was only applied for backend 'gentl' and always stored under the 'gentl' properties key. This change treats the backend name dynamically (accepting both 'gentl' and 'basler'), and stores the default trigger settings under the actual backend key in cam.properties. Also preserves behavior when cam.properties or the backend namespace is not a dict. * Throttle hardware-trigger wait logs Reduce log flooding when waiting for hardware triggers by adding throttled logging. Changes in dlclivegui/services/multi_camera_controller.py: - Import time. - Add a new _log_interval_while_waiting_for_trigger_s attribute to SingleCameraWorker. - Replace the direct LOGGER.debug call for expected trigger wait timeouts with a call to _log_trigger_wait_throttled. - Implement _log_trigger_wait_throttled to suppress repeated timeout messages, emit a consolidated debug message, and report how many repeated logs were suppressed. This prevents high-frequency expected poll-timeout logs (common in trigger-waiting modes) from overwhelming the logs. * Enhance FakePylon and add Basler backend tests Extend the test conftest FakePylon to better emulate pypylon: add FakePylonTimeoutException, richer _Feature (symbolics, min/max/inc, read/write checks, call tracking), _EnumEntry, expanded _DeviceInfo, GrabResult.release tracking, and a more complete InstantCamera (timeouts, software trigger, trigger/line features, buffer and grab controls, test knobs). Reset the fake factory and provide default fake devices and a basler_settings_factory fixture. Patch the basler SDK fixture to use FakePylon. Add new test suite tests/cameras/backends/test_basler_backend.py covering lifecycle (open/read/close, fast-start, idempotent close), discovery/rebind, resolution/exposure/gain/fps handling, and comprehensive trigger behavior (follower/master/software/external) to validate backend logic. * Throttle trigger-wait logs, mark Basler test xfail Add throttled logging for hardware-trigger wait timeouts in SingleCameraWorker to avoid noisy repeated timeout messages. Introduce _trigger_wait_log_interval, _last_trigger_wait_log and _trigger_wait_suppressed_count and move _log_trigger_wait_throttled into the worker; remove the duplicate implementation from MultiCameraController. Also mark the Basler software-trigger test as xfail because software trigger support is not implemented yet. * Update multi_camera_controller.py * Basler backend: add timing and FPS config Integrates optional WorkerTimingStats into the Basler camera backend and refactors FPS handling and frame retrieval. Adds a _configure_frame_rate() helper to centralize AcquisitionFrameRate enabling, setting and readbacks (logs many related nodes and records actual_fps). Initializes a WorkerTimingStats instance (controlled by SINGLE_CAMERA_WORKER_DO_LOG_TIMING) and wraps RetrieveResult/convert/get array/release steps with timing measurements, improved error handling, proper grab_result release on exceptions, and frame counting/logging. Overall improves observability and robustness when setting frame rates and reading frames from Basler cameras. * style: apply pre-commit * Update basler_backend.py * Fix multi-camera shutdown synchronization Refactors `MultiCameraController.stop()` to track a dedicated stopping state and finalize shutdown only after all camera threads are actually stopped. It now delays `all_stopped` emission until cleanup is truly complete, keeps references to threads that refuse to terminate, and avoids the previous premature "stopped" state. The main window now explicitly calls `stop(wait=True)` during camera/project shutdown so UI transitions block until camera teardown is done. * Handle only PyPylon timeout errors Import `genicam` alongside `pylon` and add a helper to detect `genicam.TimeoutException` safely. The hardware-trigger timeout path now converts exceptions to `TimeoutError` only for true SDK timeout exceptions, avoiding incorrect timeout handling for unrelated grab failures. * Test Basler timeout mapping for trigger reads Add coverage for hardware-triggered Basler reads to ensure SDK timeout errors are re-raised as `TimeoutError` with the expected message and exception cause. Update the Basler SDK test patching to also inject a fake `genicam.TimeoutException`, so timeout handling paths can be exercised reliably in tests. * Harden multi-camera shutdown lifecycle Tightens controller start/stop state handling to prevent unsafe restarts while shutdown is in progress, and finalizes shutdown only after all camera threads actually exit. Cleanup now resets runtime state consistently, `all_stopped` emission is guarded against duplicates, and stop requests only fan out to workers when actively running. Camera stop handling was also refined so full initialization failure and last-camera runtime shutdown both transition into the same safe finalization path, with clearer logging around incomplete shutdowns. * Do not use software as auto mode for source Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Preserve runtime info when clearing workers Stop clearing `_runtime_info` as it is not currently defined * Stabilize Basler timeout mapping test Update the hardware-trigger timeout test to raise `bb.genicam.TimeoutException` directly instead of a fake timeout class, and assert that this real pylon exception is preserved as the `__cause__` of the mapped `TimeoutError`. This keeps the test aligned with actual backend behavior. * Stop injecting GenTL trigger save defaults Simplify `CameraSettings.with_save_defaults()` to return only a deep copy, removing the special-case logic that auto-populated `properties.gentl.trigger`. This avoids silently adding GenTL trigger settings during save. * Stop auto-initializing camera trigger config Removes the helper that injected default trigger settings for GenTL/Basler cameras and drops its call sites when adding or loading cameras. This avoids silently mutating camera properties with trigger defaults unless they are explicitly configured. * Revise trigger config behavior tests Updates `tests/test_config.py` to reflect the new trigger configuration semantics. The tests now verify that missing trigger config defaults to `role="off"` and `source="auto"`, explicit trigger settings round-trip through `ApplicationSettings` for both `gentl` and `basler`, and serialization does not inject implicit trigger blocks into camera properties. * Disable CAMEMU debug setting * Fix model_type coercion in Engine parser Adjust `Engine.from_model_type` to normalize non-string `model_type` values by converting either `.value` or the original object with `str(...)` in a single step. This avoids eager fallback conversion inside `getattr` and makes enum-like inputs more reliably coercible. * Disable Strobe in master role * Handle stop requests during camera startup Fixes a race where stopping preview during camera initialization could be ignored. `_stop_preview` now checks `is_active()` instead of only `is_running()`, and `SingleCameraWorker` now exits cleanly if a stop is requested right after backend open. The controller also adds `is_active()`/`is_starting()` state helpers and clarifies `is_running()` semantics. --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent 36a3d56 commit d5cc3f8

22 files changed

Lines changed: 4299 additions & 261 deletions

‎dlclivegui/cameras/backends/basler_backend.py‎

Lines changed: 580 additions & 27 deletions
Large diffs are not rendered by default.

‎dlclivegui/cameras/backends/gentl_backend.py‎

Lines changed: 710 additions & 20 deletions
Large diffs are not rendered by default.

‎dlclivegui/cameras/base.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ class SupportLevel(str, Enum):
7070
"set_gain": SupportLevel.UNSUPPORTED,
7171
"device_discovery": SupportLevel.UNSUPPORTED,
7272
"stable_identity": SupportLevel.UNSUPPORTED,
73+
"hardware_trigger": SupportLevel.UNSUPPORTED,
7374
}
7475

7576

‎dlclivegui/config.py‎

Lines changed: 193 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,21 @@
1212
TileLayout = Literal["auto", "2x2", "1x4", "4x1"]
1313
Precision = Literal["FP32", "FP16"]
1414
ModelType = Literal["pytorch", "tensorflow"]
15+
TriggerRole = Literal["off", "external", "master", "follower"]
16+
TriggerActivation = Literal["RisingEdge", "FallingEdge", "AnyEdge", "LevelHigh", "LevelLow"]
17+
TriggerStrobePolarity = Literal["ActiveHigh", "ActiveLow"]
18+
TriggerStrobeOperation = Literal["Exposure", "FixedDuration"]
19+
20+
# Global settings
21+
## GUI
22+
GUI_MAX_DISPLAY_FPS: float = 30.0
23+
24+
25+
## Debug
26+
### Timing logs
27+
SINGLE_CAMERA_WORKER_DO_LOG_TIMING: bool = False
28+
MULTI_CAMERA_WORKER_DO_LOG_TIMING: bool = False
29+
# MAIN_WINDOW_DO_LOG_TIMING: bool = False
1530

1631

1732
class CameraSettings(BaseModel):
@@ -38,6 +53,27 @@ class CameraSettings(BaseModel):
3853
enabled: bool = True
3954
properties: dict[str, Any] = Field(default_factory=dict)
4055

56+
def pretty(self) -> str:
57+
crop = (
58+
"none"
59+
if self.get_crop_region() is None
60+
else f"({self.crop_x0}, {self.crop_y0}) -> ({self.crop_x1 or 'edge'}, {self.crop_y1 or 'edge'})"
61+
)
62+
return (
63+
f"CameraSettings[\n"
64+
f" name={self.name!r}, index={self.index}, backend={self.backend!r}, enabled={self.enabled}\n"
65+
f" fps={self.fps}, size={self.width or 'auto'}x{self.height or 'auto'}, "
66+
f"exposure={self.exposure or 'auto'}, gain={self.gain or 'auto'}\n"
67+
f" rotation={self.rotation}, crop={crop}\n"
68+
f"]"
69+
)
70+
71+
def __str__(self) -> str:
72+
return self.pretty()
73+
74+
def __repr__(self) -> str:
75+
return self.pretty()
76+
4177
@field_validator("fps", mode="before")
4278
@classmethod
4379
def _coerce_fps(cls, v):
@@ -168,6 +204,148 @@ def check_diff(old: CameraSettings, new: CameraSettings) -> dict:
168204
pass
169205
return out
170206

207+
def backend_options(self, backend: str | None = None) -> dict[str, Any]:
208+
key = backend or self.backend
209+
props = self.properties if isinstance(self.properties, dict) else {}
210+
ns = props.get(str(key).lower(), {})
211+
return ns if isinstance(ns, dict) else {}
212+
213+
def get_trigger_settings(self, backend: str | None = None) -> CameraTriggerSettings:
214+
ns = self.backend_options(backend)
215+
return CameraTriggerSettings.from_any(ns.get("trigger"))
216+
217+
def set_trigger_settings(self, trigger: CameraTriggerSettings, backend: str | None = None) -> None:
218+
key = backend or self.backend
219+
if not isinstance(self.properties, dict):
220+
self.properties = {}
221+
ns = self.properties.setdefault(str(key).lower(), {})
222+
if not isinstance(ns, dict):
223+
ns = {}
224+
self.properties[str(key).lower()] = ns
225+
ns["trigger"] = trigger.to_properties()
226+
227+
def with_save_defaults(self) -> CameraSettings:
228+
"""
229+
Return a serializable deep copy.
230+
231+
Runtime defaults (incl. TriggerSettings) are applied
232+
when settings are read and not inserted into the saved config.
233+
"""
234+
return self.model_copy(deep=True)
235+
236+
237+
class CameraTriggerSettings(BaseModel):
238+
"""
239+
Generic hardware-trigger settings.
240+
241+
Backend-specific code may ignore fields that are unsupported by a given
242+
camera/SDK.
243+
244+
For GenTL/TIS DMK 37BUX287:
245+
- follower/external maps mainly to TriggerMode, TriggerSelector,
246+
TriggerActivation. TriggerSource may be read-only and is best-effort.
247+
- master output maps primarily to StrobeEnable, StrobePolarity,
248+
StrobeOperation, StrobeDuration, and StrobeDelay.
249+
"""
250+
251+
role: TriggerRole = "off"
252+
253+
# Input trigger config: external/follower
254+
selector: str = "FrameStart"
255+
source: str = "auto"
256+
activation: TriggerActivation | str = "RisingEdge"
257+
258+
# Generic/SFNC output config: master fallback for cameras exposing Line* nodes.
259+
output_line: str = "Line2"
260+
output_source: str = "ExposureActive"
261+
262+
# Strobe output config: master path for TIS/DMK 37U cameras.
263+
strobe_polarity: TriggerStrobePolarity | str = "ActiveHigh"
264+
strobe_operation: TriggerStrobeOperation | str = "Exposure"
265+
strobe_duration: int | None = None # µs, used when strobe_operation=FixedDuration
266+
strobe_delay: int | None = None # µs
267+
268+
# Runtime behavior
269+
timeout: float | None = None
270+
strict: bool = False
271+
272+
@field_validator("role", mode="before")
273+
@classmethod
274+
def _coerce_role(cls, v):
275+
if v is None:
276+
return "off"
277+
278+
s = str(v).strip().lower()
279+
aliases = {
280+
"": "off",
281+
"none": "off",
282+
"false": "off",
283+
"disabled": "off",
284+
"disable": "off",
285+
"off": "off",
286+
"true": "external",
287+
"on": "external",
288+
"trigger": "external",
289+
"triggered": "external",
290+
"external": "external",
291+
"follower": "follower",
292+
"slave": "follower",
293+
"master": "master",
294+
"main": "master",
295+
}
296+
return aliases.get(s, s)
297+
298+
@field_validator("timeout", mode="before")
299+
@classmethod
300+
def _coerce_timeout(cls, v):
301+
if v in (None, ""):
302+
return None
303+
try:
304+
fv = float(v)
305+
except Exception:
306+
return None
307+
return fv if fv > 0 else None
308+
309+
@field_validator("strobe_duration", "strobe_delay", mode="before")
310+
@classmethod
311+
def _coerce_optional_nonnegative_int(cls, v):
312+
if v in (None, ""):
313+
return None
314+
try:
315+
iv = int(float(v))
316+
except Exception:
317+
return None
318+
return iv if iv >= 0 else None
319+
320+
@field_validator("source", mode="before")
321+
@classmethod
322+
def _coerce_source(cls, v):
323+
if v is None:
324+
return "auto"
325+
326+
s = str(v).strip()
327+
if not s:
328+
return "auto"
329+
330+
aliases = {
331+
"default": "auto",
332+
"automatic": "auto",
333+
"device": "auto",
334+
"camera": "auto",
335+
}
336+
return aliases.get(s.lower(), s)
337+
338+
@classmethod
339+
def from_any(cls, value) -> CameraTriggerSettings:
340+
if isinstance(value, cls):
341+
return value
342+
if isinstance(value, dict):
343+
return cls(**value)
344+
return cls()
345+
346+
def to_properties(self) -> dict[str, Any]:
347+
return self.model_dump(exclude_none=True)
348+
171349

172350
class MultiCameraSettings(BaseModel):
173351
cameras: list[CameraSettings] = Field(default_factory=list)
@@ -206,12 +384,19 @@ def from_dict(cls, data: dict[str, Any]) -> MultiCameraSettings:
206384
return cls(cameras=cameras, max_cameras=max_cameras, tile_layout=tile_layout)
207385

208386
def to_dict(self) -> dict[str, Any]:
387+
out = self.with_save_defaults()
209388
return {
210-
"cameras": [cam.model_dump() for cam in self.cameras],
211-
"max_cameras": self.max_cameras,
212-
"tile_layout": self.tile_layout,
389+
"cameras": [cam.model_dump() for cam in out.cameras],
390+
"max_cameras": out.max_cameras,
391+
"tile_layout": out.tile_layout,
213392
}
214393

394+
def with_save_defaults(self) -> MultiCameraSettings:
395+
"""Return a copy with save defaults applied to all cameras."""
396+
out = self.model_copy(deep=True)
397+
out.cameras = [cam.with_save_defaults() for cam in out.cameras]
398+
return out
399+
215400

216401
class DynamicCropModel(BaseModel):
217402
enabled: bool = False
@@ -377,10 +562,13 @@ def from_dict(cls, data: dict[str, Any]) -> ApplicationSettings:
377562
)
378563

379564
def to_dict(self) -> dict[str, Any]:
565+
camera = self.camera.with_save_defaults()
566+
multi_camera = self.multi_camera.with_save_defaults()
567+
380568
return {
381569
"version": self.version,
382-
"camera": self.camera.model_dump(),
383-
"multi_camera": self.multi_camera.to_dict(),
570+
"camera": camera.model_dump(),
571+
"multi_camera": multi_camera.to_dict(),
384572
"dlc": self.dlc.model_dump(),
385573
"recording": self.recording.model_dump(),
386574
"bbox": self.bbox.model_dump(),

‎dlclivegui/gui/camera_config/camera_config_dialog.py‎

Lines changed: 66 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from ...config import CameraSettings, MultiCameraSettings
2222
from .loaders import CameraLoadWorker, CameraProbeWorker, CameraScanState, DetectCamerasWorker
2323
from .preview import PreviewSession, PreviewState, apply_crop, apply_rotation, resize_to_fit, to_display_pixmap
24+
from .trigger_config_dialog import TriggerConfigDialog
2425
from .ui_blocks import setup_camera_config_dialog_ui
2526

2627
LOGGER = logging.getLogger(__name__)
@@ -328,6 +329,7 @@ def _connect_signals(self) -> None:
328329
self.active_cameras_list.currentRowChanged.connect(self._on_active_camera_selected)
329330
self.available_cameras_list.currentRowChanged.connect(self._on_available_camera_selected)
330331
self.available_cameras_list.itemDoubleClicked.connect(self._on_available_camera_double_clicked)
332+
self.trigger_settings_btn.clicked.connect(self._open_trigger_settings_dialog)
331333
self.apply_settings_btn.clicked.connect(self._apply_camera_settings)
332334
self.reset_settings_btn.clicked.connect(self._reset_selected_camera)
333335
self.preview_btn.clicked.connect(self._toggle_preview)
@@ -451,11 +453,24 @@ def _refresh_camera_labels(self) -> None:
451453
finally:
452454
cam_list.blockSignals(False)
453455

456+
def _trigger_role_for_label(self, cam: CameraSettings) -> str:
457+
backend = (cam.backend or "").lower()
458+
props = cam.properties if isinstance(cam.properties, dict) else {}
459+
ns = props.get(backend, {}) if isinstance(props.get(backend), dict) else {}
460+
trigger = ns.get("trigger", {})
461+
if not isinstance(trigger, dict):
462+
return "off"
463+
return str(trigger.get("role", "off") or "off").lower()
464+
454465
def _format_camera_label(self, cam: CameraSettings, index: int = -1) -> str:
455466
status = "✓" if cam.enabled else "○"
456467
this_id = f"{(cam.backend or '').lower()}:{cam.index}"
457468
dlc_indicator = " [DLC]" if this_id == self._dlc_camera_id and cam.enabled else ""
458-
return f"{status} {cam.name} [{cam.backend}:{cam.index}]{dlc_indicator}"
469+
470+
trigger_role = self._trigger_role_for_label(cam)
471+
trigger_indicator = "" if trigger_role in {"off", "disabled"} else f" [{trigger_role}]"
472+
473+
return f"{status} {cam.name} [{cam.backend}:{cam.index}]{trigger_indicator}{dlc_indicator}"
459474

460475
def _selected_detected_camera(self) -> DetectedCamera | None:
461476
row = self.available_cameras_list.currentRow()
@@ -514,6 +529,9 @@ def apply(widget, feature: str, label: str, *, allow_best_effort: bool = True):
514529
apply(self.cam_exposure, "set_exposure", "Exposure")
515530
apply(self.cam_gain, "set_gain", "Gain")
516531

532+
# Hardware trigger / sync
533+
apply(self.trigger_settings_btn, "hardware_trigger", "Hardware trigger")
534+
517535
def _set_preview_button_loading(self, loading: bool) -> None:
518536
if loading:
519537
self.preview_btn.setText("Cancel Loading")
@@ -1029,6 +1047,39 @@ def _enabled_count_with(self, row: int, new_enabled: bool) -> int:
10291047
count += 1
10301048
return count
10311049

1050+
def _open_trigger_settings_dialog(self) -> None:
1051+
"""Open per-camera hardware trigger settings dialog."""
1052+
if self._current_edit_index is None:
1053+
return
1054+
1055+
row = self._current_edit_index
1056+
if row < 0 or row >= len(self._working_settings.cameras):
1057+
return
1058+
1059+
# Commit normal camera edits first so we do not lose pending UI changes.
1060+
if not self._commit_pending_edits(reason="before opening trigger settings"):
1061+
return
1062+
1063+
cam = self._working_settings.cameras[row]
1064+
1065+
dlg = TriggerConfigDialog(cam, self)
1066+
if dlg.exec() != QDialog.Accepted:
1067+
return
1068+
1069+
updated = dlg.camera_settings
1070+
1071+
self._working_settings.cameras[row] = updated
1072+
self._update_active_list_item(row, updated)
1073+
self._load_camera_to_form(updated)
1074+
1075+
# Trigger changes require reopening the camera preview/backend.
1076+
if self._is_preview_live():
1077+
self._append_status("[Trigger] Restarting preview to apply trigger settings.")
1078+
self._request_preview_restart(updated, reason="trigger-settings")
1079+
1080+
self.apply_settings_btn.setEnabled(False)
1081+
self._set_apply_dirty(False)
1082+
10321083
def _apply_camera_settings(self) -> bool:
10331084
try:
10341085
for sb in (
@@ -1085,9 +1136,7 @@ def _apply_camera_settings(self) -> bool:
10851136
old_settings = current_model
10861137

10871138
restart = False
1088-
should_consider_restart = self._preview.state == PreviewState.ACTIVE and isinstance(
1089-
old_settings, CameraSettings
1090-
)
1139+
should_consider_restart = self._is_preview_live() and isinstance(old_settings, CameraSettings)
10911140
if should_consider_restart:
10921141
restart = self._should_restart_preview(old_settings, new_model)
10931142

@@ -1099,7 +1148,7 @@ def _apply_camera_settings(self) -> bool:
10991148
new_model.index,
11001149
)
11011150

1102-
if self._preview.state == PreviewState.ACTIVE and restart:
1151+
if self._is_preview_live() and restart:
11031152
self._append_status("[Apply] Restarting preview to apply camera settings changes.")
11041153
self._request_preview_restart(new_model, reason="apply-settings")
11051154

@@ -1597,6 +1646,13 @@ def _bump_epoch(self) -> int:
15971646
self._preview.epoch += 1
15981647
return self._preview.epoch
15991648

1649+
def _trigger_dict_for_cam(self, cam: CameraSettings) -> dict:
1650+
backend = (cam.backend or "").lower()
1651+
props = cam.properties if isinstance(cam.properties, dict) else {}
1652+
ns = props.get(backend, {}) if isinstance(props.get(backend), dict) else {}
1653+
trigger = ns.get("trigger", {})
1654+
return trigger if isinstance(trigger, dict) else {}
1655+
16001656
def _should_restart_preview(self, old: CameraSettings, new: CameraSettings) -> bool:
16011657
"""
16021658
Fast UX policy:
@@ -1612,6 +1668,9 @@ def _should_restart_preview(self, old: CameraSettings, new: CameraSettings) -> b
16121668
except Exception:
16131669
return True # safest: restart
16141670

1671+
if self._trigger_dict_for_cam(old) != self._trigger_dict_for_cam(new):
1672+
return True
1673+
16151674
# No restart needed if only rotation/crop/enabled changed
16161675
return False
16171676

@@ -1756,9 +1815,10 @@ def _on_loader_finished(self, e: int) -> None:
17561815
self._preview.restart_scheduled = False
17571816
self._preview.loader = None
17581817

1759-
if pending and self._preview.state == PreviewState.IDLE:
1818+
if pending and self._preview.state in (PreviewState.IDLE, PreviewState.ACTIVE):
17601819
LOGGER.debug("[Loader] finished with pending restart for backend=%s idx=%s", pending.backend, pending.index)
17611820
self._begin_preview_load(pending, reason="pending-restart-after-finish")
1821+
return # UI sync is already handled in _begin_preview_load
17621822

17631823
self._sync_preview_ui()
17641824

0 commit comments

Comments
 (0)