diff --git a/apps/ctt-server/ctt_server/app.py b/apps/ctt-server/ctt_server/app.py
index 1237db0..0462074 100644
--- a/apps/ctt-server/ctt_server/app.py
+++ b/apps/ctt-server/ctt_server/app.py
@@ -34,6 +34,7 @@
MJPEG_CONTENT_TYPE,
CameraError,
get_shared_camera,
+ list_cameras,
platform_target,
reload_shared_camera,
)
@@ -239,7 +240,30 @@ def images_page(name: str):
@app.route('/api/health')
def api_health():
try:
- return jsonify({'camera': True, **get_shared_camera().health()})
+ cam = get_shared_camera()
+ return jsonify({
+ 'camera': True,
+ 'cameras': list_cameras(),
+ **cam.health(),
+ })
+ except CameraError as err:
+ return jsonify({'camera': False, 'error': str(err)}), 503
+
+ @app.route('/api/cameras')
+ def api_cameras():
+ return jsonify(list_cameras())
+
+ @app.route('/api/camera', methods=['POST'])
+ def api_switch_camera():
+ """Switch to a different camera by its index number."""
+ body = request.get_json(force=True) or {}
+ try:
+ camera_num = int(body['camera_num'])
+ except (KeyError, TypeError, ValueError):
+ return jsonify({'error': 'camera_num (int) is required'}), 400
+ try:
+ cam = get_shared_camera(camera_num=camera_num)
+ return jsonify({'camera': True, **cam.health()})
except CameraError as err:
return jsonify({'camera': False, 'error': str(err)}), 503
diff --git a/apps/ctt-server/ctt_server/camera.py b/apps/ctt-server/ctt_server/camera.py
index 4ba7e37..a16c458 100644
--- a/apps/ctt-server/ctt_server/camera.py
+++ b/apps/ctt-server/ctt_server/camera.py
@@ -50,7 +50,9 @@ class Picamera2Camera:
viewfinder's field of view is exactly what the captured DNG covers.
"""
- def __init__(self, preview_max_width: int = 1920, tuning_file: str | None = None) -> None:
+ def __init__(
+ self, preview_max_width: int = 1920, tuning_file: str | None = None, camera_num: int = 0
+ ) -> None:
try:
from picamera2 import Picamera2 # noqa: PLC0415 (lazy import)
except ImportError as err: # pragma: no cover - depends on Pi environment
@@ -64,19 +66,20 @@ def __init__(self, preview_max_width: int = 1920, tuning_file: str | None = None
Picamera2.set_logging(logging.WARNING) # quieten Picamera2's own INFO chatter (once)
_picamera2_logging_quieted = True
+ self.camera_num = camera_num
self._lock = threading.Lock()
self._ev = 0.0 # exposure compensation (EV); tracked here as metadata may omit it
self._auto = True # AeEnable state; tracked so we report it reliably (AeLocked is ambiguous)
- self._fps = 30.0 # framerate target; 0 = unconstrained (variable frame duration)
+ self._fps = 0.0 # framerate target; 0 = unconstrained — set by _apply_mode or user
# Optionally start the pipeline with a specific tuning file (e.g. a freshly
# generated CTT tuning, for the Results-page live preview test). None = the
# camera's built-in default tuning.
self.tuning_file = tuning_file
if tuning_file:
tuning = Picamera2.load_tuning_file(os.path.basename(tuning_file), dir=os.path.dirname(tuning_file))
- self._picam2 = Picamera2(tuning=tuning)
+ self._picam2 = Picamera2(tuning=tuning, camera_num=camera_num)
else:
- self._picam2 = Picamera2()
+ self._picam2 = Picamera2(camera_num=camera_num)
try:
self.model = self._picam2.camera_properties.get('Model', 'unknown')
# Advertised sensor modes, one entry per raw size (preferring the deeper
@@ -133,6 +136,11 @@ def _apply_mode(self, mode: dict) -> None:
prev_w = min(self._preview_max_width, w) & ~1
prev_h = int(round(prev_w * h / w)) & ~1
self._preview_size = (prev_w, prev_h)
+ # Adopt the mode's maximum framerate so the user starts at the best
+ # rate for the new resolution, not a stale target from a prior mode.
+ mode_fps = mode.get('fps', 0)
+ if mode_fps:
+ self._fps = mode_fps
def _sensor_config(self) -> dict:
# sensor= pins the actual camera mode (readout/binning) so libcamera
@@ -479,6 +487,7 @@ def capture_burst(self, frames: int, quality: int = 95) -> list[tuple[bytes, byt
def health(self) -> dict:
return {
+ 'camera_num': self.camera_num,
'model': self.model,
'resolution': list(self.resolution),
'modes': [
@@ -504,13 +513,22 @@ def close(self) -> None:
_SHARED_LOCK = threading.Lock()
-def get_shared_camera() -> Picamera2Camera:
- """Return a process-wide singleton camera (one Picamera2 per process)."""
+def get_shared_camera(camera_num: int | None = None) -> Picamera2Camera:
+ """Return a process-wide singleton camera (one Picamera2 per process).
+
+ When *camera_num* is given and differs from the currently open camera, the
+ current camera is closed and reopened on the requested index. When
+ *camera_num* is None the current camera is returned unchanged (or the first
+ available camera on the first call).
+ """
global _SHARED
with _SHARED_LOCK:
+ if camera_num is not None and _SHARED is not None and _SHARED.camera_num != camera_num:
+ _SHARED.close()
+ _SHARED = None
if _SHARED is None:
try:
- _SHARED = Picamera2Camera()
+ _SHARED = Picamera2Camera(camera_num=camera_num or 0)
except CameraError:
raise
except Exception as err:
@@ -518,6 +536,28 @@ def get_shared_camera() -> Picamera2Camera:
return _SHARED
+def list_cameras() -> list[dict]:
+ """Return every camera detected by libcamera on this Pi.
+
+ Each entry has ``num``, ``model``, ``id``, ``location`` and ``rotation``
+ fields, suitable for the UI camera selector.
+ """
+ try:
+ from picamera2 import Picamera2 # noqa: PLC0415
+ except ImportError:
+ return []
+ return [
+ {
+ 'num': c['Num'],
+ 'model': c.get('Model', 'unknown'),
+ 'id': c.get('Id', ''),
+ 'location': c.get('Location', 0),
+ 'rotation': c.get('Rotation', 0),
+ }
+ for c in Picamera2.global_camera_info()
+ ]
+
+
def validate_tuning_file(path: str, timeout: float = 60.0) -> None:
"""Test-load a tuning file by bringing the camera up in a throwaway subprocess.
@@ -559,7 +599,8 @@ def validate_tuning_file(path: str, timeout: float = 60.0) -> None:
def reload_shared_camera(
- tuning_file: str | None = None, preview_max_width: int = 1920, validate: bool = False
+ tuning_file: str | None = None, preview_max_width: int = 1920,
+ validate: bool = False, camera_num: int | None = None,
) -> Picamera2Camera:
"""Restart the shared camera with a given tuning file (None = default tuning).
@@ -568,24 +609,34 @@ def reload_shared_camera(
capture page's "restore default" call cheap. validate=True canary-tests the
tuning in a subprocess first (see validate_tuning_file) — use it for any
tuning that isn't known-good, e.g. hand-edited custom files.
+
+ When *camera_num* is given, also switches to that camera index.
"""
global _SHARED
with _SHARED_LOCK:
+ if camera_num is not None and _SHARED is not None and _SHARED.camera_num != camera_num:
+ _SHARED.close()
+ _SHARED = None
if _SHARED is not None and _SHARED.tuning_file == tuning_file:
return _SHARED
if _SHARED is not None:
_SHARED.close()
_SHARED = None
+ target_num = camera_num if camera_num is not None else 0
try:
if validate and tuning_file is not None:
validate_tuning_file(tuning_file)
- _SHARED = Picamera2Camera(preview_max_width=preview_max_width, tuning_file=tuning_file)
+ _SHARED = Picamera2Camera(
+ preview_max_width=preview_max_width, tuning_file=tuning_file, camera_num=target_num,
+ )
except Exception as err:
# The requested tuning failed to load: reopen with the default
# tuning so the camera isn't left dead, then report.
if tuning_file is not None:
with contextlib.suppress(Exception):
- _SHARED = Picamera2Camera(preview_max_width=preview_max_width)
+ _SHARED = Picamera2Camera(
+ preview_max_width=preview_max_width, camera_num=target_num,
+ )
if isinstance(err, CameraError):
raise
tuning_name = os.path.basename(tuning_file) if tuning_file else 'the default tuning'
diff --git a/apps/ctt-server/ctt_server/static/app.css b/apps/ctt-server/ctt_server/static/app.css
index c45f135..40b8ba5 100644
--- a/apps/ctt-server/ctt_server/static/app.css
+++ b/apps/ctt-server/ctt_server/static/app.css
@@ -430,4 +430,4 @@ td.mono { font-family: var(--mono); }
}
/* sensor-mode dropdown in the preview info box */
-.caminfo .mode-sel { width: auto; padding: 3px 8px; font-size: 0.85rem; align-self: flex-start; }
+.caminfo .mode-sel { width: auto; max-width: 100%; padding: 3px 8px; font-size: 0.85rem; align-self: flex-start; }
diff --git a/apps/ctt-server/ctt_server/static/app.js b/apps/ctt-server/ctt_server/static/app.js
index 2a3ca72..a04bfb5 100644
--- a/apps/ctt-server/ctt_server/static/app.js
+++ b/apps/ctt-server/ctt_server/static/app.js
@@ -168,8 +168,8 @@ function captureApp(cfg) {
counts: { macbeth: 0, alsc: 0, cac: 0, dark: 0 },
form: { image_type: 'macbeth', colour_temp: 6500, lux: 1000, frames: 1 },
controls: { exposure: 10000, gain: 1.0, auto_exposure: true, colour_temp: 0, lux: 0, ev: 0 },
- fpsTarget: 30, // framerate target; 0 = unconstrained (variable frame duration)
- camera: { model: '', resolution: null },
+ fpsTarget: 0, // framerate target; 0 = unconstrained — set by loadControls or user
+ camera: { model: '', resolution: null, modes: [] },
metered: { exposure: 0, gain: 0, colour_temp: 0, lux: 0, focus_fom: 0 },
clip: { r: 0, g: 0, b: 0 },
macbeth: { found: false, confidence: null, corners: null, small: false, saturated: false },
@@ -179,6 +179,8 @@ function captureApp(cfg) {
hflip: false,
vflip: false,
previewTick: 0, // bumped to reconnect the MJPEG after a camera reconfigure
+ cameras: [], // list of all cameras detected on the server
+ cameraNum: 0, // currently selected camera index
async init() {
this.updateCounts();
@@ -189,8 +191,11 @@ function captureApp(cfg) {
const r = await fetch('/api/health');
const h = await r.json();
this.connected = !!h.camera;
+ if (h.cameras) this.cameras = h.cameras;
+ this.cameraNum = h.camera_num || 0;
if (h.model) this.camera.model = h.model;
if (h.resolution) this.camera.resolution = h.resolution;
+ if (h.modes) this.camera.modes = h.modes;
if (h.controls) this.metered = h.controls;
} catch (e) {
this.connected = false;
@@ -202,7 +207,7 @@ function captureApp(cfg) {
try { await fetch('/api/preview-default', { method: 'POST' }); } catch (e) { /* best effort */ }
// The reload reset the sensor flip; re-enforce the stored choice.
if (this.hflip || this.vflip) await this.applyTransform();
- this.loadControls();
+ await this.loadControls();
this.pollHistogram();
}
},
@@ -219,6 +224,46 @@ function captureApp(cfg) {
} catch (e) { this.error = 'Failed to set flip'; }
},
+ async switchCamera(num) {
+ this.busy = true; this.error = '';
+ try {
+ const r = await fetch('/api/camera', {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ camera_num: Number(num) }),
+ });
+ const h = await r.json();
+ if (!h.camera) { this.error = h.error || 'Failed to switch camera'; return; }
+ this.connected = true;
+ this.cameraNum = h.camera_num || 0;
+ this.camera.model = h.model || '';
+ this.camera.resolution = h.resolution;
+ if (h.modes) this.camera.modes = h.modes;
+ if (h.controls) this.metered = h.controls;
+ this.previewTick = Date.now(); // force the MJPEG
to reopen on the new camera
+ // Re-assert the stored flip choice on the new camera.
+ if (this.hflip || this.vflip) await this.applyTransform();
+ await this.loadControls();
+ } catch (e) { this.error = 'Failed to switch camera'; }
+ finally { this.busy = false; }
+ },
+
+ async switchMode(width, height) {
+ this.busy = true; this.error = '';
+ try {
+ const r = await fetch('/api/mode', {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ width: Number(width), height: Number(height) }),
+ });
+ const h = await r.json();
+ if (!h.resolution) { this.error = h.error || 'Failed to set mode'; return; }
+ this.camera.resolution = h.resolution;
+ if (h.modes) this.camera.modes = h.modes;
+ this.previewTick = Date.now(); // force the MJPEG
to reopen on the new mode
+ await this.loadControls(); // controls may change with mode (frame rate limits etc.)
+ } catch (e) { this.error = 'Failed to switch sensor mode'; }
+ finally { this.busy = false; }
+ },
+
async loadLightbox() {
try {
const r = await fetch('/api/lightbox');
@@ -765,7 +810,7 @@ function resultsApp(cfg) {
camera: {}, // preview page: {model, resolution} for the live sensor-info box
metered: { exposure: 0, gain: 0, colour_temp: 0, lux: 0 }, // live metered values
controls: { auto_exposure: true, exposure: 0, gain: 1, ev: 0 }, // exposure panel state
- fpsTarget: 30, // framerate target; 0 = unconstrained (variable frame duration)
+ fpsTarget: 0, // framerate target; 0 = unconstrained — set by loadControls or user
lightbox: { present: false, channel: null, intensity: 0, illuminants: {} }, // optional lightbox device
colour: null, // current live ΔE reading (for whichever tuning is loaded)
liveColour: true, // semi-live colour measurement while previewing
diff --git a/apps/ctt-server/ctt_server/templates/capture.html b/apps/ctt-server/ctt_server/templates/capture.html
index b88f17c..f557180 100644
--- a/apps/ctt-server/ctt_server/templates/capture.html
+++ b/apps/ctt-server/ctt_server/templates/capture.html
@@ -37,9 +37,29 @@