rpi-camera-ensemble 0.4.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. rpi_camera_ensemble/__init__.py +22 -0
  2. rpi_camera_ensemble/_version.py +24 -0
  3. rpi_camera_ensemble/acquisition/__init__.py +0 -0
  4. rpi_camera_ensemble/acquisition/acquisition.py +210 -0
  5. rpi_camera_ensemble/acquisition/camera/__init__.py +0 -0
  6. rpi_camera_ensemble/acquisition/camera/base.py +200 -0
  7. rpi_camera_ensemble/acquisition/camera/factory.py +57 -0
  8. rpi_camera_ensemble/acquisition/camera/picamera2_encoder.py +133 -0
  9. rpi_camera_ensemble/acquisition/camera/picamera2_impl.py +346 -0
  10. rpi_camera_ensemble/acquisition/camera/picamera2_streaming.py +99 -0
  11. rpi_camera_ensemble/acquisition/process/__init__.py +0 -0
  12. rpi_camera_ensemble/acquisition/process/manager.py +205 -0
  13. rpi_camera_ensemble/acquisition/process/models.py +21 -0
  14. rpi_camera_ensemble/acquisition/process/process.py +202 -0
  15. rpi_camera_ensemble/agent/__init__.py +0 -0
  16. rpi_camera_ensemble/agent/agent.py +217 -0
  17. rpi_camera_ensemble/agent/agent_api_interface.py +101 -0
  18. rpi_camera_ensemble/agent/api/__init__.py +0 -0
  19. rpi_camera_ensemble/agent/api/factory.py +45 -0
  20. rpi_camera_ensemble/agent/api/routers/__init__.py +0 -0
  21. rpi_camera_ensemble/agent/api/routers/acquisition.py +118 -0
  22. rpi_camera_ensemble/agent/api/routers/agent.py +40 -0
  23. rpi_camera_ensemble/agent/api_thread.py +48 -0
  24. rpi_camera_ensemble/agent/maintenance_threads.py +69 -0
  25. rpi_camera_ensemble/cli/__init__.py +0 -0
  26. rpi_camera_ensemble/cli/acquisition/__init__.py +0 -0
  27. rpi_camera_ensemble/cli/acquisition/acquisition.py +141 -0
  28. rpi_camera_ensemble/cli/agent/__init__.py +0 -0
  29. rpi_camera_ensemble/cli/agent/entrypoint.py +203 -0
  30. rpi_camera_ensemble/cli/agent/parser.py +113 -0
  31. rpi_camera_ensemble/cli/conductor/__init__.py +0 -0
  32. rpi_camera_ensemble/cli/conductor/entrypoint.py +121 -0
  33. rpi_camera_ensemble/cli/conductor/parser.py +70 -0
  34. rpi_camera_ensemble/conductor/__init__.py +0 -0
  35. rpi_camera_ensemble/conductor/agent_client.py +129 -0
  36. rpi_camera_ensemble/conductor/conductor.py +488 -0
  37. rpi_camera_ensemble/conductor/models.py +25 -0
  38. rpi_camera_ensemble/config/__init__.py +0 -0
  39. rpi_camera_ensemble/config/_mixin.py +108 -0
  40. rpi_camera_ensemble/config/acquisition.py +222 -0
  41. rpi_camera_ensemble/config/agent.py +33 -0
  42. rpi_camera_ensemble/config/api_models.py +39 -0
  43. rpi_camera_ensemble/config/camera/__init__.py +0 -0
  44. rpi_camera_ensemble/config/camera/camera.py +81 -0
  45. rpi_camera_ensemble/config/camera/picamera2_impl.py +141 -0
  46. rpi_camera_ensemble/config/conductor.py +42 -0
  47. rpi_camera_ensemble/io/__init__.py +7 -0
  48. rpi_camera_ensemble/io/models.py +96 -0
  49. rpi_camera_ensemble/io/session.py +210 -0
  50. rpi_camera_ensemble/io/validate.py +334 -0
  51. rpi_camera_ensemble/py.typed +0 -0
  52. rpi_camera_ensemble/utils/__init__.py +44 -0
  53. rpi_camera_ensemble/utils/log.py +74 -0
  54. rpi_camera_ensemble/utils/ttl/__init__.py +0 -0
  55. rpi_camera_ensemble/utils/ttl/emitter.py +138 -0
  56. rpi_camera_ensemble/utils/ttl/mixin.py +111 -0
  57. rpi_camera_ensemble/utils/ttl/receiver.py +177 -0
  58. rpi_camera_ensemble-0.4.3.dist-info/METADATA +289 -0
  59. rpi_camera_ensemble-0.4.3.dist-info/RECORD +62 -0
  60. rpi_camera_ensemble-0.4.3.dist-info/WHEEL +4 -0
  61. rpi_camera_ensemble-0.4.3.dist-info/entry_points.txt +4 -0
  62. rpi_camera_ensemble-0.4.3.dist-info/licenses/LICENSE +29 -0
