-
Notifications
You must be signed in to change notification settings - Fork 28
Separate recording logic from GUI #92
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
23 commits
Select commit
Hold shift + click to select a range
eefcb66
Lock processor settings during DLC inference
C-Achard 39edcd7
Improve processor discovery and logging
C-Achard 7b601d2
Add processors package exports
C-Achard b6fd701
Move example socket processors to examples module
C-Achard 8ff43e0
Skip socket base module in processor scan
C-Achard 2ce7696
Update processor_utils.py
C-Achard a0e637e
Warn on duplicate processor registration
C-Achard d2f827c
Fix dlclive Processor import paths
C-Achard 4801de2
Extract processor registry into new module
C-Achard e63ede7
Refactor camera worker and recording pipeline
C-Achard 55c3f7e
Route recording frames through recording sink
C-Achard 6146d7d
Update recording manager test imports
C-Achard bdae9c8
Propagate capture metadata in single-camera flow
C-Achard 4624747
Add timestamp metadata to frame signal
C-Achard 4168e05
Comment previous signals
C-Achard a0c9fa5
Fix dispatcher lifecycle and add flush API
C-Achard 4cedf3d
Update tests for CapturedFrame integration
C-Achard ec6571d
Stabilize recording and camera tests
C-Achard 58c2e89
Harden recording dispatcher shutdown flow
C-Achard df31298
Retry recorder stop until success
C-Achard b6fb344
Disable debug timing log
C-Achard 8cd58ec
Add timeout for recorder stop retries
C-Achard ad60136
Rename recording frame toggle API
C-Achard File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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, | ||
| ) |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.