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 @@
-
+
+
+ Camera + + +
Sensor
-
Resolution
+
+ Resolution + + +
Flips