@@ -0,0 +1,22 @@
1
+ __author__ = "Lars B. Rollik"
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+ from pathlib import Path
5
+
6
+ try:
7
+ __version__ = version(Path(__file__).parent.name)
8
+ except PackageNotFoundError:
9
+ __version__ = "0.0.0.dev0"
10
+
11
+
12
+ CURRENT_API_VERSION = "v1"
13
+ API_PREFIX = f"/api/{CURRENT_API_VERSION}"
14
+
15
+ FORMAT_DATETIME_FOR_FILENAME = "%Y%m%d%H%M%S"
16
+
17
+ __all__ = [
18
+ "API_PREFIX",
19
+ "CURRENT_API_VERSION",
20
+ "FORMAT_DATETIME_FOR_FILENAME",
21
+ "__version__",
22
+ ]
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.4.3'
22
+ __version_tuple__ = version_tuple = (0, 4, 3)
23
+
24
+ __commit_id__ = commit_id = None
File without changes
@@ -0,0 +1,210 @@
1
+ import logging
2
+ from datetime import datetime
3
+ from pathlib import Path
4
+ from typing import Any
5
+ from uuid import UUID
6
+
7
+ import yaml
8
+
9
+ from rpi_camera_ensemble.acquisition.camera.factory import create_camera
10
+ from rpi_camera_ensemble.config.acquisition import (
11
+ AcquisitionConfigsResponse,
12
+ # AcquisitionDebugInfo,
13
+ AcquisitionPaths,
14
+ AcquisitionSession,
15
+ AcquisitionSettings,
16
+ AcquisitionStatusResponse,
17
+ )
18
+ from rpi_camera_ensemble.config.camera.camera import CameraConfig
19
+ from rpi_camera_ensemble.utils.ttl.emitter import TTLEmitter, TTLEmitterBase
20
+ from rpi_camera_ensemble.utils.ttl.receiver import TTLReceiver, TTLReceiverBase
21
+
22
+
23
+ class Acquisition:
24
+ """Acquisition manages Camera & TTL objects"""
25
+
26
+ ttl_emitter: TTLEmitter | TTLEmitterBase | None = None
27
+ ttl_receiver: TTLReceiver | TTLReceiverBase | None = None
28
+
29
+ def __init__(
30
+ self,
31
+ session: AcquisitionSession,
32
+ settings: AcquisitionSettings,
33
+ paths: AcquisitionPaths,
34
+ camera_config: CameraConfig,
35
+ instance_name: str | None = None,
36
+ ) -> None:
37
+ # Inputs
38
+ self.session: AcquisitionSession = session
39
+ self.settings: AcquisitionSettings = settings
40
+ self.paths: AcquisitionPaths = paths
41
+ self.camera_config: CameraConfig = camera_config
42
+ self.instance_name: str = instance_name or "standalone"
43
+ self.logger = logging.getLogger(f"Acquisition[{self.instance_name}]")
44
+
45
+ # General settings
46
+ # TODO: max_recording_duration_sec limit for Acquisition shutdown not implemented # noqa: E501
47
+ self.max_recording_duration_sec = self.settings.max_recording_duration_sec
48
+
49
+ # Make Camera & TTL objects
50
+ camera_init_args: dict[str, Any] = {
51
+ "config": camera_config,
52
+ "instance_name": self.instance_name,
53
+ }
54
+
55
+ if self.camera_config.ttl.out_pin is not None:
56
+ self.ttl_emitter = TTLEmitter(
57
+ library=self.camera_config.ttl.library, # FIXME: value call necessary?
58
+ pin=self.camera_config.ttl.out_pin,
59
+ duration_us=self.camera_config.ttl.out_duration_us,
60
+ )
61
+
62
+ # wrapper to ignore encoder args
63
+ def pre_encoder_callback(*args, **kwargs):
64
+ self.ttl_emitter.emit()
65
+
66
+ camera_init_args["pre_encoder_callback"] = pre_encoder_callback
67
+
68
+ if self.camera_config.ttl.in_pin is not None:
69
+ self.ttl_receiver = TTLReceiver(
70
+ library=self.camera_config.ttl.library,
71
+ pin=self.camera_config.ttl.in_pin,
72
+ )
73
+
74
+ # Create camera
75
+ self.camera = create_camera(**camera_init_args)
76
+ # Write camera metadata & controls after warmup
77
+ self.write_metadata()
78
+
79
+ # Success
80
+ self.logger.info("Acquisition initialized")
81
+
82
+ def start_preview(self) -> bool:
83
+ """Start camera preview."""
84
+ return self.camera.start_preview()
85
+
86
+ def start_recording(self) -> UUID:
87
+ """Start recording session."""
88
+ # start listening to TTL-in
89
+ if self.ttl_receiver is not None and not self.ttl_receiver.is_recording():
90
+ self.ttl_receiver.start_recording()
91
+
92
+ success = self.camera.start_recording(
93
+ video_path=self.paths.video,
94
+ ttl_out_path=self.paths.ttl_out,
95
+ )
96
+ if not success:
97
+ raise RuntimeError("Camera recording failed to start")
98
+
99
+ self.logger.info(
100
+ f"Recording started (session: {self.session.acquisition_uuid})"
101
+ )
102
+ return self.session.acquisition_uuid
103
+
104
+ def stop(self, acquisition_uuid: UUID | str) -> bool:
105
+ """Stop recording and save data."""
106
+ if isinstance(acquisition_uuid, str):
107
+ acquisition_uuid = UUID(acquisition_uuid)
108
+
109
+ # Validate session ID to protect acquisition
110
+ if acquisition_uuid and acquisition_uuid != self.session.acquisition_uuid:
111
+ self.logger.error(
112
+ f"Acquisition uuid mismtach "
113
+ f"[{acquisition_uuid} != {self.session.acquisition_uuid}]"
114
+ )
115
+ return False
116
+
117
+ if not self.camera.is_previewing and not self.camera.is_recording:
118
+ self.logger.warning(
119
+ f"Acquisition stop called but status '{self.camera.status.value}'"
120
+ )
121
+ return False
122
+
123
+ # Stop camera
124
+ success = self.camera.stop()
125
+ if not success:
126
+ self.logger.error("Acquisition stop failed at camera stop")
127
+ return False
128
+
129
+ # Save TTL timestamps and metadata
130
+ if self.ttl_emitter is not None:
131
+ self.ttl_emitter.save_events(filepath=self.paths.debug_ttl_emitter)
132
+ if self.ttl_receiver is not None:
133
+ self.ttl_receiver.stop_recording()
134
+ self.ttl_receiver.save_events(
135
+ filepath=self.paths.ttl_in,
136
+ )
137
+
138
+ dts = round(
139
+ (
140
+ datetime.now()
141
+ - self.session.acquisition_start_time.replace(tzinfo=None)
142
+ ).total_seconds(),
143
+ 3,
144
+ )
145
+ # dts = round((datetime.now() - self.session.acquisition_start_time).total_seconds(), 3) # noqa: E501
146
+ self.logger.info(f"Acquisition stopped [{dts}s]")
147
+ return True
148
+
149
+ def get_status(self) -> AcquisitionStatusResponse:
150
+ """Get current acquisition status."""
151
+ status_resp = AcquisitionStatusResponse(
152
+ running=True, # as we are evidently inside Acq class
153
+ acquisition_uuid=self.session.acquisition_uuid,
154
+ instance_name=self.instance_name,
155
+ status=self.camera.status,
156
+ camera_error=self.camera.last_error or "No error",
157
+ # TODO: add actual framerate, or submodel for debug info,
158
+ # or print total frames from ttl emitter
159
+ # debug_info=AcquisitionDebugInfo(actual_framerate=FPS),
160
+ )
161
+ return status_resp
162
+
163
+ def get_config(self) -> AcquisitionConfigsResponse:
164
+ """Get acquisition configuration for metadata."""
165
+ config_dict = AcquisitionConfigsResponse(
166
+ running=True, # evidently
167
+ session=self.session,
168
+ settings=self.settings,
169
+ paths=self.paths,
170
+ camera_config=self.camera_config,
171
+ camera_controls=self.camera.get_metadata(),
172
+ )
173
+ return config_dict
174
+
175
+ def write_metadata(self) -> None:
176
+ """Write camera control metadata after warmup"""
177
+ if self.camera is not None:
178
+ control_metadata = self.camera.get_metadata(as_dict=True)
179
+ configs = self.get_config()
180
+ status = self.get_status().to_dict()
181
+
182
+ metadata = {
183
+ "timestamp_saved": datetime.now().isoformat(),
184
+ "camera_controls": control_metadata,
185
+ "configs": configs.model_dump(),
186
+ "status": status,
187
+ }
188
+ try:
189
+ with Path(self.paths.debug_acq_meta).open("w") as f:
190
+ yaml.dump(
191
+ data=metadata,
192
+ stream=f,
193
+ default_flow_style=False,
194
+ sort_keys=False,
195
+ allow_unicode=True,
196
+ )
197
+ except Exception as e:
198
+ print(e)
199
+
200
+ def cleanup(self):
201
+ """Cleanup acquisition resources."""
202
+ if self.camera:
203
+ # stop TTL listening + stop recording if active
204
+ if self.ttl_receiver is not None and self.ttl_receiver.is_recording():
205
+ self.ttl_receiver.stop_recording()
206
+
207
+ self.write_metadata()
208
+ self.camera.cleanup()
209
+
210
+ self.logger.info("Acquisition cleanup complete")
File without changes
@@ -0,0 +1,200 @@
1
+ import logging
2
+ import traceback
3
+ from abc import ABC, abstractmethod
4
+ from pathlib import Path
5
+
6
+ from rpi_camera_ensemble.config.camera.camera import CameraConfig, CameraStatus
7
+
8
+
9
+ class BaseCamera(ABC):
10
+ """Abstract base camera for hardware control with state machine."""
11
+
12
+ status: CameraStatus = CameraStatus.UNINITIALIZED
13
+ last_error: str | None = None
14
+
15
+ def __init__(
16
+ self, config: CameraConfig, instance_name: str | None = None, **kwargs
17
+ ) -> None:
18
+ """Initialize camera with config and optional TTL output callback.
19
+
20
+ Args:
21
+ config: Camera configuration (CameraConfig.get_camera_params())
22
+ """
23
+ self.config = config
24
+ self.instance_name = instance_name or "standalone"
25
+ self.logger = logging.getLogger(f"Camera[{self.instance_name}]")
26
+
27
+ try:
28
+ self._initialize_hardware()
29
+ self.status = CameraStatus.INITIALIZED
30
+ self.logger.info("Camera initialized successfully")
31
+ except Exception as e:
32
+ self._set_error_state(e)
33
+ raise
34
+
35
+ # Abstract methods for camera implementations
36
+ @abstractmethod
37
+ def _initialize_hardware(self):
38
+ """Initialize camera hardware and apply settings."""
39
+ pass
40
+
41
+ @abstractmethod
42
+ def _start_preview_impl(self) -> bool:
43
+ """Backend-specific preview start implementation."""
44
+ pass
45
+
46
+ @abstractmethod
47
+ def _start_recording_impl(
48
+ self,
49
+ video_path: str | Path,
50
+ ttl_out_path: str | Path | None = None,
51
+ ) -> bool:
52
+ """Backend-specific recording start implementation."""
53
+ pass
54
+
55
+ @abstractmethod
56
+ def _stop_impl(self):
57
+ """Backend-specific preview/recording stop implementation."""
58
+ pass
59
+
60
+ @abstractmethod
61
+ def _get_metadata_impl(self, as_dict: bool = False) -> dict:
62
+ """Backend-specific metadata get implementation."""
63
+ pass
64
+
65
+ @abstractmethod
66
+ def _cleanup_impl(self):
67
+ """Backend-specific cleanup implementation."""
68
+ pass
69
+
70
+ # Error handling helper
71
+ def _set_error_state(self, exception: Exception):
72
+ """Set error state and capture traceback."""
73
+ self.status = CameraStatus.ERROR
74
+ self.last_error = (
75
+ f"{type(exception).__name__}: {exception}\n{traceback.format_exc()}"
76
+ )
77
+ self.logger.error(f"Camera error: {exception}")
78
+
79
+ # State properties
80
+ @property
81
+ def is_initialized(self) -> bool:
82
+ return self.status == CameraStatus.INITIALIZED
83
+
84
+ @property
85
+ def is_previewing(self) -> bool:
86
+ return self.status == CameraStatus.PREVIEWING
87
+
88
+ @property
89
+ def is_recording(self) -> bool:
90
+ return self.status == CameraStatus.RECORDING
91
+
92
+ @property
93
+ def is_stopped(self) -> bool:
94
+ return self.status == CameraStatus.STOPPED
95
+
96
+ @property
97
+ def is_error(self) -> bool:
98
+ return self.status == CameraStatus.ERROR
99
+
100
+ # Public interface methods
101
+ def start_preview(self) -> bool:
102
+ """Start camera preview."""
103
+ if self.status not in [CameraStatus.INITIALIZED, CameraStatus.STOPPED]:
104
+ self.logger.warning(f"Cannot start preview from state: {self.status}")
105
+ return False
106
+
107
+ try:
108
+ success = self._start_preview_impl()
109
+ if success:
110
+ # Start streaming if configured
111
+ if getattr(self.config, "stream", None) and getattr(
112
+ self.config.stream, "enabled", False
113
+ ):
114
+ self._start_preview_impl()
115
+ self.status = CameraStatus.PREVIEWING
116
+ self.logger.info("Preview started")
117
+ return success
118
+ except Exception as e:
119
+ self._set_error_state(e)
120
+ return False
121
+
122
+ def start_recording(
123
+ self,
124
+ video_path: Path,
125
+ ttl_out_path: str | Path | None = None,
126
+ ) -> bool:
127
+ """Start video recording."""
128
+ if self.status not in [
129
+ CameraStatus.INITIALIZED,
130
+ CameraStatus.PREVIEWING,
131
+ CameraStatus.STOPPED,
132
+ ]:
133
+ self.logger.warning(f"Cannot start recording from state: {self.status}")
134
+ return False
135
+
136
+ try:
137
+ # if not already previewing, then start preview as well
138
+ if self.status != CameraStatus.PREVIEWING:
139
+ self._start_preview_impl()
140
+
141
+ success = self._start_recording_impl(
142
+ video_path=video_path,
143
+ ttl_out_path=ttl_out_path,
144
+ )
145
+ if success:
146
+ # Start streaming if configured and not already active
147
+ if (
148
+ getattr(self.config, "stream", None)
149
+ and getattr(self.config.stream, "enabled", False)
150
+ and self.status != CameraStatus.PREVIEWING
151
+ ):
152
+ self._start_preview_impl()
153
+
154
+ self.status = CameraStatus.RECORDING
155
+ self.logger.info(f"Recording started: {video_path}")
156
+ return success
157
+ except Exception as e:
158
+ self._set_error_state(e)
159
+ return False
160
+
161
+ def stop(self) -> bool:
162
+ """Stop video recording."""
163
+ if self.status not in [CameraStatus.PREVIEWING, CameraStatus.RECORDING]:
164
+ self.logger.warning(f"Cannot stop recording from state: {self.status}")
165
+ return False
166
+
167
+ try:
168
+ self._stop_impl()
169
+ self.status = CameraStatus.STOPPED
170
+ self.logger.info("Recording stopped")
171
+ return True
172
+ except Exception as e:
173
+ self._set_error_state(e)
174
+ return False
175
+
176
+ def get_metadata(self, as_dict: bool = False) -> dict:
177
+ try:
178
+ return self._get_metadata_impl(as_dict=as_dict)
179
+ except Exception as e:
180
+ self._set_error_state(e)
181
+ return {}
182
+
183
+ def cleanup(self):
184
+ """Cleanup camera resources."""
185
+ try:
186
+ if self.is_previewing or self.is_recording:
187
+ self.stop()
188
+
189
+ self._cleanup_impl()
190
+ self.status = CameraStatus.STOPPED
191
+ self.logger.info("Camera cleanup complete")
192
+ except Exception as e:
193
+ self._set_error_state(e)
194
+
195
+ # Context manager
196
+ def __enter__(self):
197
+ return self
198
+
199
+ def __exit__(self, exc_type, exc_val, exc_tb):
200
+ self.cleanup()
@@ -0,0 +1,57 @@
1
+ import logging
2
+
3
+ from rpi_camera_ensemble.acquisition.camera.base import BaseCamera
4
+ from rpi_camera_ensemble.config.camera.camera import CameraBackend, CameraConfig
5
+
6
+
7
+ class CameraFactory:
8
+ """Factory for creating camera instances with explicit backend selection."""
9
+
10
+ _logger = logging.getLogger("CameraFactory")
11
+
12
+ @classmethod
13
+ def get_backend_class(cls, backend: CameraBackend) -> type[BaseCamera]:
14
+ """Get camera implementation class for backend."""
15
+ if backend == CameraBackend.PICAMERA2:
16
+ from rpi_camera_ensemble.acquisition.camera.picamera2_impl import (
17
+ Picamera2Impl,
18
+ )
19
+
20
+ return Picamera2Impl
21
+ # NOTE: other backends go here
22
+ # elif backend == CameraBackend.PICAMERA:
23
+ # from .picamera_impl import PiCameraImpl
24
+ # return PiCameraImpl
25
+ else:
26
+ raise ValueError(f"Unknown backend: {backend}")
27
+
28
+ @classmethod
29
+ def create_camera(cls, config: CameraConfig, **kwargs) -> BaseCamera:
30
+ """Create camera instance based on explicit backend config."""
31
+ backend = config.backend
32
+ cls._logger.info(f"Creating camera with backend: {backend.value}")
33
+
34
+ try:
35
+ camera_class = cls.get_backend_class(backend)
36
+ camera = camera_class(config, **kwargs)
37
+ cls._logger.info(f"Camera created: {camera.__class__.__name__}")
38
+ return camera
39
+ except ImportError as e:
40
+ raise RuntimeError(f"Backend {backend.value} not available: {e}") from e
41
+ except Exception as e:
42
+ cls._logger.error(f"Failed to create camera: {e}")
43
+ raise
44
+
45
+ @classmethod
46
+ def is_backend_available(cls, backend: CameraBackend) -> bool:
47
+ """Check if backend is available."""
48
+ try:
49
+ cls.get_backend_class(backend)
50
+ return True
51
+ except ImportError:
52
+ return False
53
+
54
+
55
+ def create_camera(config: CameraConfig, **kwargs) -> BaseCamera:
56
+ """Convenience function to create camera."""
57
+ return CameraFactory.create_camera(config, **kwargs)
@@ -0,0 +1,133 @@
1
+ import logging
2
+ import time
3
+ from collections.abc import Callable
4
+ from pathlib import Path
5
+
6
+ import numpy as np
7
+
8
+ try:
9
+ from picamera2.encoders import H264Encoder
10
+ except ImportError as _err:
11
+ raise ImportError(
12
+ "picamera2 is required for TimestampH264Encoder. "
13
+ "Please install it via 'pip install picamera2'."
14
+ ) from _err
15
+
16
+ from rpi_camera_ensemble.config.camera.picamera2_impl import Picamera2VideoEncoder
17
+
18
+
19
+ class TimestampH264Encoder(H264Encoder):
20
+ """Timestamp-capturing encoder based on H264Encoder"""
21
+
22
+ def __init__(
23
+ self,
24
+ *args,
25
+ pre_encode_callback: Callable | None = None,
26
+ timestamp_save_path: str | Path | None = None,
27
+ instance_name: str = "",
28
+ max_frames: int = 2_000_000,
29
+ **kwargs,
30
+ ):
31
+ super().__init__(*args, **kwargs)
32
+ # inputs
33
+ self.pre_encode_callback: Callable | None = pre_encode_callback
34
+ self.timestamp_save_path = timestamp_save_path
35
+ self.instance_name = instance_name or "standalone"
36
+ assert isinstance(max_frames, int)
37
+ self.max_frames = max_frames
38
+
39
+ # vars
40
+ self.frame_count = 0
41
+ # Pre-allocate for 2 hours at 120Hz + 10% buffer
42
+ self.timestamps = np.zeros((self.max_frames, 2), dtype=np.int64)
43
+ self.timestamps.fill(-1)
44
+ self.logger = logging.getLogger(
45
+ f"{self.__class__.__name__}[{self.instance_name}]"
46
+ )
47
+
48
+ # def set_callback(self, callback: Callable):
49
+ # """Set the pre-encode callback."""
50
+ # self.pre_encode_callback = callback
51
+
52
+ def encode(self, stream, request):
53
+ """
54
+ Override picamera2 base encoder's `encode` method
55
+ with timestamp capture logic.
56
+ """
57
+ # 1) Call callback with encode signature
58
+ if self.pre_encode_callback:
59
+ self.pre_encode_callback(self, stream, request)
60
+
61
+ # 2) Store timestamps
62
+ self._store_timestamp(request)
63
+
64
+ # 3) Call super class encode
65
+ return super().encode(stream, request)
66
+
67
+ def _store_timestamp(self, request):
68
+ """Helper method to store timestamps in numpy array."""
69
+ if self.frame_count < len(self.timestamps):
70
+ sensor_ts = request.get_metadata().get("SensorTimestamp", -1)
71
+ monotonic_ts = time.monotonic_ns()
72
+
73
+ self.timestamps[self.frame_count, 0] = sensor_ts
74
+ self.timestamps[self.frame_count, 1] = monotonic_ts
75
+ self.frame_count += 1
76
+
77
+ def get_timestamps(self):
78
+ """Return captured timestamps, trimmed to actual count."""
79
+ return self.timestamps[: self.frame_count, :]
80
+
81
+ def save_timestamps(self, path: str | None = None):
82
+ """Save timestamps to numpy file."""
83
+ save_path = path or self.timestamp_save_path
84
+ if save_path:
85
+ ts = self.get_timestamps()
86
+ np.savez(
87
+ save_path,
88
+ sensor_ts=ts[:, 0],
89
+ monotonic_ts=ts[:, 1],
90
+ )
91
+ self.logger.info(
92
+ f"Saved frame timestamps to '{str(save_path)}' [frames = {ts.shape[0]}]"
93
+ )
94
+
95
+ def __del__(self):
96
+ """Auto-save timestamps on destruction if path provided."""
97
+ if self.timestamp_save_path:
98
+ self.save_timestamps()
99
+
100
+
101
+ class EncoderFactory:
102
+ """Factory for creating timestamp-capturing encoders."""
103
+
104
+ @staticmethod
105
+ def create(
106
+ encoder: Picamera2VideoEncoder = Picamera2VideoEncoder.H264_TTL,
107
+ pre_encode_callback: Callable | None = None,
108
+ timestamp_save_path: str | Path | None = None,
109
+ *args,
110
+ **kwargs,
111
+ ):
112
+ """
113
+ Create TSEncoder subclass of the given encoder.
114
+
115
+ Args:
116
+ encoder: Encoder class to subclass (default: Picamera2VideoEncoder.H264)
117
+ pre_encode_callback: Callback called before each encode
118
+ timestamp_save_path: Path to save timestamps on exit
119
+ *args, **kwargs: Arguments for encoder constructor
120
+ """
121
+ if encoder == Picamera2VideoEncoder.H264_TTL:
122
+ ts_encoder = TimestampH264Encoder(
123
+ *args,
124
+ pre_encode_callback=pre_encode_callback,
125
+ timestamp_save_path=timestamp_save_path,
126
+ **kwargs,
127
+ )
128
+
129
+ return ts_encoder
130
+ else:
131
+ raise NotImplementedError(
132
+ "Only TimestampH264Encoder is currently supported."
133
+ )