Camera backends
A camera backend is a small class with five methods — open, close, configure, set_controls, capture_frame — and a capabilities attribute. Exactly one instance is created at startup and owned by exactly one CaptureWorker for the process lifetime; capture_frame() is blocking and is called only from that worker's thread.
Every backend hands back a RawFrame:
@dataclass(frozen=True)
class RawFrame:
image: np.ndarray # already RGB
exposure_us: int
analogue_gain: float
sensor_timestamp_ns: int
captured_at: datetimeimage is RGB, already debayered. For this generation of the stack, backends are expected to hand back ISP-processed frames (picamera2's main stream, or OpenCV's converted BGR). A raw-Bayer capture path with its own debayer step is a future extension, not something the pipeline currently carries.
Selecting a backend
camera.backend in the config takes one of three values — mock, picamera2, opencv — and selection is explicit, never a silent fallback:
def create_camera_backend(cfg: CameraConfig) -> CameraBackend:
if cfg.backend == "mock": ...
if cfg.backend == "opencv": ...
if cfg.backend == "picamera2": ... # imported lazily, here
raise ValueError(f"Unknown camera backend: {cfg.backend!r}")picamera2 is imported inside the branch, so a development machine without it installed can import the package freely as long as it never selects that backend.
mock
A synthetic-frame backend for development and CI. It builds a fixed 320×240 scene — a horizon brightness gradient plus fifteen stars placed from a fixed seed — and scales it by (exposure_us / 10000) * analogue_gain with a little Gaussian noise on top, so the exposure feedback loop has something real to converge against.
Its resolution is deliberately fixed and independent of camera.resolution; configure() is a no-op. This backend exists to exercise the control loop, the event bus, the derivative pool and the API quickly and deterministically, not to simulate a sensor's pixel count.
picamera2
The Raspberry Pi camera module backend, via picamera2/libcamera. It is only importable where the picamera2 extra is installed (ARM-only — the dependency carries an aarch64/armv7l marker), and if you select it without that package present you get a clear RuntimeError rather than a mysterious import failure.
open() builds a still configuration with main={"size": resolution} and starts the camera. set_controls() sets three controls together:
self._picam2.set_controls({
"AeEnable": False, # the app owns exposure, never the camera
"ExposureTime": exposure_us,
"AnalogueGain": analogue_gain,
})capture_frame() takes a request, pulls the main array and the request metadata, then releases the request. If the platform's default format includes an alpha channel it is dropped, keeping RGB order. Exposure, gain and the sensor timestamp are read back from libcamera's metadata, so the values stored with the frame are what the sensor actually applied, not what was requested.
WARNING
This backend has no automated test coverage — there is no Pi camera in CI. Validate it against real hardware, particularly the channel order and the alpha-channel handling in capture_frame().
opencv
A generic UVC/V4L2 webcam backend. Its purpose is to let the entire pipeline — capture loop, sky-state driven policy, derivatives, API, web UI — be exercised on an ordinary PC with a webcam plugged in, with no astro hardware required.
Device selection
camera.sensor_id doubles as the V4L2 device selector. The backend tries to parse it as an integer; if that succeeds it is used as a device index, otherwise it is passed through as an explicit device path.
{
"camera": {
"backend": "opencv",
"sensor_id": "0",
"resolution": [1280, 720]
}
}"0"→ device index 0, i.e./dev/video0"/dev/video2"→ that exact device node"cam0"(the schema default) → passed through as a path, which will not open; change it when switching to this backend
The capture object is opened with cv2.VideoCapture(device, cv2.CAP_V4L2), and a device that will not open raises RuntimeError: Could not open V4L2 camera at ....
Resolution negotiation
camera.resolution (default [4056, 3040], sized for an HQ camera sensor) is requested via CAP_PROP_FRAME_WIDTH / CAP_PROP_FRAME_HEIGHT, then read back. Webcams routinely ignore the request and pick the nearest mode they support. If the read-back differs from the request, the backend logs
Camera '0' negotiated (640, 480) instead of requested (4056, 3040)and adopts the negotiated size as both its own resolution and its reported CameraCapabilities.max_resolution. Nothing downstream breaks — statistics, thumbnails and derivatives all work from the actual array shape — but check the log if frames are smaller than you expected.
Exposure and gain
open() sets CAP_PROP_AUTO_EXPOSURE to 1, V4L2's V4L2_EXPOSURE_MANUAL enum value, which OpenCV's Linux V4L2 backend passes through directly. If the driver refuses, you get:
Camera '0' did not accept manual-exposure mode — AE may still be activeset_controls() then writes CAP_PROP_EXPOSURE in units of 100 µs (the common UVC convention once manual mode is engaged) and CAP_PROP_GAIN as the raw gain value. The values reported back in the RawFrame are the last values set, not values read back from the device, and sensor_timestamp_ns is 0 — UVC devices do not expose a hardware timestamp worth trusting.
WARNING
Manual exposure and gain support varies enormously across consumer webcam drivers — units, ranges, and whether manual mode is honoured at all are all device- and driver-specific. Treat this backend as a pipeline-testing tool, not as a source of accurate photometry.
The CAELUM_CAMERA_BACKEND override
config.json's camera.backend is authoritative. Setting the environment variable CAELUM_CAMERA_BACKEND overrides it for one run:
backend_name = settings.camera_backend_override or cfg.camera.backend
camera = create_camera_backend(cfg.camera.model_copy(update={"backend": backend_name}))Only backend is overridden — sensor_id and resolution still come from the config. The variable is unset by default and there is deliberately no auto-detection, so a misconfigured Pi fails loudly instead of quietly capturing synthetic frames for a week.
CAELUM_CAMERA_BACKEND=opencv uv run caelumTesting with a plain USB webcam
This is the fastest way to exercise the real capture path without astro hardware:
CAELUM_CAMERA_BACKEND=opencv uv run caelumwith camera.sensor_id set to "0" in your config.
The daylight preset will look broken indoors
The default day exposure preset is 100 µs to 10 ms at 1–2× gain. That is tuned for a genuinely bright daytime sky through an allsky lens, and pointed at an ordinary lit room it produces a black frame — the control loop is working exactly as configured, there simply is not enough light.
Two ways out. Widen the preset in a local test config:
{
"exposure_policy": {
"presets": {
"day": { "exposure_us_min": 1000, "exposure_us_max": 200000, "gain_min": 1.0, "gain_max": 8.0 }
}
}
}Or bypass the loop entirely with a manual override:
curl -X POST localhost:8000/api/camera/exposure-override \
-H 'content-type: application/json' \
-d '{"exposure_us": 100000}'Clear it again with {"clear": true}. While an override is active the closed loop is not consulted at all, and GET /api/status reports manual_exposure_override: true.