Skip to content

Commit bc5da19

Browse files
committed
Replace rpicam-still/rpicam-vid with picamera2 daemon, add WebSocket live stream
Camera daemon (single-process, eliminates capture vs stream contention): - picamera2_daemon.py: standalone daemon owning Picamera2 instance - Continuous grab loop in daemon thread - Shared memory for JPEG stream (dynamically sized from stream resolution) - Unix socket IPC for controls, capture, metadata, sensor info - OSD overlay via original indi-allsky overlay modules (orb, cardinals, moon texture, Pillow text with # color/xy/anchor/size directives) - Stream resolution/quality/OSD state persisted to config DB - picamera2_client.py: zero-dependency client for capture worker and Flask - Resource tracker unregister to prevent shm deletion on client exit - libcamera.py: setCcdExposure uses daemon client instead of rpicam-still - Async capture via background thread, sync via blocking wait - Metadata from daemon instead of JSON file parsing - findCcd queries daemon for live sensor info - No colorspace conversions (BGR throughout) - stream_overlay.py: imports real overlay modules (orb, cardinals, moon), translates config from DB, renders with Pillow (TrueType fonts, Unicode) WebSocket live stream: - Flask WebSocket endpoint reads directly from daemon shared memory - Binary frame protocol: [4B JSON len][metadata JSON][JPEG data] - Metadata: exposure, gain, lux, colour_temp, sensor_temp, WB gains - Sends latest frame immediately on connect (no stall during long exposures) - Metadata RPC cached at 1s intervals (not per frame) - AllskyPlayer JS: canvas-based WS player with MJPEG fallback, OSD bar - Camera controls: unified sliders (gain, exposure, brightness, contrast, target ADU, JPEG quality), resolution selector, OSD toggle - All controls send to daemon via stream update API Flask/gunicorn: - wsgi.py: root redirect middleware for direct SSL without reverse proxy - captureapi_views.py: MJPEGStreamManager reads from daemon shared memory, _stop_allsky/_start_allsky removed (no camera contention), SensorInfoMethodView queries daemon, stream update sends to daemon - flask-sock added to requirements for WebSocket support Infrastructure: - picamera2-daemon.service: systemd unit (starts before indi-allsky) - activate-indi-allsky.sh / activate-allsky-ng.sh: switching scripts - Runs on HTTPS/443 via gunicorn SSL — no Apache/nginx needed
1 parent b611971 commit bc5da19

16 files changed

Lines changed: 3850 additions & 350 deletions
Lines changed: 364 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,364 @@
1+
"""Camera backend abstraction for the picamera2 daemon.
2+
3+
Provides a common interface for both picamera2 (preferred on Pi)
4+
and raw libcamera Python bindings (fallback / non-Pi).
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import logging
10+
import mmap
11+
import os
12+
import selectors
13+
import struct
14+
import time
15+
from typing import Any, Optional
16+
17+
import numpy as np
18+
19+
logger = logging.getLogger(__name__)
20+
21+
22+
class CameraBackend:
23+
"""Abstract camera backend — subclass for each library."""
24+
25+
def __init__(self, camera_num: int = 0) -> None:
26+
self._camera_num = camera_num
27+
self.sensor_info: dict[str, Any] = {}
28+
29+
def start(self) -> None:
30+
raise NotImplementedError
31+
32+
def stop(self) -> None:
33+
raise NotImplementedError
34+
35+
def grab_frame(self) -> tuple[np.ndarray, dict]:
36+
"""Grab one frame. Returns (bgr_array, metadata_dict)."""
37+
raise NotImplementedError
38+
39+
def set_controls(self, controls: dict[str, Any]) -> None:
40+
raise NotImplementedError
41+
42+
def capture_dng(self, path: str) -> None:
43+
raise NotImplementedError
44+
45+
46+
class Picamera2Backend(CameraBackend):
47+
"""Uses the picamera2 library (preferred on Raspberry Pi)."""
48+
49+
def __init__(self, camera_num: int = 0) -> None:
50+
super().__init__(camera_num)
51+
self._picam2: Any = None
52+
53+
def start(self) -> None:
54+
from picamera2 import Picamera2
55+
56+
self._picam2 = Picamera2(self._camera_num)
57+
58+
props = self._picam2.camera_properties
59+
sensor_name = props.get("Model", "unknown")
60+
pixel_size = props.get("UnitCellSize", (0, 0))
61+
pixel_um = pixel_size[0] / 1000.0 if pixel_size[0] else 0
62+
63+
config = self._picam2.create_still_configuration(
64+
main={"format": "RGB888"}, # actually BGR
65+
buffer_count=2,
66+
)
67+
self._picam2.configure(config)
68+
self._picam2.start()
69+
70+
controls = self._picam2.camera_controls
71+
gain_info = controls.get("AnalogueGain", (1.0, 1.0, None))
72+
exp_info = controls.get("ExposureTime", (1, 1000000, None))
73+
74+
sensor_config = self._picam2.camera_configuration()
75+
main_cfg = sensor_config.get("main", {})
76+
w = main_cfg.get("size", (0, 0))[0]
77+
h = main_cfg.get("size", (0, 0))[1]
78+
79+
cfa_info = props.get("ColorFilterArrangement")
80+
cfa_map = {0: "RGGB", 1: "GRBG", 2: "GBRG", 3: "BGGR"}
81+
cfa = cfa_map.get(cfa_info, "RGGB")
82+
83+
self.sensor_info = {
84+
"sensor_name": sensor_name,
85+
"width": w,
86+
"height": h,
87+
"pixel": pixel_um,
88+
"min_gain": float(gain_info[0]),
89+
"max_gain": float(gain_info[1]),
90+
"min_exposure": float(exp_info[0]) / 1e6,
91+
"max_exposure": float(exp_info[1]) / 1e6,
92+
"cfa": cfa,
93+
"bit_depth": 10,
94+
"backend": "picamera2",
95+
}
96+
97+
logger.info(
98+
"Picamera2 backend: %s %dx%d gain=%.1f-%.1f exp=%.6f-%.1fs",
99+
sensor_name, w, h,
100+
self.sensor_info["min_gain"], self.sensor_info["max_gain"],
101+
self.sensor_info["min_exposure"], self.sensor_info["max_exposure"],
102+
)
103+
104+
def stop(self) -> None:
105+
if self._picam2:
106+
try:
107+
self._picam2.stop()
108+
self._picam2.close()
109+
except Exception:
110+
pass
111+
self._picam2 = None
112+
113+
def grab_frame(self) -> tuple[np.ndarray, dict]:
114+
array = self._picam2.capture_array("main")
115+
metadata = self._picam2.capture_metadata()
116+
return array, metadata
117+
118+
def set_controls(self, controls: dict[str, Any]) -> None:
119+
self._picam2.set_controls(controls)
120+
121+
def capture_dng(self, path: str) -> None:
122+
self._picam2.capture_file(path, format="dng")
123+
124+
@property
125+
def sensor_modes(self) -> list:
126+
if self._picam2:
127+
return self._picam2.sensor_modes
128+
return []
129+
130+
131+
class LibcameraBackend(CameraBackend):
132+
"""Uses raw libcamera Python bindings (works on non-Pi Linux)."""
133+
134+
def __init__(self, camera_num: int = 0) -> None:
135+
super().__init__(camera_num)
136+
self._cm: Any = None
137+
self._camera: Any = None
138+
self._allocator: Any = None
139+
self._stream = None
140+
self._buffers: list = []
141+
self._sel: Any = None
142+
self._pending_controls: dict = {}
143+
self._started = False
144+
145+
def start(self) -> None:
146+
import libcamera
147+
148+
self._cm = libcamera.CameraManager.singleton()
149+
cameras = self._cm.cameras
150+
if not cameras:
151+
raise RuntimeError("No libcamera cameras found")
152+
if self._camera_num >= len(cameras):
153+
raise RuntimeError(f"Camera {self._camera_num} not found (have {len(cameras)})")
154+
155+
self._camera = cameras[self._camera_num]
156+
self._camera.acquire()
157+
158+
# Configure for viewfinder (continuous grab)
159+
config = self._camera.generate_configuration(
160+
[libcamera.StreamRole.Viewfinder]
161+
)
162+
stream_cfg = config.at(0)
163+
164+
# Get sensor properties
165+
props = self._camera.properties
166+
model = props.get(libcamera.properties.Model, "unknown")
167+
pixel_size = props.get(libcamera.properties.UnitCellSize, libcamera.Size(0, 0))
168+
pixel_um = pixel_size.width / 1000.0 if hasattr(pixel_size, 'width') else 0
169+
170+
w = stream_cfg.size.width
171+
h = stream_cfg.size.height
172+
173+
# Try to get control limits
174+
ctrl_info = self._camera.controls
175+
min_gain = 1.0
176+
max_gain = 16.0
177+
min_exp = 0.000001
178+
max_exp = 60.0
179+
180+
for ctrl_id, ctrl_range in ctrl_info.items():
181+
name = ctrl_id.name if hasattr(ctrl_id, 'name') else str(ctrl_id)
182+
if name == "AnalogueGain":
183+
min_gain = float(ctrl_range.min)
184+
max_gain = float(ctrl_range.max)
185+
elif name == "ExposureTime":
186+
min_exp = float(ctrl_range.min) / 1e6
187+
max_exp = float(ctrl_range.max) / 1e6
188+
189+
self.sensor_info = {
190+
"sensor_name": model,
191+
"width": w,
192+
"height": h,
193+
"pixel": pixel_um,
194+
"min_gain": min_gain,
195+
"max_gain": max_gain,
196+
"min_exposure": min_exp,
197+
"max_exposure": max_exp,
198+
"cfa": "RGGB",
199+
"bit_depth": 10,
200+
"backend": "libcamera",
201+
}
202+
203+
# Set pixel format to BGR (matching picamera2's RGB888 = BGR)
204+
try:
205+
stream_cfg.pixel_format = libcamera.formats.BGR888
206+
except AttributeError:
207+
# Older libcamera may not have BGR888, try RGB888
208+
try:
209+
stream_cfg.pixel_format = libcamera.formats.RGB888
210+
except AttributeError:
211+
pass # use default
212+
213+
config.validate()
214+
self._camera.configure(config)
215+
216+
# Allocate buffers
217+
self._stream = config.at(0).stream
218+
self._allocator = libcamera.FrameBufferAllocator(self._camera)
219+
num_bufs = self._allocator.allocate(self._stream)
220+
if num_bufs <= 0:
221+
raise RuntimeError("Failed to allocate libcamera buffers")
222+
self._buffers = self._allocator.buffers(self._stream)
223+
224+
# Start camera
225+
self._camera.start()
226+
self._started = True
227+
228+
# Queue all buffers
229+
for buf in self._buffers:
230+
req = self._camera.create_request()
231+
req.add_buffer(self._stream, buf)
232+
self._camera.queue_request(req)
233+
234+
# Selector for event-driven frame readout
235+
self._sel = selectors.DefaultSelector()
236+
self._sel.register(self._cm.event_fd, selectors.EVENT_READ)
237+
238+
logger.info(
239+
"libcamera backend: %s %dx%d gain=%.1f-%.1f exp=%.6f-%.1fs",
240+
model, w, h, min_gain, max_gain, min_exp, max_exp,
241+
)
242+
243+
def stop(self) -> None:
244+
if self._camera and self._started:
245+
try:
246+
self._camera.stop()
247+
except Exception:
248+
pass
249+
self._started = False
250+
if self._sel:
251+
self._sel.close()
252+
self._sel = None
253+
if self._camera:
254+
try:
255+
self._camera.release()
256+
except Exception:
257+
pass
258+
self._camera = None
259+
260+
def grab_frame(self) -> tuple[np.ndarray, dict]:
261+
"""Block until next frame, return (bgr_array, metadata)."""
262+
import libcamera
263+
264+
# Wait for frame
265+
events = self._sel.select(timeout=30)
266+
if not events:
267+
raise TimeoutError("libcamera frame timeout")
268+
269+
completed = self._cm.get_ready_requests()
270+
if not completed:
271+
raise RuntimeError("No completed requests")
272+
273+
req = completed[-1] # latest
274+
275+
# Extract metadata — raw libcamera uses ControlId objects as keys
276+
metadata = {}
277+
try:
278+
for key, value in req.metadata.items():
279+
if hasattr(key, 'name'):
280+
metadata[key.name] = value
281+
else:
282+
metadata[str(key)] = value
283+
except Exception as e:
284+
logger.debug("Metadata extraction error: %s", e)
285+
286+
# Extract frame data from buffer
287+
fb = req.buffers[self._stream]
288+
planes = fb.planes
289+
if not planes:
290+
raise RuntimeError("No planes in frame buffer")
291+
292+
plane = planes[0]
293+
w = self.sensor_info["width"]
294+
h = self.sensor_info["height"]
295+
296+
# mmap the buffer fd to get pixel data
297+
with mmap.mmap(plane.fd, plane.length, mmap.MAP_SHARED, mmap.PROT_READ,
298+
offset=plane.offset) as mm:
299+
array = np.frombuffer(mm[:w * h * 3], dtype=np.uint8).reshape(h, w, 3).copy()
300+
301+
# Re-queue the request's buffer
302+
try:
303+
new_req = self._camera.create_request()
304+
new_req.add_buffer(self._stream, fb)
305+
# Apply pending controls
306+
if self._pending_controls:
307+
for ctrl_name, val in self._pending_controls.items():
308+
try:
309+
ctrl_id = libcamera.controls.controls[ctrl_name]
310+
new_req.set_control(ctrl_id, val)
311+
except (KeyError, AttributeError):
312+
pass
313+
self._pending_controls.clear()
314+
self._camera.queue_request(new_req)
315+
except Exception:
316+
pass
317+
318+
# Release other completed requests
319+
for r in completed[:-1]:
320+
try:
321+
buf = r.buffers[self._stream]
322+
nr = self._camera.create_request()
323+
nr.add_buffer(self._stream, buf)
324+
self._camera.queue_request(nr)
325+
except Exception:
326+
pass
327+
328+
return array, metadata
329+
330+
def set_controls(self, controls: dict[str, Any]) -> None:
331+
"""Queue controls for next request."""
332+
self._pending_controls.update(controls)
333+
334+
def capture_dng(self, path: str) -> None:
335+
logger.warning("DNG capture not supported with raw libcamera backend")
336+
337+
338+
def create_backend(backend_type: str = "auto", camera_num: int = 0) -> CameraBackend:
339+
"""Factory: create camera backend.
340+
341+
backend_type: "auto" (prefer picamera2), "picamera2", "libcamera"
342+
"""
343+
if backend_type == "picamera2":
344+
return Picamera2Backend(camera_num)
345+
346+
if backend_type == "libcamera":
347+
return LibcameraBackend(camera_num)
348+
349+
# Auto: prefer picamera2, fall back to libcamera
350+
try:
351+
import picamera2
352+
logger.info("Auto-detected picamera2")
353+
return Picamera2Backend(camera_num)
354+
except ImportError:
355+
pass
356+
357+
try:
358+
import libcamera
359+
logger.info("Falling back to raw libcamera")
360+
return LibcameraBackend(camera_num)
361+
except ImportError:
362+
pass
363+
364+
raise RuntimeError("Neither picamera2 nor libcamera Python bindings found")

0 commit comments

Comments
 (0)