Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion apps/ctt-server/ctt_server/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
MJPEG_CONTENT_TYPE,
CameraError,
get_shared_camera,
list_cameras,
platform_target,
reload_shared_camera,
)
Expand Down Expand Up @@ -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

Expand Down
71 changes: 61 additions & 10 deletions apps/ctt-server/ctt_server/camera.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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': [
Expand All @@ -504,20 +513,51 @@ 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:
raise CameraError(f'Failed to start the camera: {err}') from err
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.

Expand Down Expand Up @@ -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).

Expand All @@ -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'
Expand Down
2 changes: 1 addition & 1 deletion apps/ctt-server/ctt_server/static/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
53 changes: 49 additions & 4 deletions apps/ctt-server/ctt_server/static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand All @@ -179,6 +179,8 @@ function captureApp(cfg) {
hflip: false,
vflip: false,
previewTick: 0, // bumped to reconnect the MJPEG <img> after a camera reconfigure
cameras: [], // list of all cameras detected on the server
cameraNum: 0, // currently selected camera index

async init() {
this.updateCounts();
Expand All @@ -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;
Expand All @@ -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();
}
},
Expand All @@ -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 <img> 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 <img> 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');
Expand Down Expand Up @@ -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
Expand Down
24 changes: 22 additions & 2 deletions apps/ctt-server/ctt_server/templates/capture.html
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,29 @@
</div>
</div>
<div class="caminfo" x-show="connected" x-cloak>
<div class="caminfo-row" style="grid-template-columns: repeat(5, 1fr)">
<div class="caminfo-row" style="grid-template-columns: repeat(6, 1fr)">
<div>
<span class="k">Camera</span>
<select class="mode-sel" x-show="cameras.length > 1" @change="switchCamera($event.target.value)">
<template x-for="c in cameras" :key="c.num">
<option :value="c.num" :selected="c.num === cameraNum"
x-text="c.num + ' · ' + c.model"></option>
</template>
</select>
<span class="v" x-show="!(cameras.length > 1)" x-text="camera.model || '—'"></span>
</div>
<div><span class="k">Sensor</span><span class="v" x-text="camera.model || '—'"></span></div>
<div><span class="k">Resolution</span><span class="v" x-text="camera.resolution ? camera.resolution[0] + ' × ' + camera.resolution[1] : '—'"></span></div>
<div style="grid-column: span 2">
<span class="k">Resolution</span>
<select class="mode-sel" x-show="camera.modes.length > 1" @change="switchMode($event.target.value.split('x')[0], $event.target.value.split('x')[1])">
<template x-for="m in camera.modes" :key="m.size[0] + 'x' + m.size[1]">
<option :value="m.size[0] + 'x' + m.size[1]"
:selected="camera.resolution && m.size[0] === camera.resolution[0] && m.size[1] === camera.resolution[1]"
x-text="m.size[0] + ' × ' + m.size[1] + ' · ' + m.bit_depth + '‑bit · ' + m.fps.toFixed(0) + ' fps'"></option>
</template>
</select>
<span class="v" x-show="!(camera.modes.length > 1)" x-text="camera.resolution ? camera.resolution[0] + ' × ' + camera.resolution[1] : '—'"></span>
</div>
<div>
<span class="k">Flips</span>
<div class="seg compact" style="align-self:flex-start">
Expand Down