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.
- rpi_camera_ensemble/__init__.py +22 -0
- rpi_camera_ensemble/_version.py +24 -0
- rpi_camera_ensemble/acquisition/__init__.py +0 -0
- rpi_camera_ensemble/acquisition/acquisition.py +210 -0
- rpi_camera_ensemble/acquisition/camera/__init__.py +0 -0
- rpi_camera_ensemble/acquisition/camera/base.py +200 -0
- rpi_camera_ensemble/acquisition/camera/factory.py +57 -0
- rpi_camera_ensemble/acquisition/camera/picamera2_encoder.py +133 -0
- rpi_camera_ensemble/acquisition/camera/picamera2_impl.py +346 -0
- rpi_camera_ensemble/acquisition/camera/picamera2_streaming.py +99 -0
- rpi_camera_ensemble/acquisition/process/__init__.py +0 -0
- rpi_camera_ensemble/acquisition/process/manager.py +205 -0
- rpi_camera_ensemble/acquisition/process/models.py +21 -0
- rpi_camera_ensemble/acquisition/process/process.py +202 -0
- rpi_camera_ensemble/agent/__init__.py +0 -0
- rpi_camera_ensemble/agent/agent.py +217 -0
- rpi_camera_ensemble/agent/agent_api_interface.py +101 -0
- rpi_camera_ensemble/agent/api/__init__.py +0 -0
- rpi_camera_ensemble/agent/api/factory.py +45 -0
- rpi_camera_ensemble/agent/api/routers/__init__.py +0 -0
- rpi_camera_ensemble/agent/api/routers/acquisition.py +118 -0
- rpi_camera_ensemble/agent/api/routers/agent.py +40 -0
- rpi_camera_ensemble/agent/api_thread.py +48 -0
- rpi_camera_ensemble/agent/maintenance_threads.py +69 -0
- rpi_camera_ensemble/cli/__init__.py +0 -0
- rpi_camera_ensemble/cli/acquisition/__init__.py +0 -0
- rpi_camera_ensemble/cli/acquisition/acquisition.py +141 -0
- rpi_camera_ensemble/cli/agent/__init__.py +0 -0
- rpi_camera_ensemble/cli/agent/entrypoint.py +203 -0
- rpi_camera_ensemble/cli/agent/parser.py +113 -0
- rpi_camera_ensemble/cli/conductor/__init__.py +0 -0
- rpi_camera_ensemble/cli/conductor/entrypoint.py +121 -0
- rpi_camera_ensemble/cli/conductor/parser.py +70 -0
- rpi_camera_ensemble/conductor/__init__.py +0 -0
- rpi_camera_ensemble/conductor/agent_client.py +129 -0
- rpi_camera_ensemble/conductor/conductor.py +488 -0
- rpi_camera_ensemble/conductor/models.py +25 -0
- rpi_camera_ensemble/config/__init__.py +0 -0
- rpi_camera_ensemble/config/_mixin.py +108 -0
- rpi_camera_ensemble/config/acquisition.py +222 -0
- rpi_camera_ensemble/config/agent.py +33 -0
- rpi_camera_ensemble/config/api_models.py +39 -0
- rpi_camera_ensemble/config/camera/__init__.py +0 -0
- rpi_camera_ensemble/config/camera/camera.py +81 -0
- rpi_camera_ensemble/config/camera/picamera2_impl.py +141 -0
- rpi_camera_ensemble/config/conductor.py +42 -0
- rpi_camera_ensemble/io/__init__.py +7 -0
- rpi_camera_ensemble/io/models.py +96 -0
- rpi_camera_ensemble/io/session.py +210 -0
- rpi_camera_ensemble/io/validate.py +334 -0
- rpi_camera_ensemble/py.typed +0 -0
- rpi_camera_ensemble/utils/__init__.py +44 -0
- rpi_camera_ensemble/utils/log.py +74 -0
- rpi_camera_ensemble/utils/ttl/__init__.py +0 -0
- rpi_camera_ensemble/utils/ttl/emitter.py +138 -0
- rpi_camera_ensemble/utils/ttl/mixin.py +111 -0
- rpi_camera_ensemble/utils/ttl/receiver.py +177 -0
- rpi_camera_ensemble-0.4.3.dist-info/METADATA +289 -0
- rpi_camera_ensemble-0.4.3.dist-info/RECORD +62 -0
- rpi_camera_ensemble-0.4.3.dist-info/WHEEL +4 -0
- rpi_camera_ensemble-0.4.3.dist-info/entry_points.txt +4 -0
- 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
|
+
)
|