diff --git a/apps/ctt_server/app.py b/apps/ctt_server/app.py index 92230b7..4cf72dc 100644 --- a/apps/ctt_server/app.py +++ b/apps/ctt_server/app.py @@ -32,7 +32,7 @@ from devices import LightboxError, LightmeterError, get_shared_lightbox, get_shared_lightmeter -from . import auto_capture, colour_check, ctt_runner, mtf, results +from . import auto_capture, auto_characterise, characterise, colour_check, ctt_runner, mtf, results from .camera import ( MJPEG_CONTENT_TYPE, CameraError, @@ -78,6 +78,44 @@ def _run_info(available: dict) -> dict: return info +# Colour metrics that are invalid (and dropped) when there is too little light. +_COLOUR_KEYS = ( + 'cct', + 'duv', + 'cie1931_xy', + 'cie1976_uv', + 'cie1960_uv', + 'tristimulus_xyz', + 'dominant_wavelength', + 'excitation_purity', + 'cri_ra', + 'cri_ri', + 'spectrum_5nm', + 'spectrum_1nm', +) + + +def _capped_reading(measurement, limits) -> dict: + """A reading dict that never exceeds the meter's limits. + + Out-of-range illuminance is clamped to the nearest bound (so the API never emits + the device's sentinels, e.g. -100 lx), and colour metrics are dropped when there is + less light than they need (below `colour_min_lux`), since they are meaningless there. + `in_range` stays on the dict so the UI can render the capped value as "< min"/"> max". + """ + reading = measurement.to_dict() + if limits is None: + return reading + lux = measurement.illuminance_lux + if not measurement.in_range: + reading['illuminance_lux'] = min(max(lux, limits.illuminance_min), limits.illuminance_max) + reading.pop('illuminance_fc', None) + if not measurement.in_range or lux < limits.colour_min_lux: + for key in _COLOUR_KEYS: + reading.pop(key, None) + return reading + + def _serialise_captures(project: Project) -> list[dict]: """Capture metadata for the browser, with a per-file validity flag.""" out = [] @@ -228,15 +266,20 @@ def lightmeter_status() -> dict: def lightmeter_reading_or_none() -> dict | None: """A fresh reading for tagging a capture, or None. Never raises: a capture - must succeed unchanged when no meter is present or the reading fails.""" + must succeed unchanged when no meter is present or the reading fails, and an + out-of-range reading is treated as no reading so garbage is never recorded.""" meter = lightmeter_or_none() if meter is None: return None try: - reading = meter.measure().to_dict() + measurement = meter.measure() except LightmeterError as err: logger.warning('capture: light-meter reading failed: %s', err) return None + if not measurement.in_range: + logger.warning('capture: light-meter reading out of range; not recorded') + return None + reading = measurement.to_dict() # Keep the project sidecar compact: the 5 nm spectrum is plenty. reading.pop('spectrum_1nm', None) return reading @@ -497,7 +540,7 @@ def api_sample_lightmeter(): return jsonify({'error': f'unknown action {action!r}'}), 400 except LightmeterError as err: return jsonify({'error': str(err)}), 400 - return jsonify({'present': True, **meter.info(), 'reading': reading.to_dict()}) + return jsonify({'present': True, **meter.info(), 'reading': _capped_reading(reading, meter.limits)}) # --- auto-capture cycle -------------------------------------------------- def sse_response(events): @@ -973,6 +1016,122 @@ def mtf_measure(name: str): return jsonify({'error': 'MTF measurement failed (is the capture a valid DNG?)'}), 500 return jsonify({'rois': results_list}) + # --- sensor characterisation -------------------------------------------- + # Offline analysis of the project's existing captures (dark bursts, ALSC + # flat fields, Macbeth bursts). Results live in /characterisation/, + # which calibration runs never scan — the same isolation as /mtf/. + + @app.route('/projects//characterisation') + def characterisation_page(name: str): + proj = get_project_or_404(name) + return render_template('characterisation.html', project=proj) + + @app.route('/projects//characterisation/data') + def characterisation_data(name: str): + proj = get_project_or_404(name) + return jsonify({'results': characterise.read_results(proj), **characterise.quick_scan(proj)}) + + @app.route('/projects//characterisation/analyse/stream') + def characterisation_stream(name: str): + """Run an analysis, streaming progress over SSE (EventSource is GET-only). + + mode=offline (default) walks the project's existing DNGs; mode=live + drives the camera through a manual exposure sweep (gains, points and + frames from the query string) for the sweep-derived metrics. + """ + proj = get_project_or_404(name) + if request.args.get('mode') == 'live': + camera = camera_or_503() + gains = [float(g) for g in request.args.get('gains', '1').split(',') if g.strip()] + lines = characterise.sweep_stream( + proj, + camera, + gains, + points_per_gain=int(request.args.get('points', 10)), + frames=int(request.args.get('frames', 8)), + ) + else: + lines = characterise.analyse_stream(proj) + + def generate(): + for line in lines: + yield f'data: {json.dumps(line)}\n\n' + + return Response( + stream_with_context(generate()), + mimetype='text/event-stream', + headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'}, + ) + + @app.route('/projects//auto-characterise/stream') + def auto_characterise_stream(name: str): + """Run the gap-driven auto-characterise cycle, streaming progress as SSE. + + Validation and missing-device failures are streamed as an error event rather + than returned as a 4xx (EventSource cannot read error bodies). + """ + proj = get_project_or_404(name) + + def error_events(message: str): + return iter([{'event': 'error', 'error': message}, {'event': 'done', 'ok': False, 'cancelled': False}]) + + try: + gains = [float(g) for g in request.args.get('gains', '1').split(',') if g.strip()] + except ValueError: + return sse_response(error_events('invalid gains')) + if not gains: + return sse_response(error_events('no gains selected')) + lamp = request.args.get('lamp', '').strip() + if not lamp: + return sse_response(error_events('no lamp selected')) + intensity = request.args.get('intensity') + + def flag(key: str) -> bool: + return request.args.get(key, '1') in ('1', 'true', 'yes') + + box = lightbox_or_none() + if box is None: + return sse_response(error_events('No lightbox detected')) + meter = lightmeter_or_none() + if meter is None: + return sse_response(error_events('No light meter detected')) + try: + camera = get_shared_camera() + except CameraError as err: + return sse_response(error_events(str(err))) + return sse_response( + auto_characterise.run_auto_characterise_stream( + proj, + camera, + box, + meter, + gains, + lamp, + intensity=float(intensity) if intensity else None, + include_darks=flag('darks'), + include_flats=flag('flats'), + include_sweep=flag('sweep'), + sweep_points=int(request.args.get('sweep_points', 10)), + sweep_frames=int(request.args.get('sweep_frames', 8)), + dark_frames=int(request.args.get('dark_frames', 16)), + flat_frames=int(request.args.get('flat_frames', 16)), + ) + ) + + @app.route('/projects//auto-characterise/continue', methods=['POST']) + def auto_characterise_continue(name: str): + get_project_or_404(name) + if not auto_characterise.request_continue(): + abort(409, 'No auto-characterise cycle is waiting for the lens cap') + return jsonify({'ok': True}) + + @app.route('/projects//auto-characterise/cancel', methods=['POST']) + def auto_characterise_cancel(name: str): + get_project_or_404(name) + if not auto_characterise.request_cancel(): + abort(409, 'No auto-characterise cycle is running') + return jsonify({'ok': True}) + @app.route('/projects//results/data') def results_data(name: str): proj = get_project_or_404(name) diff --git a/apps/ctt_server/auto_capture.py b/apps/ctt_server/auto_capture.py index 2d54a38..147bcf8 100644 --- a/apps/ctt_server/auto_capture.py +++ b/apps/ctt_server/auto_capture.py @@ -105,6 +105,7 @@ class AutoControl: class StabiliseResult(NamedTuple): reading: Measurement | None # None: cancelled or the meter kept failing timed_out: bool + error: str | None = None # why no reading (meter failure / under range), for the caller def is_running() -> bool: @@ -214,15 +215,37 @@ def _stabilise(lightmeter, illuminant: str, cfg: StabiliseConfig, cancel: thread start = time.monotonic() window: list[Measurement] = [] failures = 0 + last_error = 'no usable light-meter reading' while not cancel.is_set(): tick = time.monotonic() try: reading = lightmeter.measure() except LightmeterError as err: failures += 1 + last_error = 'light-meter read failed' logger.warning('auto-capture: meter reading failed (%d/%d): %s', failures, cfg.max_read_failures, err) if failures >= cfg.max_read_failures: + return StabiliseResult(None, False, last_error) + if cancel.wait(max(0.0, cfg.sample_interval_s - (time.monotonic() - tick))): return StabiliseResult(None, False) + continue + # An out-of-range reading is not a measurement; treat it like a read failure + # (a persistently dark scene fails the step with a clear "add light" message). + if not reading.in_range: + failures += 1 + last_error = 'light is below the meter’s measurable range — add more light' + yield { + 'event': 'reading', + 'illuminant': illuminant, + 'lux': reading.illuminance_lux, + 'cct': reading.cct, + 'stable_count': 0, + 'needed': cfg.window, + 'elapsed_s': round(time.monotonic() - start, 1), + 'in_range': False, + } + if failures >= cfg.max_read_failures: + return StabiliseResult(None, False, last_error) if cancel.wait(max(0.0, cfg.sample_interval_s - (time.monotonic() - tick))): return StabiliseResult(None, False) continue @@ -240,6 +263,7 @@ def _stabilise(lightmeter, illuminant: str, cfg: StabiliseConfig, cancel: thread 'stable_count': stable, 'needed': cfg.window, 'elapsed_s': round(elapsed, 1), + 'in_range': True, } if len(window) == cfg.window and stable == cfg.window: yield { @@ -315,7 +339,7 @@ def _capture_lamp(project, camera, lightbox, lightmeter, lamp, frames, cfg, adju if control.cancel.is_set(): return LampOutcome(cancelled=True) if result.reading is None: - return LampOutcome(error='no usable light-meter reading') + return LampOutcome(error=result.error or 'no usable light-meter reading') reading_obj = result.reading if result.timed_out: warnings.append(f'{lamp.illuminant}: meter did not stabilise within {cfg.timeout_s:.0f} s') diff --git a/apps/ctt_server/auto_characterise.py b/apps/ctt_server/auto_characterise.py new file mode 100644 index 0000000..0be1718 --- /dev/null +++ b/apps/ctt_server/auto_characterise.py @@ -0,0 +1,300 @@ +# SPDX-License-Identifier: BSD-2-Clause +# +# Copyright (C) 2026, Raspberry Pi +# +# Gap-driven automated sensor characterisation over a lightbox and a light meter. +# +# run_auto_characterise_stream() completes a characterisation with the least work: it +# inventories what the project already holds (characterise.quick_scan + results.json), +# then only captures or sweeps what is missing, matched by operating point (gain). The +# lightbox provides steady illumination for the sweep and flat-field captures; the light +# meter gates each on stabilisation (reusing auto_capture._stabilise). Dark frames are +# captured last, after a lens-cap pause. +# +# Like the other long-running flows it yields structured event dicts for the browser to +# render; the reused characterise.sweep_stream / analyse_stream emit plain lines, which +# are wrapped as 'log' events. At most one cycle runs at a time (module lock), and it +# refuses to start alongside a calibration or another characterisation. + +from __future__ import annotations + +import contextlib +import logging +import threading +from collections.abc import Iterator +from typing import TYPE_CHECKING + +from devices import LightboxError + +from . import characterise, ctt_runner +from .auto_capture import AutoControl, StabiliseConfig, _stabilise, save_burst +from .camera import CameraError + +if TYPE_CHECKING: + from .sessions import Project + +logger = logging.getLogger(__name__) + +_run_lock = threading.Lock() +_control: AutoControl | None = None + + +def is_running() -> bool: + return _run_lock.locked() + + +def request_cancel() -> bool: + """Signal the running cycle to stop; True if one was running.""" + control = _control + if control is None: + return False + control.cancel.set() + control.proceed.set() # unblock a lens-cap wait so the cancel is seen promptly + return True + + +def request_continue() -> bool: + """Release a cycle paused at the lens-cap prompt; True if one was waiting.""" + control = _control + if control is None or not control.waiting: + return False + control.proceed.set() + return True + + +def _gaps(project: Project, gains: list[float], tol: float) -> list[dict]: + """Per requested gain, decide whether each input is present (reuse) or missing. + + Darks/flats are matched to an existing capture group at the same gain; the sweep is + considered present when results.json holds a reliable PTC fit at that gain and is not + stale (new captures since the last analysis invalidate it). + """ + scan = characterise.quick_scan(project) + groups = scan['groups'] + stale = scan['stale'] + results = characterise.read_results(project) + fits = results['ptc']['fits'] if results else [] + + def has_group(kind: str, gain: float) -> bool: + return any(g['kind'] == kind and abs(g['gain'] - gain) <= tol for g in groups) + + def has_sweep(gain: float) -> bool: + return not stale and any(abs(f['gain'] - gain) <= tol and f.get('reliable') for f in fits) + + return [ + { + 'gain': gain, + 'darks': 'reuse' if has_group('dark', gain) else 'capture', + 'flats': 'reuse' if has_group('flat', gain) else 'capture', + 'sweep': 'reuse' if has_sweep(gain) else 'sweep', + } + for gain in gains + ] + + +def _wrap_char_stream(lines: Iterator[str]) -> Iterator[dict]: + """Forward a CHAR_EXIT-terminated line stream as 'log' events; return success.""" + ok = False + for line in lines: + if line.startswith('CHAR_EXIT '): + ok = line.strip().endswith('0') + continue + yield {'event': 'log', 'line': line} + return ok + + +def _lamp_cct(lightbox, lamp, reading) -> int | None: + """Colour temperature to tag a flat with: the measured reading if available, else + the lamp's nominal CCT from the lightbox.""" + if reading is not None: + return round(reading.cct) + with contextlib.suppress(Exception): + info = lightbox.info() + channel = info['channel'] + temps = info.get('illuminant_temps', {}) + return temps.get(channel) or temps.get(str(channel)) + return None + + +def _prep_dark(camera, gain: float) -> None: + """Manual, shortest exposure at the requested gain — standard for read noise/DSNU.""" + exp_min = camera.get_controls().get('exposure_min') or 100 + camera.set_controls({'auto_exposure': False, 'exposure': int(exp_min), 'gain': float(gain), 'fps': 0, 'awb': False}) + + +def _prep_flat(camera, gain: float, roi_fraction: float = 0.5) -> None: + """Manual gain, exposed to ~50 % of the DN swing and verified unclipped, for PRNU.""" + exp_min = camera.get_controls().get('exposure_min') or 100 + camera.set_controls({'auto_exposure': False, 'gain': float(gain), 'exposure': int(exp_min), 'fps': 0, 'awb': False}) + exposure = characterise.flat_exposure(camera, float(gain), roi_fraction) + camera.set_controls( + {'auto_exposure': False, 'gain': float(gain), 'exposure': int(exposure), 'fps': 0, 'awb': False} + ) + + +def run_auto_characterise_stream( + project: Project, + camera, + lightbox, + lightmeter, + gains: list[float], + lamp: str, + intensity: float | None = None, + include_darks: bool = True, + include_flats: bool = True, + include_sweep: bool = True, + dark_frames: int = 16, + flat_frames: int = 16, + sweep_points: int = 10, + sweep_frames: int = 8, + cfg: StabiliseConfig | None = None, + gain_tol: float = 0.05, +) -> Iterator[dict]: + """Run the gap-driven auto-characterise cycle, yielding structured progress events. + + Lit steps (sweep, flats) come first while the lamp is on; dark frames are captured + last after the box is switched off and the user has fitted the lens cap. Existing + inputs at a matching gain are reused. A camera failure aborts; other per-step failures + are reported and the cycle continues. Exactly one terminal event: 'done' or 'error'. + """ + global _control + cfg = cfg or StabiliseConfig() + gains = sorted({round(float(g), 4) for g in gains}) or [1.0] + + if ctt_runner.is_running() or characterise._char_lock.locked(): + yield {'event': 'error', 'error': 'a calibration or characterisation is already running'} + return + if not _run_lock.acquire(blocking=False): + yield {'event': 'error', 'error': 'an auto-characterise cycle is already running'} + return + control = _control = AutoControl() + prev = None + reused: list[str] = [] + captured: list[str] = [] + swept: list[float] = [] + warnings: list[str] = [] + fatal = normal_finish = False + try: + plan = _gaps(project, gains, gain_tol) + yield {'event': 'start', 'gains': gains, 'lamp': lamp} + yield {'event': 'plan', 'gains': plan} + + missing_sweep = [p['gain'] for p in plan if p['sweep'] == 'sweep'] if include_sweep else [] + missing_flats = [p['gain'] for p in plan if p['flats'] == 'capture'] if include_flats else [] + missing_darks = [p['gain'] for p in plan if p['darks'] == 'capture'] if include_darks else [] + for p in plan: + if include_sweep and p['sweep'] == 'reuse': + reused.append(f'sweep g{p["gain"]:g}') + if include_flats and p['flats'] == 'reuse': + reused.append(f'flat g{p["gain"]:g}') + if include_darks and p['darks'] == 'reuse': + reused.append(f'dark g{p["gain"]:g}') + + prev = camera.get_controls() + + # --- lit steps: sweep then flats (lamp on, meter-gated) ---------------- + reading = None + if missing_sweep or missing_flats: + yield {'event': 'phase', 'name': 'illuminating', 'lamp': lamp} + try: + lightbox.set_illuminant(lamp, intensity) + except LightboxError as err: + fatal = True + yield {'event': 'error', 'error': f'lightbox: {err}'} + if not fatal: + result = yield from _stabilise(lightmeter, lamp, cfg, control.cancel) + reading = result.reading + + if missing_sweep and not fatal and not control.cancel.is_set(): + yield {'event': 'phase', 'name': 'sweep', 'gains': missing_sweep} + ok = yield from _wrap_char_stream( + characterise.sweep_stream( + project, camera, missing_sweep, points_per_gain=sweep_points, frames=sweep_frames + ) + ) + if ok: + swept.extend(missing_sweep) + else: + warnings.append('sweep did not complete cleanly') + + if missing_flats and not fatal and not control.cancel.is_set(): + cct = _lamp_cct(lightbox, lamp, reading) + for gain in missing_flats: + if control.cancel.is_set(): + break + yield {'event': 'phase', 'name': 'flat', 'gain': gain} + try: + _prep_flat(camera, gain) + shots = camera.capture_burst(flat_frames) + except CameraError as err: + fatal = True + yield {'event': 'error', 'error': f'camera failure: {err}'} + break + caps = save_burst(project, shots, 'alsc', cct) + captured.append(f'flat g{gain:g}') + yield {'event': 'captured', 'kind': 'flat', 'gain': gain, 'added': [c.filename for c in caps]} + + # --- dark frames last: box off, lens-cap pause ------------------------- + if missing_darks and not fatal and not control.cancel.is_set(): + with contextlib.suppress(LightboxError): + lightbox.off() + control.proceed.clear() + control.waiting = True + try: + while not control.cancel.is_set(): + yield {'event': 'waiting_user', 'message': 'Fit the lens cap, then press Continue.'} + if control.proceed.wait(15): + break + finally: + control.waiting = False + for gain in missing_darks: + if control.cancel.is_set(): + break + yield {'event': 'phase', 'name': 'dark', 'gain': gain} + try: + _prep_dark(camera, gain) + shots = camera.capture_burst(dark_frames) + except CameraError as err: + fatal = True + yield {'event': 'error', 'error': f'camera failure: {err}'} + break + caps = save_burst(project, shots, 'dark', None) + captured.append(f'dark g{gain:g}') + yield {'event': 'captured', 'kind': 'dark', 'gain': gain, 'added': [c.filename for c in caps]} + + # --- merge newly-captured darks/flats into results (the sweep self-merges) --- + if captured and not fatal and not control.cancel.is_set(): + yield {'event': 'phase', 'name': 'analyse'} + yield from _wrap_char_stream(characterise.analyse_stream(project)) + + if not fatal: + cancelled = control.cancel.is_set() + if cancelled: + yield {'event': 'cancelled'} + yield { + 'event': 'done', + 'ok': not cancelled, + 'reused': reused, + 'captured': captured, + 'swept': swept, + 'warnings': warnings, + 'cancelled': cancelled, + } + normal_finish = not cancelled + finally: + if prev is not None: + with contextlib.suppress(Exception): + camera.set_controls( + { + 'auto_exposure': prev.get('auto_exposure', True), + 'exposure': prev.get('exposure'), + 'gain': prev.get('gain'), + 'fps': prev.get('fps', 30), + 'awb': True, + } + ) + if not normal_finish: + with contextlib.suppress(Exception): + lightbox.off() + _control = None + _run_lock.release() diff --git a/apps/ctt_server/camera.py b/apps/ctt_server/camera.py index 1265df3..72d1ffc 100644 --- a/apps/ctt_server/camera.py +++ b/apps/ctt_server/camera.py @@ -422,6 +422,114 @@ def _chart_too_small(self, corners, frame_w: int, frame_h: int) -> bool: pitch_y = chart_h / 2 / 4 # half-res pitch across 4 patch rows return min(pitch_x, pitch_y) < self._MIN_PITCH + # --- raw burst capture (sensor characterisation) ------------------------ + # Bayer offsets of the Gr sample (the green sharing a row with red) within + # the 2x2 tile, keyed by the CFA order encoded in the raw format string. + _GR_OFFSET = {'RGGB': (0, 1), 'GRBG': (0, 0), 'BGGR': (1, 0), 'GBRG': (1, 1)} + + def _configured_raw_format(self) -> str: + """The raw stream format actually configured (may differ from the request). + + Requesting the mode's unpacked format (e.g. 'SRGGB12') can be satisfied + with a deeper container (e.g. 'SRGGB16', samples already left-justified + to 16 bits) — the delivered format decides the sample scaling. + """ + try: + return str(self._picam2.camera_configuration()['raw']['format']) + except Exception: # pragma: no cover - defensive: fall back to the request + return self._raw_format or '' + + def _gr_offset(self, fmt: str) -> tuple[int, int]: + for pattern, offset in self._GR_OFFSET.items(): + if pattern in fmt: + return offset + return (0, 0) # mono (or unknown): a single plane, any offset works + + def _raw_frame(self, request): + """The request's raw plane as a full-resolution uint16 array. + + The raw stream is configured unpacked (one sample per 16-bit word), but + make_array can hand it back as bytes with row-stride padding; view as + uint16 and crop to the active area. + """ + import numpy as np # noqa: PLC0415 + + arr = request.make_array('raw') + if arr.dtype == np.uint8: + arr = arr.view(np.uint16) + w, h = self._raw_size + return arr[:h, :w] + + def capture_raw_burst(self, frames: int, exposure_us: int, gain: float, roi_fraction: float = 0.5) -> dict: + """A burst of Gr-channel ROI crops at one manual operating point (DN16). + + The caller must already have auto-exposure/AWB off and the frame + duration unconstrained (set_controls). Sets the requested exposure and + gain, discards frames until the metadata readback matches the request + (controls take a few frames to land — trust readbacks, not requests), + then grabs the burst from the running video config: no mode switch, no + DNG on disk, memory bounded by ROI x frames. Samples are left-shifted + to the 16-bit domain of ctt.characterisation. + + Returns {'frames', 'exposure_us', 'gain', 'blacklevel_16', 'sigbits'} + with the *readback* exposure/gain and the pipeline's reported black + level (libcamera quotes SensorBlackLevels in the 16-bit domain). + """ + import numpy as np # noqa: PLC0415 + + frames = max(1, min(int(frames), 32)) + with self._lock: + self._picam2.set_controls({'ExposureTime': int(exposure_us), 'AnalogueGain': float(gain)}) + metadata = None + for _ in range(12): # settle: controls typically land within 1-3 frames + request = self._picam2.capture_request() + metadata = request.get_metadata() + request.release() + applied_exp = int(metadata.get('ExposureTime', 0)) + applied_gain = float(metadata.get('AnalogueGain', 0.0)) + exp_ok = abs(applied_exp - exposure_us) <= max(0.02 * exposure_us, 25) + gain_ok = abs(applied_gain - gain) <= 0.02 * gain + if exp_ok and gain_ok: + break + else: + raise CameraError( + f'controls did not settle: requested {exposure_us} us / gain {gain:.2f}, ' + f'applied {applied_exp} us / {applied_gain:.2f} ' + '(exposure may exceed the frame duration or the sensor limits)' + ) + + # Sample scaling follows the *delivered* stream format: 'SRGGB16' + # samples are already left-justified 16-bit, 'SRGGB12' needs << 4. + fmt = self._configured_raw_format() + digits = ''.join(c for c in fmt if c.isdigit()) + stream_bits = int(digits) if digits else 16 + shift = max(0, 16 - stream_bits) + dy, dx = self._gr_offset(fmt) + crops = [] + for _ in range(frames): + request = self._picam2.capture_request() + try: + plane = self._raw_frame(request)[dy::2, dx::2] + if crops == []: + from ctt.characterisation import centre_roi # noqa: PLC0415 + + roi = centre_roi(plane.shape, roi_fraction) + crops.append(np.left_shift(plane[roi].astype(np.uint16), shift)) + metadata = request.get_metadata() + finally: + request.release() + # SensorBlackLevels: per-channel, 16-bit domain; fall back to 4096 + # (a 12-bit pedestal of 256) only if the pipeline omits it. + blacks = metadata.get('SensorBlackLevels') + blacklevel_16 = float(blacks[0]) if blacks else 4096.0 + return { + 'frames': crops, + 'exposure_us': int(metadata.get('ExposureTime', exposure_us)), + 'gain': float(metadata.get('AnalogueGain', gain)), + 'blacklevel_16': blacklevel_16, + 'sigbits': stream_bits, + } + # --- DNG capture ------------------------------------------------------- def capture_dng(self) -> tuple[bytes, dict]: with self._lock: diff --git a/apps/ctt_server/characterise.py b/apps/ctt_server/characterise.py new file mode 100644 index 0000000..796f590 --- /dev/null +++ b/apps/ctt_server/characterise.py @@ -0,0 +1,660 @@ +# SPDX-License-Identifier: BSD-2-Clause +# +# Copyright (C) 2026, Raspberry Pi +# +# Offline sensor characterisation over a project's existing captures. +# +# Orchestrates ctt.characterisation against the DNGs already in a project: +# dark bursts give the black-level pedestal, DSNU and direct read noise; ALSC +# flat-field bursts give PRNU and photon-transfer points; Macbeth bursts add +# show-only PTC points. Results persist to /characterisation/ +# results.json — a subdirectory, so calibration runs never see it. Full +# PTC/linearity/full-well/gain-sweep metrics need live exposure sweeps and are +# reported as unavailable until that capture path exists. + +from __future__ import annotations + +import contextlib +import json +import threading +from collections.abc import Iterator +from dataclasses import asdict +from datetime import UTC, datetime +from pathlib import Path + +import numpy as np + +from ctt.algorithms.black_level import measure_dark_image +from ctt.characterisation import ( + CaptureGroup, + FrameSet, + PtcPoint, + centre_roi, + fit_ptc, + frameset_from_dngs, + gr_plane, + ptc_point, + scan_project, + spatial_stats, + temporal_stats, +) +from ctt.characterisation.ptc import dynamic_range, full_well, linearity, snr_curve +from ctt.core.image_loader import dng_load_image + +from .sessions import Project + +_RESULTS_DIRNAME = 'characterisation' +_RESULTS_FILENAME = 'results.json' +RESULTS_VERSION = 1 + +# Analysis loads every DNG in the project; only one walk at a time (and never +# alongside a calibration run — both read the same files on a small machine). +_char_lock = threading.Lock() + + +def is_running() -> bool: + return _char_lock.locked() + + +def results_path(project: Project) -> Path: + return project.path / _RESULTS_DIRNAME / _RESULTS_FILENAME + + +def _now_iso() -> str: + return datetime.now(UTC).isoformat(timespec='seconds') + + +def _fingerprint(groups: list[CaptureGroup]) -> list[dict]: + out = [] + for g in groups: + for p in g.paths: + stat = p.stat() + out.append({'name': p.name, 'mtime': stat.st_mtime, 'size': stat.st_size}) + return sorted(out, key=lambda e: e['name']) + + +def _excluded(project: Project) -> set[str]: + return {c.filename for c in project.captures if c.excluded} + + +def _group_summary(g: CaptureGroup) -> dict: + return { + 'label': g.label, + 'kind': g.kind, + 'frames': len(g.paths), + 'exposure_us': g.exposure_us, + 'gain': g.gain, + 'width': g.width, + 'height': g.height, + 'sigbits': g.sigbits, + 'blacklevel': g.blacklevel, + 'colour_temp': g.colour_temp, + 'lux': g.lux, + 'warnings': list(g.warnings), + } + + +def read_results(project: Project) -> dict | None: + path = results_path(project) + if not path.exists(): + return None + try: + return json.loads(path.read_text()) + except (OSError, json.JSONDecodeError): + return None + + +# Page-load scans are EXIF-only but still ~100 ms per DNG; cache per project, +# keyed by the directory's stat fingerprint, so unchanged projects are free. +_scan_cache: dict[str, tuple] = {} + + +def quick_scan(project: Project) -> dict: + """A fast inventory of the project's characterisation inputs + staleness. + + EXIF-only (no pixel data) and cached against the directory's stat + fingerprint; the staleness flag compares the current fingerprint with the + one recorded in the persisted results. + """ + excluded = _excluded(project) + stat_key = tuple((p.name, p.stat().st_mtime, p.stat().st_size) for p in sorted(project.path.glob('*.dng'))) + tuple( + sorted(excluded) + ) + cached = _scan_cache.get(project.name) + if cached is not None and cached[0] == stat_key: + summaries, fingerprint = cached[1], cached[2] + else: + groups = scan_project(project.path, excluded) + summaries = [_group_summary(g) for g in groups] + fingerprint = _fingerprint(groups) + _scan_cache[project.name] = (stat_key, summaries, fingerprint) + results = read_results(project) + stale = results is not None and results.get('inputs') != fingerprint + return { + 'groups': summaries, + 'has_results': results is not None, + 'stale': stale, + } + + +def _dark_analysis(group: CaptureGroup, roi_fraction: float) -> tuple[dict, FrameSet]: + """Pedestal (shared measurement), read noise and DSNU from a dark burst. + + Loads each frame once: the per-channel pedestal reuses + ctt.algorithms.black_level.measure_dark_image — identical by construction + with the /blacklevel endpoint and calibration runs — while the Gr ROI crop + feeds the temporal/spatial statistics. + """ + frames = [] + pedestals = [] + roi = None + first = None + for path in group.paths: + img = dng_load_image(None, str(path), demosaic=False) + pedestals.append(measure_dark_image(img)) + plane = gr_plane(img) + if roi is None: + roi = centre_roi(plane.shape, roi_fraction) + first = img + frames.append(plane[roi].copy()) + fs = FrameSet( + frames=frames, + exposure_us=first.exposure, + gain=first.againQ8_norm, + blacklevel_16=float(first.blacklevel_16), + sigbits=first.sigbits, + channel='Y' if first.pattern == 128 else 'Gr', + label=group.label, + sources=[p.name for p in group.paths], + ) + ts = temporal_stats(fs) + ss = spatial_stats(fs) + measured = float(np.mean([p['black_level'] for p in pedestals])) + dark = { + 'available': True, + 'label': group.label, + 'pedestal': { + 'black_level_16': round(measured, 1), + 'metadata_black_level_16': fs.blacklevel_16, + 'frames': pedestals, + }, + 'read_noise_dn': round(float(np.sqrt(ts.var_dn2)), 2) if ts else None, + 'read_noise_e': None, # filled in when a reliable conversion gain exists + 'dsnu_dn': round(ss.residual_std_dn, 2), + 'dsnu_e': None, + 'exposure_us': group.exposure_us, + 'gain': group.gain, + 'n_frames': len(group.paths), + 'channel': fs.channel, + } + if ts is None: + dark['warnings'] = ['single dark frame: no temporal read noise'] + return dark, fs + + +def analyse_stream(project: Project, roi_fraction: float = 0.5) -> Iterator[str]: + """Run the offline analysis, yielding progress lines; ends with CHAR_EXIT . + + The full walk loads every usable DNG once (roughly 0.5-1 s each on a Pi), + so this streams over SSE exactly like a calibration run. + """ + from . import ctt_runner # local import to avoid a cycle at module load + + if ctt_runner.is_running(): + yield 'ERROR: a calibration is running; wait for it to finish' + yield 'CHAR_EXIT 2' + return + if not _char_lock.acquire(blocking=False): + yield 'ERROR: a characterisation analysis is already running' + yield 'CHAR_EXIT 2' + return + existing = read_results(project) # to carry a prior live sweep across this re-analysis + try: + yield f'$ characterise (offline) project={project.name} roi={roi_fraction:.2f}' + groups = scan_project(project.path, _excluded(project)) + if not groups: + yield 'ERROR: no usable captures found (need dark/, alsc_ or Macbeth DNG bursts)' + yield 'CHAR_EXIT 1' + return + + results: dict = { + 'version': RESULTS_VERSION, + 'generated_at': _now_iso(), + 'roi_fraction': roi_fraction, + 'inputs': _fingerprint(groups), + 'camera': None, + 'groups': [_group_summary(g) for g in groups], + 'dark': {'available': False, 'unavailable_reason': 'no dark frames captured'}, + 'ptc': {'points': [], 'fits': [], 'unavailable_reason': None}, + 'prnu': {'available': False, 'groups': [], 'best_pct': None}, + 'warnings': [], + } + for g in groups: + for w in g.warnings: + results['warnings'].append({'group': g.label, 'message': w}) + + yield f'Found {len(groups)} capture groups' + + # --- darks: pedestal, read noise, DSNU (the foundation) ------------- + darks = [g for g in groups if g.kind == 'dark'] + dark_fs = None + dark_group = None + if darks: + dark_group = max(darks, key=lambda g: len(g.paths)) + yield f'\t{dark_group.label}: {len(dark_group.paths)} dark frames (pedestal, read noise, DSNU)' + dark, dark_fs = _dark_analysis(dark_group, roi_fraction) + results['dark'] = dark + yield ( + f'\t\tblack level {dark["pedestal"]["black_level_16"]:.0f} DN16' + f' · read noise {dark["read_noise_dn"]} DN16' + f' · DSNU {dark["dsnu_dn"]} DN16' + ) + for extra in darks: + if extra is not dark_group: + yield f'\t{extra.label}: additional dark operating point noted (not analysed)' + + # --- flats and charts: PTC points + PRNU ----------------------------- + points = [] # serialised entries for results.json + point_objs = [] # the PtcPoint objects, for fitting + prnu_entries = [] + for g in groups: + if g.kind not in ('flat', 'chart'): + continue + yield f'\t{g.label}: {len(g.paths)} frames ({g.kind})' + try: + fs = frameset_from_dngs([str(p) for p in g.paths], roi_fraction) + except Exception as err: + results['warnings'].append({'group': g.label, 'message': f'load failed: {err}'}) + yield f'\t\tWARNING: load failed ({err})' + continue + fs.label = g.label + # Substitute the measured dark pedestal only on an exact mode match; + # flats from a different sensor mode keep their own metadata pedestal. + if dark_group is not None and results['dark'].get('available'): + same_mode = (g.width, g.height, g.sigbits, g.blacklevel) == ( + dark_group.width, + dark_group.height, + dark_group.sigbits, + dark_group.blacklevel, + ) + if same_mode: + fs.blacklevel_16 = results['dark']['pedestal']['black_level_16'] + pedestal_source = 'dark' + else: + pedestal_source = 'metadata' + results['warnings'].append( + { + 'group': g.label, + 'message': 'sensor mode differs from the dark burst; using the metadata black level', + } + ) + else: + pedestal_source = 'metadata' + + point = ptc_point(fs, source='flat' if g.kind == 'flat' else 'chart') + if point is not None: + entry = asdict(point) + entry['pedestal_source'] = pedestal_source + points.append(entry) + point_objs.append(point) + state = 'clipped' if point.clipped else 'ok' + yield f'\t\tPTC point: mean {point.mean_dn:.0f} DN16, variance {point.var_dn2:.1f} ({state})' + else: + yield '\t\tsingle frame: no temporal statistics' + if g.kind == 'flat' and len(fs.frames) >= 2 and point is not None and not point.clipped: + ss = spatial_stats(fs) + if ss.nonuniformity_pct is not None: + prnu_entries.append( + {'label': g.label, 'prnu_pct': round(ss.nonuniformity_pct, 3), 'mean_dn': round(ss.mean_dn, 1)} + ) + yield f'\t\tPRNU {ss.nonuniformity_pct:.2f}% at mean {ss.mean_dn:.0f} DN16' + + # --- fits + honesty --------------------------------------------------- + results['ptc']['points'] = points + fits = fit_ptc(point_objs) # clip/chart exclusion happens inside + results['ptc']['fits'] = [asdict(f) for f in fits] + reliable = next((f for f in fits if f.reliable), None) + if reliable is None: + n_flat = sum(1 for p in point_objs if not p.clipped and p.source == 'flat') + results['ptc']['unavailable_reason'] = ( + f'{n_flat} flat-field operating point{"s" if n_flat != 1 else ""} — a conversion-gain fit needs ' + f'at least 3 well-spread exposures at one gain (run the live sweep)' + ) + yield f'\tPTC: {results["ptc"]["unavailable_reason"]}' + else: + rn = f'{reliable.read_noise_e:.2f} e-' if reliable.read_noise_e is not None else 'n/a' + yield f'\tPTC fit (gain {reliable.gain}): K {reliable.k_e_per_dn:.3f} e-/DN16, read noise {rn}' + # Electron-referred dark metrics become quotable. + if results['dark'].get('available'): + k = reliable.k_e_per_dn + if results['dark']['read_noise_dn'] is not None: + results['dark']['read_noise_e'] = round(results['dark']['read_noise_dn'] * k, 2) + results['dark']['dsnu_e'] = round(results['dark']['dsnu_dn'] * k, 2) + + if prnu_entries: + results['prnu'] = { + 'available': True, + 'groups': prnu_entries, + 'best_pct': min(e['prnu_pct'] for e in prnu_entries), + } + else: + results['prnu']['unavailable_reason'] = 'no unclipped multi-frame flat-field burst' + + if dark_fs is not None: + results['camera'] = { + 'width': dark_group.width, + 'height': dark_group.height, + 'sigbits': dark_group.sigbits, + 'channel': dark_fs.channel, + } + elif groups: + g0 = groups[0] + results['camera'] = {'width': g0.width, 'height': g0.height, 'sigbits': g0.sigbits, 'channel': None} + + # A prior live exposure sweep is not reproducible from the DNGs on disk, so + # carry it across this re-analysis rather than dropping conversion gain, + # linearity, full well and dynamic range. + _carry_live_sweep(results, existing) + + out = results_path(project) + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(json.dumps(results, indent=2)) + yield f'Analysis complete — results in {out.parent.name}/{out.name}' + yield 'CHAR_EXIT 0' + except Exception as err: # a failed walk must still end the stream cleanly + yield f'ERROR: {err}' + yield 'CHAR_EXIT 1' + finally: + _char_lock.release() + + +# --- live exposure sweep (phase 2) ------------------------------------------- +# A controlled exposure sweep at fixed gain(s) provides what existing captures +# cannot: enough well-spread PTC points for a reliable conversion-gain fit, +# plus linearity, full well, SNR and dynamic range. Per-pixel temporal +# statistics average linearly, so the sweep is valid on any *static* scene; +# a flat field is only required for uniformity metrics (PRNU). + +_SWEEP_PROBE_START_US = 100 +_SWEEP_EXPOSURE_CAP_US = 1_000_000 # 1 s: enough to saturate any sane scene + + +def _point_from_dict(entry: dict) -> PtcPoint: + fields = ('mean_dn', 'var_dn2', 'exposure_us', 'gain', 'n_frames', 'clipped', 'label', 'source') + return PtcPoint(**{k: entry[k] for k in fields}) + + +# Sweep-derived sections: computed from live points, not reproducible from DNGs. +_SWEEP_SECTIONS = ('sweep', 'gain_sweep', 'linearity', 'full_well', 'dynamic_range', 'snr_curve') + + +def _carry_live_sweep(results: dict, prev: dict | None) -> None: + """Preserve a previous live sweep across a fresh offline analysis. + + A live exposure sweep captures no DNGs, so re-walking the project cannot + reproduce it. Re-add the prior live PTC points (re-fitting them together with + the offline points), carry the sweep-only sections verbatim, and refresh the + electron-referred dark metrics from the reliable fit. + """ + if not prev: + return + for key in _SWEEP_SECTIONS: + if key in prev: + results[key] = prev[key] + live = [p for p in prev.get('ptc', {}).get('points', []) if p.get('source') == 'live'] + if not live: + return + results['ptc']['points'] = results['ptc']['points'] + live + fits = fit_ptc([_point_from_dict(p) for p in results['ptc']['points']]) + results['ptc']['fits'] = [asdict(f) for f in fits] + reliable = next((f for f in fits if f.reliable), None) + if reliable is None: + return + results['ptc']['unavailable_reason'] = None + dark = results.get('dark', {}) + if dark.get('available'): + k = reliable.k_e_per_dn + if dark.get('read_noise_dn') is not None: + dark['read_noise_e'] = round(dark['read_noise_dn'] * k, 2) + if dark.get('dsnu_dn') is not None: + dark['dsnu_e'] = round(dark['dsnu_dn'] * k, 2) + + +def flat_exposure(camera, gain: float, roi_fraction: float = 0.5, target: float = 0.5) -> int: + """An exposure that lands the flat field near `target` of the DN swing, unclipped. + + Starts from half the saturation exposure and backs off until the ROI mean is + comfortably below saturation, so the flat is usable for PRNU. + """ + sat_exposure, swing = _find_saturation(camera, gain, roi_fraction) + exposure = max(int(sat_exposure * target), 50) + for _ in range(4): + ts = temporal_stats(_sweep_frameset(camera, 2, exposure, gain, roi_fraction, 'flat-probe')) + if ts is None or ts.mean_dn <= 0.7 * swing: + break + exposure = max(int(exposure * 0.6), 50) + return exposure + + +def _sweep_frameset(camera, frames: int, exposure_us: int, gain: float, roi_fraction: float, label: str) -> FrameSet: + d = camera.capture_raw_burst(frames, exposure_us, gain, roi_fraction) + return FrameSet( + frames=d['frames'], + exposure_us=d['exposure_us'], + gain=d['gain'], + blacklevel_16=d['blacklevel_16'], + sigbits=d['sigbits'], + channel='Gr', + label=label, + ) + + +def _find_saturation(camera, gain: float, roi_fraction: float) -> tuple[int, float]: + """Double the exposure until the response saturates; return (exposure_us, swing). + + The probe uses two-frame bursts (cheap) and the black-subtracted mean, and + stops on either signature of saturation: the mean crossing 85% of the DN + swing (container-limited clipping), or the mean plateauing between + doublings (electron full well, or a scene that cannot get brighter — points + beyond that would sit at collapsed variance and poison the fit). Capped at + 1 s for scenes that never saturate at all. + """ + exposure = _SWEEP_PROBE_START_US + swing = None + prev_mean = None + while exposure <= _SWEEP_EXPOSURE_CAP_US: + fs = _sweep_frameset(camera, 2, exposure, gain, roi_fraction, 'probe') + swing = 65535.0 - fs.blacklevel_16 + ts = temporal_stats(fs) + if ts is not None: + if ts.mean_dn > 0.85 * swing: + return exposure, swing + if prev_mean is not None and prev_mean > 0.02 * swing and ts.mean_dn < prev_mean * 1.1: + # Doubling the exposure barely moved the mean: response plateau. + return exposure, swing + prev_mean = ts.mean_dn + exposure *= 2 + return _SWEEP_EXPOSURE_CAP_US, swing or 61695.0 + + +def sweep_stream( + project: Project, + camera, + gains: list[float], + points_per_gain: int = 10, + frames: int = 8, + roi_fraction: float = 0.5, +) -> Iterator[str]: + """Run a live exposure sweep and merge the metrics into results.json. + + Yields progress lines; ends with CHAR_EXIT . Camera controls (AE, AWB, + exposure, gain, frame-rate target) are restored afterwards. + """ + from . import ctt_runner # local import to avoid a cycle at module load + + if ctt_runner.is_running(): + yield 'ERROR: a calibration is running; wait for it to finish' + yield 'CHAR_EXIT 2' + return + if not _char_lock.acquire(blocking=False): + yield 'ERROR: a characterisation analysis is already running' + yield 'CHAR_EXIT 2' + return + gains = sorted({round(float(g), 4) for g in gains}) or [1.0] + points_per_gain = max(4, min(int(points_per_gain), 16)) + frames = max(2, min(int(frames), 16)) + prev = None + try: + yield ( + f'$ characterise (live sweep) project={project.name} ' + f'gains={",".join(str(g) for g in gains)} points={points_per_gain} frames={frames}' + ) + yield 'Scene must stay static and steadily lit for the duration of the sweep.' + prev = camera.get_controls() + # Manual everything; unconstrained frame duration so long exposures land. + camera.set_controls( + { + 'auto_exposure': False, + 'exposure': prev.get('exposure') or 10_000, + 'gain': gains[0], + 'fps': 0, + 'awb': False, + } + ) + + points: list[PtcPoint] = [] + gain_summaries = [] + for gain in gains: + yield f'\tgain {gain:g}: probing for saturation' + sat_exposure, swing = _find_saturation(camera, gain, roi_fraction) + lo = max(camera.get_controls().get('exposure_min', 50), sat_exposure // 500) + exposures = np.unique(np.geomspace(lo, sat_exposure, points_per_gain).astype(int)).tolist() + exposures.append(min(int(sat_exposure * 1.3), _SWEEP_EXPOSURE_CAP_US)) # deliberate clip: full well + yield f'\tgain {gain:g}: {len(exposures)} exposures, {lo} us to {exposures[-1]} us' + family = [] + for exposure in exposures: + fs = _sweep_frameset(camera, frames, exposure, gain, roi_fraction, f'sweep g{gain:g} {exposure}us') + point = ptc_point(fs, source='live') + if point is None: + continue + family.append(point) + state = 'clipped' if point.clipped else 'ok' + yield f'\t\t{point.exposure_us} us: mean {point.mean_dn:.0f} DN16, var {point.var_dn2:.1f} ({state})' + points.extend(family) + + fits = fit_ptc(family) + fit = fits[0] if fits else None + summary = {'gain': gain, 'n_points': len(family)} + if fit is not None: + summary.update( + { + 'k_e_per_dn': round(fit.k_e_per_dn, 4), + 'read_noise_dn': round(fit.read_noise_dn, 2) if fit.read_noise_dn else None, + 'read_noise_e': round(fit.read_noise_e, 2) if fit.read_noise_e else None, + 'r2': round(fit.r2, 5), + 'reliable': fit.reliable, + } + ) + rn = f'{fit.read_noise_e:.2f} e-' if fit.read_noise_e is not None else 'n/a (negative intercept)' + yield ( + f'\tgain {gain:g}: K {fit.k_e_per_dn:.3f} e-/DN16, read noise ' + f'{rn} (r2 {fit.r2:.4f}{", reliable" if fit.reliable else ""})' + ) + gain_summaries.append(summary) + + yield 'Merging sweep metrics into results' + results = read_results(project) + if results is None: + results = { + 'version': RESULTS_VERSION, + 'inputs': [], + 'camera': None, + 'groups': [], + 'dark': {'available': False, 'unavailable_reason': 'no dark frames captured'}, + 'ptc': {'points': [], 'fits': [], 'unavailable_reason': None}, + 'prnu': {'available': False, 'groups': [], 'best_pct': None}, + 'warnings': [], + } + results['generated_at'] = _now_iso() + results['sweep'] = { + 'ran_at': results['generated_at'], + 'gains': gains, + 'points_per_gain': points_per_gain, + 'frames': frames, + } + + # Live points replace any previous sweep; offline points are kept. + kept = [p for p in results['ptc']['points'] if p.get('source') != 'live'] + results['ptc']['points'] = kept + [asdict(p) for p in points] + all_points = [_point_from_dict(p) for p in results['ptc']['points']] + fits = fit_ptc(all_points) + results['ptc']['fits'] = [asdict(f) for f in fits] + reliable_by_gain = {f.gain: f for f in fits if f.reliable} + if reliable_by_gain: + results['ptc']['unavailable_reason'] = None + else: + n_clipped = sum(1 for p in points if p.clipped) + results['ptc']['unavailable_reason'] = ( + f'sweep completed but no reliable fit — {n_clipped} of {len(points)} points were clipped. ' + 'A flat, evenly lit target (no bright highlights) keeps points usable across the sweep.' + ) + results['gain_sweep'] = {'available': len(gains) > 1, 'gains': gain_summaries} + + # Base the single-gain metrics on the first family's *readback* gain: the + # applied gain differs slightly from the requested value (e.g. 3.98 vs 4.0), + # so points, fits and the base gain must all be matched with tolerance. + base_gain = points[0].gain if points else gains[0] + base_fit = next((f for f in fits if f.reliable and abs(f.gain - base_gain) <= 0.05), None) + live_base = [p for p in points if abs(p.gain - base_gain) <= 0.05] + lin = linearity(live_base) + results['linearity'] = {'available': lin is not None, **(lin or {})} + fw = full_well(live_base) + if fw is not None and base_fit is not None: + fw['full_well_e'] = round(fw['full_well_dn'] * base_fit.k_e_per_dn, 0) + results['full_well'] = {'available': fw is not None, **(fw or {})} + results['snr_curve'] = snr_curve(live_base) + + # Electron-referred dark metrics, and dynamic range, once K is known. + dark = results.get('dark', {}) + if dark.get('available') and reliable_by_gain: + k_dark = reliable_by_gain.get(round(dark.get('gain', 1.0), 4)) or base_fit + if k_dark is not None: + if dark.get('read_noise_dn') is not None: + dark['read_noise_e'] = round(dark['read_noise_dn'] * k_dark.k_e_per_dn, 2) + if dark.get('dsnu_dn') is not None: + dark['dsnu_e'] = round(dark['dsnu_dn'] * k_dark.k_e_per_dn, 2) + read_noise_e = (dark.get('read_noise_e') if dark.get('available') else None) or ( + base_fit.read_noise_e if base_fit else None + ) + if fw is not None and fw.get('full_well_e') and read_noise_e: + results['dynamic_range'] = { + 'available': True, + 'db': round(dynamic_range(fw['full_well_e'], read_noise_e), 1), + 'full_well_e': fw['full_well_e'], + 'read_noise_e': read_noise_e, + } + else: + results['dynamic_range'] = {'available': False} + + out = results_path(project) + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(json.dumps(results, indent=2)) + yield f'Sweep complete — results in {out.parent.name}/{out.name}' + yield 'CHAR_EXIT 0' + except Exception as err: # a failed sweep must still end the stream cleanly + yield f'ERROR: {err}' + yield 'CHAR_EXIT 1' + finally: + if prev is not None: + # The camera may have gone away mid-sweep; restoring is best-effort. + with contextlib.suppress(Exception): + camera.set_controls( + { + 'auto_exposure': prev.get('auto_exposure', True), + 'exposure': prev.get('exposure'), + 'gain': prev.get('gain'), + 'fps': prev.get('fps', 30), + 'awb': True, + } + ) + _char_lock.release() diff --git a/apps/ctt_server/static/app.css b/apps/ctt_server/static/app.css index 96723d5..ab3a8b5 100644 --- a/apps/ctt_server/static/app.css +++ b/apps/ctt_server/static/app.css @@ -172,6 +172,8 @@ input, select, textarea { input:focus, select:focus, textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); } input:disabled, select:disabled, textarea:disabled { opacity: 0.45; cursor: not-allowed; } .field { margin-bottom: 14px; } +/* A read-out value that is out of range / not trustworthy. */ +.value-bad { color: var(--err); } .field-row { display: flex; gap: 12px; } .field-row .field { flex: 1; } .hint { font-size: 0.78rem; color: var(--text-faint); margin-top: 5px; } @@ -493,3 +495,26 @@ td.mono { font-family: var(--mono); } /* Inset for a conditional sub-flow (e.g. the update-tuning source picker): * a left rule contains the controls that only exist because of the toggle above. */ .subflow { margin: -4px 0 14px 5px; padding: 2px 0 2px 16px; border-left: 2px solid var(--border-2); } + +/* --- Characterise page --------------------------------------------------- */ +/* The datasheet strip: one tile per headline sensor measurement. */ +.stats.datasheet { flex-wrap: wrap; } +.stats.datasheet .stat { min-width: 128px; position: relative; cursor: help; } +.stats.datasheet .stat .u { font-size: 0.72rem; font-weight: 600; color: var(--text-dim); margin-left: 4px; } +.stats.datasheet .stat.pending { opacity: 0.45; } +/* Hover/focus tooltip explaining each measurement (content in data-tip). A + * pending tile is dimmed, but its tooltip must not be: opacity resets to 1. */ +.stats.datasheet .stat[data-tip]:hover::after, +.stats.datasheet .stat[data-tip]:focus-visible::after { + content: attr(data-tip); + position: absolute; left: 0; top: calc(100% + 6px); z-index: 30; width: 300px; + padding: 9px 11px; background: var(--panel-2); border: 1px solid var(--border-2); + border-radius: var(--radius-sm); box-shadow: var(--shadow); + color: var(--text-dim); font-size: 0.76rem; font-weight: 400; line-height: 1.5; + white-space: normal; text-transform: none; letter-spacing: normal; +} +.stats.datasheet .stat.pending:hover, .stats.datasheet .stat.pending:focus-visible { opacity: 1; } +.stats.datasheet .stat:focus-visible { outline: none; border-color: var(--accent); } +/* Right-most tiles: anchor the tooltip to the right edge so it stays on-screen. */ +.stats.datasheet .stat:nth-last-child(-n+2)[data-tip]:hover::after, +.stats.datasheet .stat:nth-last-child(-n+2)[data-tip]:focus-visible::after { left: auto; right: 0; } diff --git a/apps/ctt_server/static/app.js b/apps/ctt_server/static/app.js index 981ff37..241a092 100644 --- a/apps/ctt_server/static/app.js +++ b/apps/ctt_server/static/app.js @@ -177,7 +177,7 @@ function captureApp(cfg) { clip: { r: 0, g: 0, b: 0 }, macbeth: { found: false, confidence: null, corners: null, small: false, saturated: false }, lightbox: { present: false, channel: null, illuminant: '', intensity: 0, illuminants: {} }, - lightmeter: { present: false, model: '', serial: '', reading: null, busy: false, auto: true, updated: '' }, + lightmeter: { present: false, model: '', serial: '', reading: null, limits: null, busy: false, auto: true, updated: '' }, busy: false, error: '', hflip: false, @@ -305,11 +305,29 @@ function captureApp(cfg) { finally { this.lightmeter.busy = false; } }, + // Illuminance readout, capped to the meter's limits: "< min" / "> max" out of + // range (the API clamps the number), otherwise the value. + meterIlluminanceText() { + const r = this.lightmeter.reading, lim = this.lightmeter.limits; + if (!r) return '—'; + if (r.in_range === false && lim) { + return r.illuminance_lux <= lim.illuminance_min ? `< ${lim.illuminance_min} lx` : `> ${lim.illuminance_max} lx`; + } + return r.illuminance_lux.toFixed(1) + ' lx'; + }, + + // True when the colour metrics can't be trusted: illuminance out of range, or + // below the colour floor (colour needs more light than illuminance). + meterColourUnderRange() { + const r = this.lightmeter.reading, lim = this.lightmeter.limits; + return !!(r && lim && (r.in_range === false || r.illuminance_lux < lim.colour_min_lux)); + }, + // Copy the latest meter reading into the capture tags (rounded to the - // filename's integer convention). + // filename's integer convention). An out-of-range reading is ignored. tagFromMeter() { const r = this.lightmeter.reading; - if (!r) return; + if (!r || r.in_range === false) return; this.form.colour_temp = Math.round(r.cct); this.form.lux = Math.round(r.illuminance_lux); }, @@ -1044,7 +1062,7 @@ function resultsApp(cfg) { controls: { auto_exposure: true, exposure: 0, gain: 1, ev: 0 }, // exposure panel state fpsTarget: 30, // framerate target; 0 = unconstrained (variable frame duration) lightbox: { present: false, channel: null, intensity: 0, illuminants: {} }, // optional lightbox device - lightmeter: { present: false, model: '', serial: '', reading: null, busy: false, auto: true, updated: '' }, // optional light meter + lightmeter: { present: false, model: '', serial: '', reading: null, limits: null, busy: false, auto: true, updated: '' }, // optional light meter colour: null, // current live ΔE reading (for whichever tuning is loaded) liveColour: true, // semi-live colour measurement while previewing chartSeen: null, // null = no reading yet, false = chart not found, {confidence} = found @@ -1364,6 +1382,22 @@ function resultsApp(cfg) { finally { this.lightmeter.busy = false; } }, + // Illuminance readout capped to the meter's limits ("< min" / "> max" out of range). + meterIlluminanceText() { + const r = this.lightmeter.reading, lim = this.lightmeter.limits; + if (!r) return '—'; + if (r.in_range === false && lim) { + return r.illuminance_lux <= lim.illuminance_min ? `< ${lim.illuminance_min} lx` : `> ${lim.illuminance_max} lx`; + } + return r.illuminance_lux.toFixed(1) + ' lx'; + }, + + // Colour metrics are invalid when out of range or below the colour floor. + meterColourUnderRange() { + const r = this.lightmeter.reading, lim = this.lightmeter.limits; + return !!(r && lim && (r.in_range === false || r.illuminance_lux < lim.colour_min_lux)); + }, + async previewStandard() { // Switch the live preview to the camera's default (built-in) tuning, for // an A/B against the generated one — stays in preview mode. @@ -2301,3 +2335,345 @@ function chartOpts(xTitle, yTitle) { }, }; } + +// --- characterisation page --------------------------------------------------- +function characterisationApp(cfg) { + return { + project: cfg.project, + groups: [], // quick-scan inventory (renders before any analysis) + results: null, // persisted results.json contents (or null) + stale: false, // captures changed since the results were generated + running: false, + consoleUsed: false, + exitCode: null, + error: '', + source: null, + ptcChart: null, + snrChart: null, + showPoints: false, // PTC per-point table, collapsed by default + sweepGains: '1', // live-sweep parameters + sweepPoints: 10, + sweepFrames: 8, + // Auto-characterise (needs a lightbox + light meter): gap-driven cycle. + devices: { lightbox: null, lightmeter: false }, + autoChar: { open: false, running: false, waitingUser: false, lamp: null, intensity: 100, + darks: true, flats: true, sweep: true, prog: null, source: null }, + + // Sections with safe defaults so the template renders before data arrives. + get dark() { return (this.results && this.results.dark) || { available: false }; }, + get ptc() { return (this.results && this.results.ptc) || { points: [], fits: [], unavailable_reason: null }; }, + get prnu() { return (this.results && this.results.prnu) || { available: false, groups: [] }; }, + get reliableFit() { return this.ptc.fits.find((f) => f.reliable) || null; }, + get fullWell() { return (this.results && this.results.full_well) || { available: false }; }, + get dynamicRange() { return (this.results && this.results.dynamic_range) || { available: false }; }, + get gainSweep() { return (this.results && this.results.gain_sweep) || { available: false, gains: [] }; }, + get snrCurve() { return (this.results && this.results.snr_curve) || []; }, + get linearityR2() { + const lin = this.results && this.results.linearity; + return lin && lin.available ? lin.r2.toFixed(4) : '—'; + }, + + // The datasheet strip: every headline measurement, null value = not yet + // measurable (the tile renders dimmed with an em dash). Each tile carries + // a tooltip: what the number means, how it was measured, and — when + // pending — what would make it measurable. + get sheet() { + const dark = this.dark, fit = this.reliableFit; + const needSweep = 'Run a live sweep against a flat, evenly lit, static target to measure this.'; + const rn = dark.available + ? (dark.read_noise_e != null + ? { value: dark.read_noise_e, unit: 'e⁻' } + : { value: dark.read_noise_dn, unit: 'DN₁₆', note: 'Shown in electrons once a sweep provides the conversion gain.' }) + : { value: null, note: 'Needs a dark burst (lens cap on).' }; + const dsnu = dark.available + ? (dark.dsnu_e != null ? { value: dark.dsnu_e, unit: 'e⁻' } : { value: dark.dsnu_dn, unit: 'DN₁₆' }) + : { value: null, note: 'Needs a dark burst (lens cap on).' }; + const lin = this.results && this.results.linearity; + const tiles = [ + { label: 'Black level', unit: 'DN₁₆', + value: dark.available ? Math.round(dark.pedestal.black_level_16) : null, + note: dark.available ? null : 'Needs a dark burst (lens cap on).', + tip: 'The zero-light pedestal: the mean level of the dark burst. Every other measurement is taken above this. Should sit close to the metadata black level — a higher reading suggests a light leak.' }, + { label: 'Read noise', ...rn, + tip: 'The noise floor with no light: per-pixel temporal standard deviation across the dark burst. Sets the shadow noise and the bottom of the dynamic range.' }, + { label: 'DSNU', ...dsnu, + tip: 'Dark-signal non-uniformity: the fixed pattern left in the time-averaged dark frame after removing low-order shading — offset variation between pixels.' }, + { label: 'PRNU', unit: '%', value: this.prnu.available ? this.prnu.best_pct : null, + note: this.prnu.available ? null : 'Needs an unclipped multi-frame flat-field burst.', + tip: 'Photo-response non-uniformity: pixel-to-pixel sensitivity variation, from the residual spatial variation of a time-averaged flat field after removing the lens-shading gradient (best flat-field group shown).' }, + { label: 'Conversion gain', unit: 'e⁻/DN₁₆', + value: fit ? fit.k_e_per_dn.toFixed(3) : null, note: fit ? null : needSweep, + tip: 'Electrons per digital number: 1/slope of the photon-transfer curve (temporal variance vs mean signal). The bridge between DN and physical electrons — every e⁻ figure here derives from it.' }, + { label: 'Full well', unit: this.fullWell.full_well_e != null ? 'e⁻' : 'DN₁₆', + value: this.fullWell.available + ? Math.round(this.fullWell.full_well_e != null ? this.fullWell.full_well_e : this.fullWell.full_well_dn) + : null, + note: this.fullWell.available ? null : needSweep, + tip: 'Saturation capacity: the signal level where the response clips, from the highest clipped sweep point. Quoted in electrons when the conversion gain is known.' }, + { label: 'Dynamic range', unit: 'dB', + value: this.dynamicRange.available ? this.dynamicRange.db : null, + note: this.dynamicRange.available ? null : needSweep, + tip: '20·log₁₀(full well ÷ read noise), both in electrons: the usable range between saturation and the noise floor.' }, + { label: 'Linearity R²', unit: '', + value: lin && lin.available ? lin.r2.toFixed(4) : null, + note: lin && lin.available ? null : needSweep, + tip: 'Goodness of the straight-line fit of mean signal vs exposure time across the sweep (at least three unclipped points). 1.000 means the response is proportional to exposure.' }, + ]; + for (const t of tiles) { + if (t.note) t.tip += ' ' + t.note; // pending or unit-fallback explanation + } + return tiles; + }, + + // Per-channel dark pedestal for the detail card (means over the burst). + get darkChannels() { + const frames = (this.dark.available && this.dark.pedestal.frames) || []; + if (!frames.length) { + return this.dark.available + ? [{ label: 'Black level (DN₁₆)', value: this.fmtDn(this.dark.pedestal.black_level_16) }] + : []; + } + const mean = (k) => { + const vals = frames.map((f) => f[k]).filter((v) => v != null); + return vals.length ? (vals.reduce((a, b) => a + b, 0) / vals.length).toFixed(1) : null; + }; + if (frames[0].y != null) return [{ label: 'Y (DN₁₆)', value: mean('y') }]; + return [ + { label: 'R (DN₁₆)', value: mean('r') }, + { label: 'G (DN₁₆)', value: mean('g') }, + { label: 'B (DN₁₆)', value: mean('b') }, + ]; + }, + get warnings() { + const scan = this.groups.flatMap((g) => (g.warnings || []).map((m) => ({ group: g.label, message: m }))); + const analysed = (this.results && this.results.warnings) || []; + return scan.concat(analysed); + }, + + async init() { await this.load(); await this.loadDevices(); }, + + async loadDevices() { + // Auto-characterise needs both a lightbox (for steady light) and a meter. + try { + const [lb, lm] = await Promise.all([ + fetch(`/api/lightbox`).then((r) => r.json()), + fetch(`/api/lightmeter`).then((r) => r.json()), + ]); + this.devices.lightbox = lb.present ? lb : null; + this.devices.lightmeter = !!lm.present; + } catch (e) { /* devices are optional */ } + }, + + autoCharAvailable() { + return !!(this.devices.lightbox && this.devices.lightmeter); + }, + + openAutoChar() { + const lb = this.devices.lightbox || {}; + const labels = lb.illuminant_labels || {}; + const defaults = lb.illuminant_defaults || {}; + this.autoChar.lamps = Object.entries(lb.illuminants || {}).map(([ch, name]) => ({ + channel: Number(ch), illuminant: name, label: labels[ch] || name, percent: Math.round(defaults[ch] ?? 100), + })); + // Default to the brightest lamp (highest channel — the halogens) for the sweep. + this.autoChar.lamp = this.autoChar.lamps.length ? this.autoChar.lamps[this.autoChar.lamps.length - 1].illuminant : null; + this.autoChar.open = true; + }, + + startAutoChar() { + if (this.running || !this.autoChar.lamp) return; + const lamp = this.autoChar.lamps.find((l) => l.illuminant === this.autoChar.lamp); + this.autoChar.open = false; + this.autoChar.running = true; + this.autoChar.waitingUser = false; + this.autoChar.prog = { plan: [], phase: '', log: [], reused: [], captured: [], swept: [], + done: false, ok: null, cancelled: false, cancelling: false, error: '' }; + const params = new URLSearchParams({ + gains: this.sweepGains || '1', lamp: this.autoChar.lamp, + intensity: lamp ? lamp.percent : 100, + darks: this.autoChar.darks ? '1' : '0', flats: this.autoChar.flats ? '1' : '0', + sweep: this.autoChar.sweep ? '1' : '0', + sweep_points: this.sweepPoints, sweep_frames: this.sweepFrames, + }); + this.autoChar.source = new EventSource(`/projects/${this.project}/auto-characterise/stream?${params}`); + this.autoChar.source.onmessage = (e) => this.autoCharEvent(JSON.parse(e.data)); + this.autoChar.source.onerror = () => { + if (this.autoChar.running) { this.autoChar.prog.error = 'stream interrupted'; this.finishAutoChar(false); } + }; + }, + + autoCharEvent(ev) { + const p = this.autoChar.prog; + switch (ev.event) { + case 'plan': p.plan = ev.gains; break; + case 'phase': p.phase = ev.gain != null ? `${ev.name} (gain ${ev.gain})` : ev.name; break; + case 'log': p.log.push(ev.line); if (p.log.length > 400) p.log.shift(); break; + case 'reading': + p.phase = `stabilising ${ev.illuminant}: ${ev.lux.toFixed(0)} lx · ${ev.cct.toFixed(0)} K` + + ` — stable ${ev.stable_count}/${ev.needed}`; + break; + case 'captured': p.captured.push(`${ev.kind} g${ev.gain}`); break; + case 'waiting_user': this.autoChar.waitingUser = true; p.phase = 'waiting for lens cap'; break; + case 'error': p.error = ev.error; this.finishAutoChar(false); break; + case 'cancelled': p.cancelled = true; break; + case 'done': + p.reused = ev.reused || []; p.captured = ev.captured || p.captured; p.swept = ev.swept || []; + p.cancelled = ev.cancelled; this.finishAutoChar(ev.ok); + break; + } + }, + + finishAutoChar(ok) { + const p = this.autoChar.prog; + p.done = true; p.ok = ok; p.phase = ''; + this.autoChar.running = false; this.autoChar.waitingUser = false; + if (this.autoChar.source) { this.autoChar.source.close(); this.autoChar.source = null; } + this.load(); // refresh the datasheet with the merged results + this.loadDevices(); // the cycle left the lightbox off; resync + }, + + async autoCharContinue() { + this.autoChar.waitingUser = false; + try { await fetch(`/projects/${this.project}/auto-characterise/continue`, { method: 'POST' }); } + catch (e) { /* progress surfaces through the stream */ } + }, + + async autoCharCancel() { + this.autoChar.prog.cancelling = true; + try { await fetch(`/projects/${this.project}/auto-characterise/cancel`, { method: 'POST' }); } + catch (e) { /* progress surfaces through the stream */ } + }, + + async load() { + try { + const r = await fetch(`/projects/${this.project}/characterisation/data`); + if (!r.ok) throw new Error(); + const d = await r.json(); + this.groups = d.groups || []; + this.results = d.results; + this.stale = !!d.stale; + this.$nextTick(() => { this.renderPtc(); this.renderSnr(); }); + } catch (e) { this.error = 'Failed to load characterisation data'; } + }, + + analyse() { this.stream(''); }, + + // Live exposure sweep: drives the camera, so the numbers existing captures + // cannot supply (conversion gain, linearity, full well, DR) become measurable. + sweep() { + const params = new URLSearchParams({ + mode: 'live', gains: this.sweepGains || '1', + points: this.sweepPoints, frames: this.sweepFrames, + }); + this.stream(`?${params}`); + }, + + stream(query) { + if (this.running) return; + this.running = true; this.consoleUsed = true; this.exitCode = null; this.error = ''; + const console = this.$refs.console; + console.innerHTML = ''; + this.source = new EventSource(`/projects/${this.project}/characterisation/analyse/stream${query}`); + this.source.onmessage = (e) => { + const line = JSON.parse(e.data); + if (line.startsWith('CHAR_EXIT ')) { + this.exitCode = parseInt(line.slice(10), 10); + this.running = false; + this.source.close(); + this.load(); // pick up the freshly persisted results + return; + } + const el = document.createElement('span'); + el.className = 'ln ' + classify(line); + el.textContent = line; + console.appendChild(el); + const box = console.closest('.console') || console; + box.scrollTop = box.scrollHeight; + }; + this.source.onerror = () => { + if (this.running) { this.running = false; this.error = 'Analysis stream interrupted'; } + if (this.source) this.source.close(); + }; + }, + + fmtExposure(us) { + if (us == null) return '—'; + return us >= 1000 ? (us / 1000).toFixed(us >= 10000 ? 0 : 1) + ' ms' : us + ' µs'; + }, + fmtDn(v) { return v == null ? '—' : Number(v).toFixed(1); }, + + // Variance-vs-mean scatter: one series per gain family, clipped/chart + // points hollow and excluded from any drawn fit; a fit line only when the + // fit is statistically defensible (reliable). + renderPtc() { + const canvas = document.getElementById('ptcChart'); + if (!canvas || !this.ptc.points.length) return; + if (this.ptcChart) { this.ptcChart.destroy(); this.ptcChart = null; } + const palette = ['#f06595', '#a5d8ff', '#b2f2bb', '#ffd8a8', '#d0bfff']; + const usable = (p) => !p.clipped && p.source !== 'chart'; + const gains = [...new Set(this.ptc.points.filter(usable).map((p) => p.gain))].sort((a, b) => a - b); + const datasets = []; + // One solid series per usable gain family. + gains.forEach((gain, i) => { + const pts = this.ptc.points.filter((p) => usable(p) && p.gain === gain); + datasets.push({ + label: `gain ${gain.toFixed(2)}`, type: 'scatter', pointRadius: 5, + backgroundColor: palette[i % palette.length], + data: pts.map((p) => ({ x: p.mean_dn, y: p.var_dn2 })), + }); + }); + // All clipped/chart points collapse into a single hollow "excluded" series. + const excluded = this.ptc.points.filter((p) => !usable(p)); + if (excluded.length) { + datasets.push({ + label: 'excluded (clipped / chart)', type: 'scatter', pointRadius: 4, + backgroundColor: 'transparent', borderColor: '#6b7888', borderWidth: 1.5, + data: excluded.map((p) => ({ x: p.mean_dn, y: p.var_dn2 })), + }); + } + for (const fit of this.ptc.fits.filter((f) => f.reliable)) { + // Match the family by tolerance: the fit gain is rounded, the points' gain is + // the full-precision readback (e.g. 3.9811), so === would never match. + const family = this.ptc.points.filter((p) => usable(p) && Math.abs(p.gain - fit.gain) <= 0.05); + if (!family.length) continue; + const xs = family.map((p) => p.mean_dn); + const slope = 1 / fit.k_e_per_dn; + const intercept = fit.read_noise_dn ? fit.read_noise_dn ** 2 : 0; + datasets.push({ + label: `fit (gain ${fit.gain.toFixed(2)})`, type: 'line', pointRadius: 0, borderWidth: 2, + borderColor: '#e6edf3', + data: [Math.min(...xs), Math.max(...xs)].map((x) => ({ x, y: slope * x + intercept })), + }); + } + // Log-log: a PTC spans decades in both axes; linear axes crush the + // sweep into a corner and hide the straight-line (slope 1) region. + const opts = chartOpts('Mean signal (DN₁₆)', 'Temporal variance (DN₁₆²)'); + opts.scales.x.type = 'logarithmic'; + opts.scales.y.type = 'logarithmic'; + this.ptcChart = new Chart(canvas.getContext('2d'), { data: { datasets }, options: opts }); + }, + + // SNR vs signal from the live sweep: should follow the square-root + // (shot-noise) law up to a peak near full well. + renderSnr() { + const canvas = document.getElementById('snrChart'); + if (!canvas || !this.snrCurve.length) return; + if (this.snrChart) { this.snrChart.destroy(); this.snrChart = null; } + // Explicit logarithmic x: without a set type a line chart uses a category axis, + // which stacks the points into a vertical line. Sort so the line reads left→right. + const pts = [...this.snrCurve].sort((a, b) => a.mean_dn - b.mean_dn); + const opts = chartOpts('Mean signal (DN₁₆)', 'SNR (dB)'); + opts.scales.x.type = 'logarithmic'; + this.snrChart = new Chart(canvas.getContext('2d'), { + data: { + datasets: [{ + label: 'SNR', type: 'line', borderColor: '#f06595', backgroundColor: '#f06595', + pointRadius: 3, borderWidth: 2, showLine: true, + data: pts.map((p) => ({ x: p.mean_dn, y: p.snr_db })), + }], + }, + options: opts, + }); + }, + }; +} diff --git a/apps/ctt_server/templates/_project_tabs.html b/apps/ctt_server/templates/_project_tabs.html index 1a45f8f..0a31e0d 100644 --- a/apps/ctt_server/templates/_project_tabs.html +++ b/apps/ctt_server/templates/_project_tabs.html @@ -7,4 +7,5 @@ Tuning Preview MTF + Characterise diff --git a/apps/ctt_server/templates/capture.html b/apps/ctt_server/templates/capture.html index 9a46816..08aa792 100644 --- a/apps/ctt_server/templates/capture.html +++ b/apps/ctt_server/templates/capture.html @@ -146,23 +146,28 @@

Light meter

-
+
-
+
-
+
-
+
-
+
@@ -172,6 +177,9 @@

Light meter

+ diff --git a/apps/ctt_server/templates/characterisation.html b/apps/ctt_server/templates/characterisation.html new file mode 100644 index 0000000..3d0641a --- /dev/null +++ b/apps/ctt_server/templates/characterisation.html @@ -0,0 +1,270 @@ +{% extends "base.html" %} +{% block title %}Characterise · {{ project.name }}{% endblock %} +{% block body %} +{% set active_tab = 'characterisation' %} +{% include '_project_tabs.html' %} + + +
+ +
+
+

Sensor characterisation

+ +
+ + +

Measures the sensor itself from raw captures. Dark bursts (lens cap on) + provide the black level, read noise and DSNU; ALSC flat-field bursts provide PRNU and photon-transfer + points; a live exposure sweep against a static, evenly lit target adds the conversion gain, linearity, + full well and dynamic range. Statistics use the central ROI of a single green channel.

+ +
+
+ + +
Analogue gains to characterise. Several (e.g. 1,2,4,8) add the gain-sweep test.
+
+
+ + +
Exposure steps per gain, from near-dark to saturation.
+
+
+ + +
Burst size per step; more frames, cleaner noise estimates.
+
+
+ + +
+ + Runs the whole cycle for you — meter-gated sweep, flat field and dark + frames — capturing only what's missing and reusing the rest. +
+ + +
+ Manual steps +

Run an individual step — or use these when no lightbox and light meter + are attached. Analyse captures reads the DNGs already on disk (black level, read noise, DSNU, PRNU); + Run sweep drives the camera through the exposures above for conversion gain, linearity, full well + and dynamic range (needs a steady, static, evenly-lit target).

+
+ + +
+
+
+
+
+
+ + +
+ +
+ + +
+
+

Photon transfer

+ +
+ +
+
+
+
+
Temporal variance vs mean signal per burst (log–log); the straight-line slope of a + well-spread single-gain sweep gives the conversion gain, its intercept the read noise. Hollow points + are excluded: clipped, or sampled from a chart rather than a flat field.
+ + + + + +
PointMean (DN₁₆)VarianceGain
+ + + + + +
GainK (e⁻/DN₁₆)Read noise (DN₁₆)Read noise (e⁻)
+
+ +
+ +
+

Captures

+
+
📷
No usable captures — dark, ALSC or Macbeth bursts feed characterisation. +
+ + + + + +
GroupKindFramesExposureGain
+
+ +
+
+ +
+ +
+

Dark & black level

+ +
+
+ +
+
+ + — electron values appear once a sweep provides the conversion gain. +
+
+
+ + +
+

PRNU

+ +
+ +
+
Residual non-uniformity of the time-averaged + flat field after removing the lens-shading gradient.
+
+
+
+ + +
+
+

Auto-characterise

+

Completes the characterisation with the least work: reuses the captures and results + this project already has, and only captures or sweeps what's missing — matched by gain. Uses the + gains, points and frames set above.

+
+ + +
A bright lamp (e.g. a halogen) so the sweep can reach saturation; the meter confirms it's steady.
+
+
+ + + +
+ + +
+
+
+
+ + +
+
+
+

Auto-characterise

+ complete + cancelled + failed +
+
    + +
+
+
+ +
+ + +
+ + +
+
+
+ +
+{% endblock %} +{% block scripts %} + +{% endblock %} diff --git a/apps/ctt_server/templates/preview.html b/apps/ctt_server/templates/preview.html index b53af6e..734a55c 100644 --- a/apps/ctt_server/templates/preview.html +++ b/apps/ctt_server/templates/preview.html @@ -163,23 +163,28 @@

Light meter

-
+
-
+
-
+
-
+
-
+
@@ -189,6 +194,9 @@

Light meter

+ diff --git a/ctt/characterisation/__init__.py b/ctt/characterisation/__init__.py new file mode 100644 index 0000000..1d75a57 --- /dev/null +++ b/ctt/characterisation/__init__.py @@ -0,0 +1,36 @@ +# SPDX-License-Identifier: BSD-2-Clause +# +# Copyright (C) 2026, Raspberry Pi +# +# Sensor characterisation: hardware-free analysis of raw Bayer bursts. +# +# One shared recipe (central ROI, single measurement channel, temporal and +# spatial statistics per operating point) feeds every experiment: dark/black +# level/DSNU, photon transfer (conversion gain + read noise), PRNU, and — with +# live sweep captures — linearity, full well and dynamic range. The engine is +# source-agnostic: a FrameSet can be built from DNGs on disk or from live raw +# arrays, and everything downstream is identical. + +from .discover import CaptureGroup, scan_project +from .frames import FrameSet, MismatchedGroupError, centre_roi, frameset_from_dngs, gr_plane +from .ptc import PtcFit, PtcPoint, fit_ptc, ptc_point +from .stats import SpatialStats, TemporalStats, shading_fit, spatial_stats, temporal_stats + +__all__ = [ + 'CaptureGroup', + 'FrameSet', + 'MismatchedGroupError', + 'PtcFit', + 'PtcPoint', + 'SpatialStats', + 'TemporalStats', + 'centre_roi', + 'fit_ptc', + 'frameset_from_dngs', + 'gr_plane', + 'ptc_point', + 'scan_project', + 'shading_fit', + 'spatial_stats', + 'temporal_stats', +] diff --git a/ctt/characterisation/discover.py b/ctt/characterisation/discover.py new file mode 100644 index 0000000..be77e0d --- /dev/null +++ b/ctt/characterisation/discover.py @@ -0,0 +1,164 @@ +# SPDX-License-Identifier: BSD-2-Clause +# +# Copyright (C) 2026, Raspberry Pi +# +# Discover and group a project's existing DNGs into characterisation inputs. +# +# Grouping is filename-first (a burst shares a stem and differs only in the +# trailing _ index — the capture naming convention), then EXIF-verified: +# frames of a group must agree exactly on exposure, ISO, geometry, container +# bits and black level. Disagreeing frames split into sub-groups keyed by that +# exact tuple; there is deliberately no tolerance clustering — ISO 100 and 107 +# are different operating points, and the PTC slope depends on gain. + +from __future__ import annotations + +import logging +import re +from dataclasses import dataclass, field +from pathlib import Path + +import exifread + +from ..core.camera import get_col_lux +from .frames import WHITE_16 + +logger = logging.getLogger(__name__) + +_INDEX_RE = re.compile(r'_\d+(?=\.dng$)', re.IGNORECASE) + + +@dataclass +class CaptureGroup: + """A burst of frames at one verified operating point.""" + + label: str + kind: str # 'dark' | 'flat' | 'chart' + paths: list[Path] + exposure_us: int + gain: float + width: int + height: int + sigbits: int + blacklevel: int # as stored in the DNG (container-domain DN) + colour_temp: int | None = None + lux: int | None = None + warnings: list[str] = field(default_factory=list) + + @property + def blacklevel_16(self) -> float: + return float(self.blacklevel << (16 - self.sigbits)) + + +def group_key(name: str) -> str: + """Burst stem: the filename with a trailing _ index removed. + + Generalises ctt.core.camera.burst_group_key (which only matches Macbeth + names ending in a lux tag) to the dark_ and alsc_k_ conventions. + """ + return _INDEX_RE.sub('', name) + + +def classify(name: str) -> str | None: + """The capture kind, using the same substring rules as CTT's image intake.""" + lower = name.lower() + if 'dark' in lower: + return 'dark' + if 'alsc' in lower: + return 'flat' + if 'cac' in lower: + return None # dot charts: no characterisation use + return 'chart' + + +def _read_exif(path: Path) -> dict: + """The operating-point EXIF of one DNG, tolerant of writer layout differences. + + details=True is required: some writers (observed on PiDNG ALSC captures) + hide BlackLevel from the fast details=False parse. + """ + with open(path, 'rb') as f: + tags = exifread.process_file(f, details=True) + + def tag(name): + for prefix in ('EXIF SubIFD0', 'Image', 'EXIF'): + value = tags.get(f'{prefix} {name}') + if value is not None: + return value + raise KeyError(name) + + exp = tag('ExposureTime').values[0] + try: + white = int(tags['EXIF SubIFD0 Tag 0xC61D'].values[0]) + except KeyError: + white = WHITE_16 # mono DNGs may omit WhiteLevel + blacks = tag('BlackLevel').values + try: + black = int(blacks[0]) + except TypeError: + black = int(blacks) + return { + 'exposure_us': int(exp.num / exp.den * 1_000_000), + 'iso': int(tag('ISOSpeedRatings').values[0]), + 'width': int(tag('ImageWidth').values[0]), + 'height': int(tag('ImageLength').values[0]), + 'sigbits': white.bit_length(), + 'blacklevel': black, + } + + +def scan_project(directory: Path | str, excluded: frozenset[str] | set[str] = frozenset()) -> list[CaptureGroup]: + """Group a project directory's DNGs into verified operating-point bursts. + + Only the directory root is scanned (where CTT's own captures live). + Excluded filenames are dropped before grouping; files with unreadable or + incomplete EXIF are skipped with a warning attached to their group. Name + groups whose frames disagree on the operating point split into sub-groups + labelled `#2`, `#3`, ... in scan order. + """ + directory = Path(directory) + by_stem: dict[str, list[Path]] = {} + for path in sorted(directory.glob('*.dng')): + if path.name in excluded: + continue + if classify(path.name) is None: + continue + by_stem.setdefault(group_key(path.name), []).append(path) + + groups: list[CaptureGroup] = [] + for stem, paths in by_stem.items(): + kind = classify(stem) + colour_temp, lux = get_col_lux(paths[0].name) + by_point: dict[tuple, CaptureGroup] = {} + warnings: list[str] = [] + for path in paths: + try: + meta = _read_exif(path) + except Exception as err: + warnings.append(f'{path.name}: unreadable metadata, skipped ({err.__class__.__name__}: {err})') + continue + key = tuple(meta.values()) + if key not in by_point: + suffix = f'#{len(by_point) + 1}' if by_point else '' + by_point[key] = CaptureGroup( + label=Path(stem).stem + suffix, + kind=kind, + paths=[], + exposure_us=meta['exposure_us'], + gain=meta['iso'] / 100.0, + width=meta['width'], + height=meta['height'], + sigbits=meta['sigbits'], + blacklevel=meta['blacklevel'], + colour_temp=colour_temp, + lux=lux, + ) + by_point[key].paths.append(path) + point_groups = list(by_point.values()) + if len(point_groups) > 1: + summary = ', '.join(f'{g.label} ({len(g.paths)} frames)' for g in point_groups) + warnings.append(f'{stem}: frames span {len(point_groups)} operating points — split into {summary}') + for g in point_groups: + g.warnings.extend(warnings) + groups.extend(point_groups) + return groups diff --git a/ctt/characterisation/frames.py b/ctt/characterisation/frames.py new file mode 100644 index 0000000..6d08093 --- /dev/null +++ b/ctt/characterisation/frames.py @@ -0,0 +1,109 @@ +# SPDX-License-Identifier: BSD-2-Clause +# +# Copyright (C) 2026, Raspberry Pi +# +# FrameSet: the source-agnostic unit of characterisation work. +# +# A FrameSet is one operating point — a burst of measurement-channel ROI crops +# plus the exposure/gain/pedestal that produced them. The offline path builds +# one from DNGs on disk (frameset_from_dngs); a live capture path can build one +# directly from raw arrays. All downstream statistics are identical. +# +# All DN quantities use the loader's left-justified 16-bit domain (DN16): DNG +# writers disagree about the container's significant bits (PiDNG writes +# 16-bit-scaled samples with WhiteLevel 65535, rpicam writes native-depth +# samples), so the shifted domain of image_loader.dng_load_image is the only +# representation uniform across sources. sigbits is carried for reporting. + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field + +import numpy as np + +from ..core.image import Image +from ..core.image_loader import dng_load_image + +logger = logging.getLogger(__name__) + +# 16-bit white point of the loader domain. +WHITE_16 = 65535 + + +class MismatchedGroupError(ValueError): + """Raised when burst frames disagree on their operating point or geometry.""" + + +@dataclass +class FrameSet: + """One operating point: a burst of measurement-channel ROI crops (DN16). + + frames hold uint16 or float64 arrays; statistics promote to float64. The + single measurement channel is Gr (or the sole plane, 'Y', on mono sensors): + averaging the two greens halves the per-pixel variance and doubles the + apparent conversion gain. + """ + + frames: list[np.ndarray] + exposure_us: int + gain: float + blacklevel_16: float + sigbits: int + channel: str # 'Gr' or 'Y' + label: str = '' + sources: list[str] = field(default_factory=list) + + +def centre_roi(shape: tuple[int, int], fraction: float = 0.5) -> tuple[slice, slice]: + """Centred ROI slices covering `fraction` of each axis (0.5 -> 25% of area). + + A central region avoids lens shading and vignetting at the edges, keeping + flat-field non-uniformity out of the temporal statistics. + """ + h, w = shape + rh, rw = max(1, round(h * fraction)), max(1, round(w * fraction)) + y0, x0 = (h - rh) // 2, (w - rw) // 2 + return slice(y0, y0 + rh), slice(x0, x0 + rw) + + +def gr_plane(img: Image) -> np.ndarray: + """The single measurement plane: Gr via img.order, or the sole plane on mono.""" + if img.pattern == 128: + return img.channels[0] + return img.channels[img.order[1]] + + +def frameset_from_dngs(paths: list[str], roi_fraction: float = 0.5) -> FrameSet: + """Build a FrameSet from a burst of DNGs, one frame at a time. + + Each frame's Gr ROI is cropped immediately and the rest of the image is + dropped, so peak memory is ~ROI x N rather than burst x full-res. Frames + must agree exactly on exposure, gain, geometry, sigbits and black level — + discovery groups by these, so a mismatch here is an internal error. + """ + if not paths: + raise ValueError('frameset_from_dngs needs at least one path') + frames: list[np.ndarray] = [] + first: Image | None = None + for path in paths: + img = dng_load_image(None, str(path), demosaic=False) + plane = gr_plane(img) + if first is None: + first = img + roi = centre_roi(plane.shape, roi_fraction) + else: + key = (img.exposure, img.againQ8_norm, img.sigbits, img.blacklevel_16, plane.shape) + ref = (first.exposure, first.againQ8_norm, first.sigbits, first.blacklevel_16, gr_plane(first).shape) + if key != ref: + raise MismatchedGroupError(f'{path}: operating point differs from {first.name}: {key} != {ref}') + frames.append(plane[roi].copy()) # copy releases the full-res parent array + return FrameSet( + frames=frames, + exposure_us=first.exposure, + gain=first.againQ8_norm, + blacklevel_16=float(first.blacklevel_16), + sigbits=first.sigbits, + channel='Y' if first.pattern == 128 else 'Gr', + sources=[str(p) for p in paths], + ) diff --git a/ctt/characterisation/ptc.py b/ctt/characterisation/ptc.py new file mode 100644 index 0000000..07e0455 --- /dev/null +++ b/ctt/characterisation/ptc.py @@ -0,0 +1,167 @@ +# SPDX-License-Identifier: BSD-2-Clause +# +# Copyright (C) 2026, Raspberry Pi +# +# Photon-transfer analysis: variance-vs-mean points, per-gain fits, and the +# derived metrics (conversion gain, read noise, and — given a sweep — SNR, +# linearity, full well and dynamic range). +# +# For a shot-noise-limited sensor the temporal variance is linear in the mean +# signal: slope = 1/K (K the conversion gain in e-/DN16), intercept = read +# noise squared. Clipped points are excluded from fits (saturation collapses +# the variance), and points are never pooled across analogue gains — the slope +# depends on gain, so each exact gain is its own fit family. + +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np + +from .frames import WHITE_16, FrameSet +from .stats import temporal_stats + + +@dataclass +class PtcPoint: + """One variance-vs-mean measurement at a single operating point (DN16).""" + + mean_dn: float + var_dn2: float + exposure_us: int + gain: float + n_frames: int + clipped: bool # excluded from fits + label: str = '' + source: str = 'flat' # 'flat' | 'chart' | 'live' + + +def ptc_point(fs: FrameSet, clip_limit: float = 0.9, source: str = 'flat') -> PtcPoint | None: + """One PTC point from a burst; None when temporal statistics are impossible. + + A point is flagged clipped when its black-subtracted mean exceeds + clip_limit of the available swing, or when any meaningful fraction of ROI + pixels sits at the ceiling — either way the variance is untrustworthy. + """ + ts = temporal_stats(fs) + if ts is None: + return None + swing = WHITE_16 - fs.blacklevel_16 + clipped = ts.mean_dn > clip_limit * swing or ts.clip_fraction > 0.001 + return PtcPoint( + mean_dn=ts.mean_dn, + var_dn2=ts.var_dn2, + exposure_us=fs.exposure_us, + gain=fs.gain, + n_frames=ts.n_frames, + clipped=clipped, + label=fs.label, + source=source, + ) + + +@dataclass +class PtcFit: + """A straight-line PTC fit for one exact analogue gain.""" + + gain: float + k_e_per_dn: float # conversion gain, e- per DN16 (native K = K16 * 2^(16-sigbits)) + read_noise_dn: float | None # sqrt(intercept); None when the intercept is <= 0 + read_noise_e: float | None + r2: float + n_points: int + span_ratio: float # max(mean)/min(mean) of the fitted points + reliable: bool + + +def fit_ptc(points: list[PtcPoint]) -> list[PtcFit]: + """One fit per exact-gain family of unclipped flat/live points. + + Fits are always returned when arithmetically possible, but `reliable` is + computed, not asserted: a two-point or narrow-span "fit" is reported as + indicative only, never as the sensor's conversion gain. Chart-sourced + points are excluded (a chart is not a flat field). + """ + fits: list[PtcFit] = [] + usable = [p for p in points if not p.clipped and p.source != 'chart' and p.mean_dn > 0] + for gain in sorted({round(p.gain, 4) for p in usable}): + family = [p for p in usable if round(p.gain, 4) == gain] + if len(family) < 2: + continue + means = np.array([p.mean_dn for p in family]) + variances = np.array([p.var_dn2 for p in family]) + slope, intercept = np.polyfit(means, variances, 1) + if slope <= 0: + continue # non-physical; not worth reporting even as indicative + predicted = slope * means + intercept + ss_res = float(((variances - predicted) ** 2).sum()) + ss_tot = float(((variances - variances.mean()) ** 2).sum()) + r2 = 1.0 - ss_res / ss_tot if ss_tot > 0 else 0.0 + k = 1.0 / slope + read_noise_dn = float(np.sqrt(intercept)) if intercept > 0 else None + span = float(means.max() / means.min()) + fits.append( + PtcFit( + gain=gain, + k_e_per_dn=float(k), + read_noise_dn=read_noise_dn, + read_noise_e=read_noise_dn * k if read_noise_dn is not None else None, + r2=r2, + n_points=len(family), + span_ratio=span, + reliable=len(family) >= 3 and span >= 4.0 and r2 >= 0.99, + ) + ) + return fits + + +# --- sweep-derived metrics (used by the live-sweep path) -------------------- + + +def linearity(points: list[PtcPoint]) -> dict | None: + """Straight-line fit of mean signal vs exposure for one gain family. + + Returns {'slope_dn_per_us', 'intercept_dn', 'r2', 'n_points'} from the + unclipped points. None below three points: a two-point line always fits + perfectly, so its R² would be confidence theatre. + """ + usable = [p for p in points if not p.clipped and p.source != 'chart'] + if len(usable) < 3: + return None + x = np.array([p.exposure_us for p in usable], dtype=np.float64) + y = np.array([p.mean_dn for p in usable]) + slope, intercept = np.polyfit(x, y, 1) + predicted = slope * x + intercept + ss_tot = float(((y - y.mean()) ** 2).sum()) + r2 = 1.0 - float(((y - predicted) ** 2).sum()) / ss_tot if ss_tot > 0 else 0.0 + return { + 'slope_dn_per_us': float(slope), + 'intercept_dn': float(intercept), + 'r2': r2, + 'n_points': len(usable), + } + + +def full_well(points: list[PtcPoint]) -> dict | None: + """The saturation level in DN16: the highest clipped mean, if any point clipped. + + A proper knee needs a sweep that actually reaches saturation; with none of + the points clipped the full well is not observable and None is returned. + """ + clipped = [p for p in points if p.clipped] + if not clipped: + return None + return {'full_well_dn': float(max(p.mean_dn for p in clipped))} + + +def snr_curve(points: list[PtcPoint]) -> list[dict]: + """SNR (mean / temporal sigma) per unclipped point, for plotting.""" + out = [] + for p in sorted((p for p in points if not p.clipped and p.var_dn2 > 0), key=lambda p: p.mean_dn): + out.append({'mean_dn': p.mean_dn, 'snr_db': float(20.0 * np.log10(p.mean_dn / np.sqrt(p.var_dn2)))}) + return out + + +def dynamic_range(full_well_e: float, read_noise_e: float) -> float: + """Dynamic range in dB, both quantities in electrons.""" + return float(20.0 * np.log10(full_well_e / read_noise_e)) diff --git a/ctt/characterisation/stats.py b/ctt/characterisation/stats.py new file mode 100644 index 0000000..15781b0 --- /dev/null +++ b/ctt/characterisation/stats.py @@ -0,0 +1,99 @@ +# SPDX-License-Identifier: BSD-2-Clause +# +# Copyright (C) 2026, Raspberry Pi +# +# Temporal and spatial statistics of a FrameSet. +# +# Temporal statistics (per pixel, across the burst) isolate read + shot noise; +# spatial statistics (across pixels of the time-averaged frame, after removing +# a low-order shading fit) give the fixed-pattern non-uniformity — DSNU on a +# dark burst, PRNU on an illuminated flat field. + +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np + +from .frames import WHITE_16, FrameSet + +# A pixel whose temporal mean sits this close to the 16-bit ceiling is counted +# as clipped: saturation collapses the temporal variance and drags fits down. +_CLIP_DN = 0.98 * WHITE_16 + + +@dataclass +class TemporalStats: + """Burst temporal statistics over the ROI (DN16, black-subtracted mean).""" + + mean_dn: float # ROI mean of the per-pixel temporal mean, black-subtracted + var_dn2: float # ROI mean of the per-pixel temporal variance (ddof=1) + n_frames: int + clip_fraction: float # fraction of ROI pixels with temporal mean near white + + +def temporal_stats(fs: FrameSet) -> TemporalStats | None: + """Per-pixel temporal statistics across the burst; None below two frames.""" + if len(fs.frames) < 2: + return None + stack = np.stack([f.astype(np.float64) for f in fs.frames]) + per_pixel_mean = stack.mean(axis=0) + per_pixel_var = stack.var(axis=0, ddof=1) + return TemporalStats( + mean_dn=float(per_pixel_mean.mean() - fs.blacklevel_16), + var_dn2=float(per_pixel_var.mean()), + n_frames=len(fs.frames), + clip_fraction=float((per_pixel_mean >= _CLIP_DN).mean()), + ) + + +def shading_fit(mean_frame: np.ndarray, order: int = 2) -> np.ndarray: + """Least-squares low-order 2D polynomial fit of a frame (the shading term). + + order=2 fits the six terms 1, x, y, x^2, xy, y^2 on coordinates normalised + to [-1, 1] — enough to absorb lens shading and vignetting without eating + the pixel-scale fixed pattern being measured. + """ + h, w = mean_frame.shape + y, x = np.mgrid[0:h, 0:w] + x = 2.0 * x / max(w - 1, 1) - 1.0 + y = 2.0 * y / max(h - 1, 1) - 1.0 + cols = [np.ones_like(x)] + for total in range(1, order + 1): + for i in range(total + 1): + cols.append(x ** (total - i) * y**i) + basis = np.stack([c.ravel() for c in cols], axis=1) + coeffs, *_ = np.linalg.lstsq(basis, mean_frame.ravel(), rcond=None) + return (basis @ coeffs).reshape(mean_frame.shape) + + +@dataclass +class SpatialStats: + """Fixed-pattern non-uniformity of the time-averaged frame (DN16).""" + + mean_dn: float # mean of the averaged frame, black-subtracted + residual_std_dn: float # spatial sigma after shading removal + nonuniformity_pct: float | None # residual/mean * 100; None near zero mean + n_frames: int + + +def spatial_stats(fs: FrameSet, order: int = 2) -> SpatialStats: + """Fixed-pattern statistics: time-average, remove shading, take the residual. + + Time-averaging first suppresses the temporal noise by sqrt(N), so the + residual is dominated by the fixed pattern: DSNU in DN16 on a dark burst, + PRNU (as % of mean signal) on a flat field. + """ + stack = np.stack([f.astype(np.float64) for f in fs.frames]) + mean_frame = stack.mean(axis=0) + residual = mean_frame - shading_fit(mean_frame, order) + mean_dn = float(mean_frame.mean() - fs.blacklevel_16) + residual_std = float(residual.std()) + # Percentage non-uniformity is meaningless without signal (e.g. darks). + pct = residual_std / mean_dn * 100.0 if mean_dn > 1.0 else None + return SpatialStats( + mean_dn=mean_dn, + residual_std_dn=residual_std, + nonuniformity_pct=pct, + n_frames=len(fs.frames), + ) diff --git a/devices/cl70f/device.py b/devices/cl70f/device.py index 2d1236d..28e0981 100644 --- a/devices/cl70f/device.py +++ b/devices/cl70f/device.py @@ -14,11 +14,12 @@ from __future__ import annotations +import dataclasses import logging import threading import time -from ..lightmeter import LightMeter, LightmeterError, LightmeterTimeout, Measurement +from ..lightmeter import LightMeter, LightmeterError, LightmeterTimeout, Measurement, MeasurementLimits from . import protocol from .transport import Transport, open_transport @@ -43,6 +44,17 @@ class CL70F(LightMeter): _MEASURE_TIMEOUT_S = 30.0 _POLL_INTERVAL_S = 0.2 + # Rated measurement range (CL-70F spec): illuminance 1–200,000 lx, colour needs + # >= 5 lx, CCT 1,563–100,000 K. Below range the meter returns sentinel values + # (e.g. -100 lx), which we flag rather than trust. + LIMITS = MeasurementLimits( + illuminance_min=1.0, + illuminance_max=200_000.0, + colour_min_lux=5.0, + cct_min=1563.0, + cct_max=100_000.0, + ) + def __init__(self, transport: Transport) -> None: self._transport = transport self._lock = threading.Lock() @@ -70,6 +82,10 @@ def model(self) -> str: def serial(self) -> str | None: return self._serial + @property + def limits(self) -> MeasurementLimits: + return self.LIMITS + # --- measurement ------------------------------------------------------- def measure(self) -> Measurement: """Take a fresh measurement and return the result. @@ -162,11 +178,18 @@ def _ensure_remote_locked(self) -> None: self._transact_locked('RT', '1', timeout_ms=self._DATA_TIMEOUT_MS) def _read_result_locked(self) -> Measurement: - """Read and decode the latest measurement (NR). Raises _NoResult if empty.""" + """Read and decode the latest measurement (NR). Raises _NoResult if empty. + + Flags the reading out of range (in_range False) when its illuminance falls + outside the meter's rated limits — below range the device fills the fields + with sentinels (e.g. -100 lx), which are not a real measurement. + """ resp = self._transact_locked('NR', timeout_ms=self._DATA_TIMEOUT_MS) if not resp.red: raise _NoResult - return protocol.decode_nr(resp.red) + m = protocol.decode_nr(resp.red) + in_range = self.LIMITS.illuminance_min <= m.illuminance_lux <= self.LIMITS.illuminance_max + return dataclasses.replace(m, in_range=in_range) def _poll_idle_locked(self) -> None: """Poll status (ST) until the busy bit clears, raising on an error state.""" diff --git a/devices/lightmeter.py b/devices/lightmeter.py index 01b5038..f82350d 100644 --- a/devices/lightmeter.py +++ b/devices/lightmeter.py @@ -22,6 +22,25 @@ logger = logging.getLogger(__name__) +@dataclass(frozen=True) +class MeasurementLimits: + """The range over which a meter's readings are valid. + + Illuminance below `illuminance_min` (or above `illuminance_max`) is out of range; + colour metrics (CCT, CRI, chromaticity) additionally need at least `colour_min_lux` + to be meaningful. Kept device-independent so any driver can declare its own. + """ + + illuminance_min: float + illuminance_max: float + colour_min_lux: float + cct_min: float + cct_max: float + + def to_dict(self) -> dict: + return asdict(self) + + @dataclass(frozen=True) class Spectrum: """A spectral power distribution sampled on a regular wavelength grid. @@ -67,6 +86,11 @@ class Measurement: spectrum_5nm: Spectrum | None = None # 380–780 nm, 5 nm pitch spectrum_1nm: Spectrum | None = None # 380–780 nm, 1 nm pitch + # False when the illuminance is outside the meter's measurable range (see + # LightMeter.limits): the returned values are the device's under/over-range + # sentinels, not a real measurement, and must not be trusted. + in_range: bool = True + def to_dict(self) -> dict: """A JSON-friendly dict, dropping None fields so the payload stays compact.""" return {k: v for k, v in asdict(self).items() if v is not None} @@ -98,6 +122,15 @@ def model(self) -> str: def serial(self) -> str | None: return None + @property + def limits(self) -> MeasurementLimits | None: + """The meter's valid measurement range, or None if it declares none. + + Drivers that know their range override this; a reading whose illuminance + falls outside it is returned with `Measurement.in_range` False. + """ + return None + # --- discovery --------------------------------------------------------- @classmethod @abstractmethod @@ -132,6 +165,7 @@ def info(self) -> dict: return { 'model': self.model, 'serial': self.serial, + 'limits': self.limits.to_dict() if self.limits else None, } # --- context manager --------------------------------------------------- diff --git a/docs/ctt-server.md b/docs/ctt-server.md index da70489..d3cfac5 100644 --- a/docs/ctt-server.md +++ b/docs/ctt-server.md @@ -248,8 +248,13 @@ or light meter is not available. | Method | Path | Description | |--------|------|-------------| -| GET | `/api/lightmeter` | Read-only status: `{"present": bool, ...model/serial}` (takes no measurement) | -| POST | `/api/lightmeter` | `{"action": "sample"}` — take one measurement; returns identity plus `reading` (the full `Measurement.to_dict()`) | +| GET | `/api/lightmeter` | Read-only status: `{"present": bool, ...model/serial, "limits": {...}}` (takes no measurement). `limits` gives the meter's valid range (`illuminance_min`/`max`, `colour_min_lux`, `cct_min`/`max`) or `null` | +| POST | `/api/lightmeter` | `{"action": "sample"}` — take one measurement; returns identity, `limits`, and `reading` (the full `Measurement.to_dict()`, including `in_range`). The reading is capped to the limits: `in_range` is `false` out of range, illuminance is clamped to the nearest bound (never the device sentinel like `-100 lx`), and colour metrics are omitted when there is less than `colour_min_lux` of light | + +A reading taken while out of range is never recorded with a capture (the capture stores +no `lightmeter` metadata), and the automated flows reject it (see Auto-capture / +Auto-characterise): stabilisation fails the step with an "add light" message rather than +tagging or sweeping against a bad reading. The web UI shows out-of-range values in red. ### Auto-capture (needs a lightbox and a light meter) diff --git a/docs/device-control.md b/docs/device-control.md index 8b87166..58d8769 100644 --- a/docs/device-control.md +++ b/docs/device-control.md @@ -91,12 +91,23 @@ XYZ, dominant wavelength, excitation purity, CRI (Ra and R1–R15) and the 380 spectrum where the device supplies them. `read_latest()` returns the device's last reading without triggering a new one. +**Measurement limits.** A meter may declare its valid range via `LightMeter.limits` +(a `MeasurementLimits` with `illuminance_min`/`illuminance_max`, `colour_min_lux` and +`cct_min`/`cct_max`). When a reading's illuminance falls outside the range the device +returns under/over-range sentinels (the CL-70F reports e.g. `-100 lx`), so `measure()` +sets `Measurement.in_range = False` — the numbers are not a real measurement and must +not be trusted. The CL-70F's range is **1–200,000 lx** (colour metrics need **≥ 5 lx**), +CCT **1,563–100,000 K**; below ~1 lx you get an out-of-range reading, and adding light +is the only remedy (no meter setting extends the floor). + ```python from devices import get_lightmeter with get_lightmeter() as meter: # first attached, supported meter reading = meter.measure() # trigger and read one measurement - print(reading.illuminance_lux, reading.cct) + if reading.in_range: # False when below/above the meter's range + print(reading.illuminance_lux, reading.cct) + print(meter.limits) # MeasurementLimits, or None if undeclared print(reading.to_dict()) # JSON-friendly, omitting unset fields meter.read_latest() # last stored reading, or None ``` diff --git a/tests/test_characterisation.py b/tests/test_characterisation.py new file mode 100644 index 0000000..cc99dac --- /dev/null +++ b/tests/test_characterisation.py @@ -0,0 +1,188 @@ +# SPDX-License-Identifier: BSD-2-Clause +# +# Copyright (C) 2026, Raspberry Pi +# +# Ground-truth tests for the characterisation engine: synthetic bursts with a +# known conversion gain, read noise, DSNU and PRNU, asserting the statistics +# and fits recover them. No files, no hardware. + +import numpy as np +import pytest + +from ctt.characterisation import ( + FrameSet, + centre_roi, + fit_ptc, + ptc_point, + shading_fit, + spatial_stats, + temporal_stats, +) +from ctt.characterisation.ptc import full_well, linearity, snr_curve + +BLACK_16 = 3840.0 # 240 << 4: the PiDNG 16-bit-scaled pedestal observed in real files +DN_PER_E = 1.5 # sensor response in DN16 per electron (K = 1/DN_PER_E e-/DN16) +READ_NOISE_DN = 15.0 # in DN16 (~= 10 e-) + + +def synth_burst( + n_frames, + signal_e, + *, + exposure_us=10_000, + gain=1.0, + read_noise_dn=READ_NOISE_DN, + prnu_pct=0.0, + dsnu_dn=0.0, + shading=0.0, + shape=(64, 64), + rng=None, + label='synth', +): + """A burst of frames: black + DN_PER_E * Poisson(signal) + read noise. + + prnu_pct applies a fixed per-pixel gain map; dsnu_dn a fixed per-pixel + offset map; shading a quadratic bowl of that relative depth — all constant + across the burst, as real fixed patterns are. + """ + rng = rng or np.random.default_rng(42) + prnu_map = 1.0 + rng.normal(0.0, prnu_pct / 100.0, shape) + dsnu_map = rng.normal(0.0, dsnu_dn, shape) + h, w = shape + y, x = np.mgrid[0:h, 0:w] + bowl = 1.0 - shading * (((x / max(w - 1, 1)) - 0.5) ** 2 + ((y / max(h - 1, 1)) - 0.5) ** 2) + frames = [] + for _ in range(n_frames): + electrons = rng.poisson(np.maximum(signal_e * prnu_map * bowl, 0.0)) + dn = BLACK_16 + DN_PER_E * electrons + dsnu_map + rng.normal(0.0, read_noise_dn, shape) + frames.append(np.clip(dn, 0.0, 65535.0)) + return FrameSet( + frames=frames, + exposure_us=exposure_us, + gain=gain, + blacklevel_16=BLACK_16, + sigbits=16, + channel='Gr', + label=label, + ) + + +def sweep(exposures_e, gain=1.0, n_frames=16, rng=None, **kw): + """One PTC point per signal level, exposure proportional to signal.""" + rng = rng or np.random.default_rng(7) + points = [] + for i, e in enumerate(exposures_e): + fs = synth_burst(n_frames, e, exposure_us=int(e * 10), gain=gain, rng=rng, label=f'pt{i}', **kw) + points.append(ptc_point(fs)) + return [p for p in points if p is not None] + + +def test_centre_roi_covers_quarter_area(): + ys, xs = centre_roi((100, 200), 0.5) + assert (ys.stop - ys.start) == 50 and (xs.stop - xs.start) == 100 + assert ys.start == 25 and xs.start == 50 + + +def test_temporal_stats_recover_mean_and_variance(): + signal_e = 4000.0 + fs = synth_burst(16, signal_e, rng=np.random.default_rng(1)) + ts = temporal_stats(fs) + # Mean: black-subtracted signal in DN16. + assert ts.mean_dn == pytest.approx(signal_e * DN_PER_E, rel=0.02) + # Variance: shot (DN_PER_E^2 * e) + read (READ_NOISE_DN^2). + expected_var = DN_PER_E**2 * signal_e + READ_NOISE_DN**2 + assert ts.var_dn2 == pytest.approx(expected_var, rel=0.05) + assert ts.clip_fraction == 0.0 + + +def test_temporal_stats_single_frame_is_none(): + assert temporal_stats(synth_burst(1, 100.0)) is None + + +def test_ptc_fit_recovers_conversion_gain_and_read_noise(): + points = sweep([50, 120, 300, 700, 1500, 3500, 8000, 15000]) + fits = fit_ptc(points) + assert len(fits) == 1 + fit = fits[0] + assert fit.reliable + assert fit.k_e_per_dn == pytest.approx(1.0 / DN_PER_E, rel=0.05) + assert fit.read_noise_dn == pytest.approx(READ_NOISE_DN, rel=0.25) # intercept is noisy + assert fit.read_noise_e == pytest.approx(READ_NOISE_DN / DN_PER_E, rel=0.25) + + +def test_clipped_points_do_not_move_the_fit(): + rng = np.random.default_rng(11) + points = sweep([50, 120, 300, 700, 1500, 3500, 8000, 15000], rng=rng) + k_clean = fit_ptc(points)[0].k_e_per_dn + # Saturating points: mean driven into the ceiling, variance collapsed. + saturated = sweep([50000, 60000], rng=rng) + assert all(p.clipped for p in saturated) + k_with_clipped = fit_ptc(points + saturated)[0].k_e_per_dn + assert k_with_clipped == pytest.approx(k_clean, rel=1e-9) + + +def test_fits_are_per_gain_family_and_never_pooled(): + rng = np.random.default_rng(3) + points = sweep([100, 500, 2000, 9000], gain=1.0, rng=rng) + sweep([100, 500, 2000, 9000], gain=1.07, rng=rng) + fits = fit_ptc(points) + assert [f.gain for f in fits] == [1.0, 1.07] + assert all(f.n_points == 4 for f in fits) + + +def test_sparse_or_narrow_fits_are_not_reliable(): + # Two points: fit exists but must be flagged indicative. + two = fit_ptc(sweep([1000, 1600])) + assert len(two) == 1 and not two[0].reliable and two[0].n_points == 2 + # Three points but a narrow span: still not reliable. + narrow = fit_ptc(sweep([1000, 1200, 1500])) + assert len(narrow) == 1 and not narrow[0].reliable + # A single point: no fit at all. + assert fit_ptc(sweep([1000])) == [] + + +def test_chart_points_are_excluded_from_fits(): + points = sweep([100, 500, 2000, 9000]) + for p in points: + p.source = 'chart' + assert fit_ptc(points) == [] + + +def test_spatial_stats_recover_prnu_under_shading(): + # High signal + deep burst so the fixed pattern dominates the temporal residual. + fs = synth_burst(64, 20000.0, prnu_pct=2.0, shading=0.3, rng=np.random.default_rng(5)) + ss = spatial_stats(fs) + assert ss.nonuniformity_pct == pytest.approx(2.0, rel=0.10) + + +def test_spatial_stats_recover_dsnu_on_darks(): + fs = synth_burst(64, 0.0, dsnu_dn=10.0, rng=np.random.default_rng(6)) + ss = spatial_stats(fs) + assert ss.residual_std_dn == pytest.approx(10.0, rel=0.10) + assert ss.nonuniformity_pct is None # no signal: a percentage is meaningless + + +def test_shading_fit_removes_a_quadratic_bowl(): + h, w = 64, 64 + y, x = np.mgrid[0:h, 0:w] + surface = 1000.0 + 200.0 * (x / 63 - 0.5) ** 2 + 150.0 * (y / 63 - 0.5) * (x / 63 - 0.5) + residual = surface - shading_fit(surface) + assert float(np.abs(residual).max()) < 1e-6 + + +def test_linearity_and_full_well_and_snr(): + rng = np.random.default_rng(9) + points = sweep([100, 500, 2000, 9000, 20000], rng=rng) + lin = linearity(points) + assert lin['r2'] > 0.999 + assert full_well(points) is None # nothing clipped: full well not observable + saturated = sweep([60000], rng=rng) + fw = full_well(points + saturated) + assert fw is not None and fw['full_well_dn'] > 0.9 * (65535 - BLACK_16) + curve = snr_curve(points) + assert len(curve) == 5 and curve[-1]['snr_db'] > curve[0]['snr_db'] + + +def test_mono_frameset_flows_through(): + fs = synth_burst(8, 3000.0, rng=np.random.default_rng(12)) + fs.channel = 'Y' + assert temporal_stats(fs) is not None and ptc_point(fs) is not None diff --git a/tests/test_characterisation_discover.py b/tests/test_characterisation_discover.py new file mode 100644 index 0000000..20b0568 --- /dev/null +++ b/tests/test_characterisation_discover.py @@ -0,0 +1,120 @@ +# SPDX-License-Identifier: BSD-2-Clause +# +# Copyright (C) 2026, Raspberry Pi +# +# Tests for characterisation discovery: burst grouping by filename stem with +# exact EXIF operating-point verification. EXIF reads are stubbed, so these +# run without real DNGs. + +import pytest + +from ctt.characterisation import discover +from ctt.characterisation.discover import CaptureGroup, classify, group_key, scan_project + +# A plausible operating point; tests override fields per file as needed. +BASE = {'exposure_us': 33333, 'iso': 100, 'width': 968, 'height': 548, 'sigbits': 16, 'blacklevel': 3840} + + +@pytest.fixture +def workspace(tmp_path, monkeypatch): + """A fake project dir + a per-filename EXIF table the stub serves from.""" + exif: dict[str, dict] = {} + + def add(name, **overrides): + (tmp_path / name).write_bytes(b'DNG') + exif[name] = {**BASE, **overrides} + + def fake_read_exif(path): + meta = exif[path.name] + if meta.get('_raise'): + raise KeyError('ExposureTime') + return {k: v for k, v in meta.items() if not k.startswith('_')} + + monkeypatch.setattr(discover, '_read_exif', fake_read_exif) + return tmp_path, add + + +def test_group_key_strips_trailing_index(): + assert group_key('dark_3.dng') == 'dark.dng' + assert group_key('alsc_3500k_11.dng') == 'alsc_3500k.dng' + assert group_key('imx662_2910k_1250l_0.dng') == 'imx662_2910k_1250l.dng' + assert group_key('imx662_2910k_1250l.dng') == 'imx662_2910k_1250l.dng' # index-free stays + + +def test_classify_kinds(): + assert classify('dark_0.dng') == 'dark' + assert classify('alsc_3500k_0.dng') == 'flat' + assert classify('cac_5000k_0.dng') is None + assert classify('imx662_2910k_1250l_0.dng') == 'chart' + + +def test_scan_groups_bursts_and_reads_tags(workspace): + root, add = workspace + for i in range(3): + add(f'dark_{i}.dng') + for i in range(2): + add(f'alsc_3500k_{i}.dng', exposure_us=133, iso=107, blacklevel=3200) + add('imx662_2910k_1250l_0.dng', exposure_us=5000) + + groups = {g.label: g for g in scan_project(root)} + assert set(groups) == {'dark', 'alsc_3500k', 'imx662_2910k_1250l'} + + dark = groups['dark'] + assert dark.kind == 'dark' and len(dark.paths) == 3 + assert dark.exposure_us == 33333 and dark.gain == 1.0 + assert dark.blacklevel_16 == 3840 # sigbits 16: no shift + + flat = groups['alsc_3500k'] + assert flat.kind == 'flat' and flat.gain == pytest.approx(1.07) + assert flat.colour_temp == 3500 and flat.lux is None + + chart = groups['imx662_2910k_1250l'] + assert chart.kind == 'chart' and chart.colour_temp == 2910 and chart.lux == 1250 + + +def test_scan_splits_mixed_operating_points(workspace): + root, add = workspace + add('dark_0.dng') + add('dark_1.dng') + add('dark_2.dng', exposure_us=66666) # different exposure: its own group + + groups = sorted(scan_project(root), key=lambda g: g.label) + assert [g.label for g in groups] == ['dark', 'dark#2'] + assert [len(g.paths) for g in groups] == [2, 1] + assert any('operating points' in w for w in groups[0].warnings) + + +def test_scan_skips_excluded_and_cac(workspace): + root, add = workspace + add('dark_0.dng') + add('dark_1.dng') + add('cac_5000k_0.dng') + + groups = scan_project(root, excluded={'dark_1.dng'}) + assert len(groups) == 1 + assert [p.name for p in groups[0].paths] == ['dark_0.dng'] + + +def test_scan_warns_and_skips_unreadable_metadata(workspace): + root, add = workspace + add('dark_0.dng') + add('dark_1.dng', _raise=True) + + groups = scan_project(root) + assert len(groups) == 1 and len(groups[0].paths) == 1 + assert any('unreadable metadata' in w for w in groups[0].warnings) + + +def test_native_blacklevel_scales_to_16bit_domain(): + g = CaptureGroup( + label='x', + kind='dark', + paths=[], + exposure_us=1, + gain=1.0, + width=1, + height=1, + sigbits=12, + blacklevel=200, + ) + assert g.blacklevel_16 == 200 << 4 diff --git a/tests/test_characterise_sweep.py b/tests/test_characterise_sweep.py new file mode 100644 index 0000000..0a86c6e --- /dev/null +++ b/tests/test_characterise_sweep.py @@ -0,0 +1,181 @@ +# SPDX-License-Identifier: BSD-2-Clause +# +# Copyright (C) 2026, Raspberry Pi +# +# Tests for the live exposure sweep: a synthetic-sensor camera stands in for +# Picamera2, so the whole path — probing, sweeping, fitting, results merge and +# control restoration — runs hardware-free with known ground truth. + +import json + +import numpy as np +import pytest + +from ctt_server import characterise, sessions + +DN_PER_E = 1.5 # sensor response at gain 1 (K = 1/1.5 e-/DN16) +READ_NOISE_DN = 15.0 +BLACK_16 = 4096.0 +# Electron full well above the DN ceiling at gain 1: the container clips first, +# as real sensors do at base gain, so the effective full well is swing / DN_PER_E. +FULL_WELL_E = 45_000 +SWING = 65535.0 - BLACK_16 +EFFECTIVE_FULL_WELL_E = SWING / DN_PER_E + + +class FakeCamera: + """A synthetic sensor behind the Picamera2Camera control/burst interface.""" + + def __init__(self, flux_e_per_us=2.0): + self.flux = flux_e_per_us + self.controls = {'auto_exposure': True, 'exposure': 10_000, 'gain': 1.0, 'fps': 30, 'exposure_min': 13} + self.set_history = [] + self.rng = np.random.default_rng(0) + + def get_controls(self): + return dict(self.controls) + + def set_controls(self, controls): + self.set_history.append(dict(controls)) + self.controls.update({k: v for k, v in controls.items() if v is not None}) + return self.get_controls() + + def capture_raw_burst(self, frames, exposure_us, gain, roi_fraction=0.5): + mean_e = self.flux * exposure_us + shape = (32, 32) + crops = [] + for _ in range(frames): + electrons = np.minimum(self.rng.poisson(mean_e, shape), FULL_WELL_E) + dn = BLACK_16 + DN_PER_E * gain * electrons + self.rng.normal(0, READ_NOISE_DN, shape) + crops.append(np.clip(dn, 0, 65535)) + return { + 'frames': crops, + 'exposure_us': int(exposure_us), + 'gain': float(gain), + 'blacklevel_16': BLACK_16, + 'sigbits': 12, + } + + +class BrokenCamera(FakeCamera): + def capture_raw_burst(self, *a, **kw): + raise RuntimeError('controls did not settle') + + +@pytest.fixture +def project(tmp_path): + return sessions.Workspace(tmp_path).create_project('imx662') + + +def _run(project, camera, **kw): + return list(characterise.sweep_stream(project, camera, **kw)) + + +def test_sweep_recovers_conversion_gain_end_to_end(project): + cam = FakeCamera() + lines = _run(project, cam, gains=[1.0], points_per_gain=10, frames=8) + assert lines[-1] == 'CHAR_EXIT 0' + + results = json.loads(characterise.results_path(project).read_text()) + fits = results['ptc']['fits'] + assert len(fits) == 1 and fits[0]['reliable'] + assert fits[0]['k_e_per_dn'] == pytest.approx(1.0 / DN_PER_E, rel=0.05) + assert results['ptc']['unavailable_reason'] is None + assert results['linearity']['available'] and results['linearity']['r2'] > 0.999 + # The deliberate over-exposure point makes the full well observable; at + # base gain the container clips first, so it is the DN-limited well. + assert results['full_well']['available'] + assert results['full_well']['full_well_e'] == pytest.approx(EFFECTIVE_FULL_WELL_E, rel=0.10) + assert results['dynamic_range']['available'] + expected_dr = 20 * np.log10(EFFECTIVE_FULL_WELL_E / (READ_NOISE_DN / DN_PER_E)) + assert results['dynamic_range']['db'] == pytest.approx(expected_dr, abs=1.5) + assert len(results['snr_curve']) > 3 + + +def test_sweep_gain_families_scale(project): + cam = FakeCamera() + lines = _run(project, cam, gains=[1.0, 2.0], points_per_gain=8, frames=8) + assert lines[-1] == 'CHAR_EXIT 0' + results = json.loads(characterise.results_path(project).read_text()) + summaries = {g['gain']: g for g in results['gain_sweep']['gains']} + assert results['gain_sweep']['available'] + assert summaries[2.0]['k_e_per_dn'] == pytest.approx(summaries[1.0]['k_e_per_dn'] / 2.0, rel=0.10) + + +def test_sweep_merges_into_offline_results(project): + # Pre-existing offline results: a dark section awaiting a conversion gain. + path = characterise.results_path(project) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps( + { + 'version': 1, + 'inputs': [], + 'groups': [], + 'dark': { + 'available': True, + 'read_noise_dn': 15.96, + 'dsnu_dn': 5.82, + 'gain': 1.0, + 'read_noise_e': None, + 'dsnu_e': None, + }, + 'ptc': { + 'points': [ + { + 'mean_dn': 1000.0, + 'var_dn2': 2000.0, + 'exposure_us': 100, + 'gain': 1.07, + 'n_frames': 8, + 'clipped': False, + 'label': 'alsc_3500k', + 'source': 'flat', + } + ], + 'fits': [], + 'unavailable_reason': 'x', + }, + 'prnu': {'available': True, 'groups': [], 'best_pct': 0.57}, + 'warnings': [], + } + ) + ) + lines = _run(project, FakeCamera(), gains=[1.0], points_per_gain=8, frames=8) + assert lines[-1] == 'CHAR_EXIT 0' + results = json.loads(path.read_text()) + # Offline sections survive; the flat point is still there alongside live ones. + assert results['prnu']['best_pct'] == 0.57 + sources = {p['source'] for p in results['ptc']['points']} + assert sources == {'flat', 'live'} + # The dark metrics gain electron units from the sweep's K. + assert results['dark']['read_noise_e'] == pytest.approx(15.96 / DN_PER_E, rel=0.06) + assert results['dark']['dsnu_e'] is not None + + +def test_sweep_restores_camera_controls(project): + cam = FakeCamera() + _run(project, cam, gains=[1.0], points_per_gain=8, frames=4) + final = cam.set_history[-1] + assert final['auto_exposure'] is True and final['awb'] is True and final['fps'] == 30 + + +def test_sweep_failure_still_restores_and_exits(project): + cam = BrokenCamera() + lines = _run(project, cam, gains=[1.0]) + assert lines[-1] == 'CHAR_EXIT 1' + assert any(ln.startswith('ERROR:') for ln in lines) + assert cam.set_history[-1]['auto_exposure'] is True + + +def test_sweep_route_mode_live(project, tmp_path, monkeypatch): + from ctt_server import app as app_module + + cam = FakeCamera() + monkeypatch.setattr(app_module, 'get_shared_camera', lambda: cam) + client = app_module.create_app(str(tmp_path)).test_client() + r = client.get('/projects/imx662/characterisation/analyse/stream?mode=live&gains=1&points=8&frames=4') + assert r.status_code == 200 + lines = [json.loads(x[6:]) for x in r.data.decode().splitlines() if x.startswith('data: ')] + assert lines[-1] == 'CHAR_EXIT 0' + assert any('live sweep' in ln for ln in lines) diff --git a/tests/test_ctt_server_auto_capture.py b/tests/test_ctt_server_auto_capture.py index c021799..c96a8aa 100644 --- a/tests/test_ctt_server_auto_capture.py +++ b/tests/test_ctt_server_auto_capture.py @@ -35,7 +35,7 @@ def measure(self): if entry is None: raise LightmeterError('scripted failure') lux, cct = entry - return Measurement(illuminance_lux=lux, cct=cct) + return Measurement(illuminance_lux=lux, cct=cct, in_range=lux >= 1.0) def steady_meter(lux=1000.0, cct=5000.0): @@ -196,6 +196,20 @@ def test_persistent_meter_failure_fails_the_lamp_and_continues(project): assert done['failed'] == ['F12'] and done['ok'] is False and done['captured'] == 1 +def test_under_range_readings_fail_the_lamp(project): + # A persistently under-range (below-limit) lamp fails with a clear message and is + # never tagged/captured. + dark_then_ok = ScriptedMeter([(-100.0, 0.0), (-100.0, 0.0), (-100.0, 0.0), (1000.0, 5000.0)]) + stream = drain( + run_auto_capture_stream( + project, FakeCam(), FakeBox(), dark_then_ok, [LampStep('F12'), LampStep('D65')], frames=1, cfg=FAST + ) + ) + (lamp_error,) = events(stream, 'lamp_error') + assert lamp_error['illuminant'] == 'F12' and 'range' in lamp_error['error'].lower() + assert stream[-1]['failed'] == ['F12'] and stream[-1]['captured'] == 1 + + # --- generator: chart-aware light adjustment --------------------------------------- def _set_percents(box): return [c[2] for c in box.calls if c[0] == 'set_illuminant'] diff --git a/tests/test_ctt_server_auto_characterise.py b/tests/test_ctt_server_auto_characterise.py new file mode 100644 index 0000000..7947859 --- /dev/null +++ b/tests/test_ctt_server_auto_characterise.py @@ -0,0 +1,319 @@ +# SPDX-License-Identifier: BSD-2-Clause +# +# Copyright (C) 2026, Raspberry Pi +# +# Tests for the gap-driven auto-characterise cycle (apps/ctt_server/auto_characterise.py) +# and its routes. Hardware-free: the generator runs against scripted fakes, and the +# characterise sub-streams (quick_scan / read_results / sweep_stream / analyse_stream) are +# monkeypatched so no pixel data or camera is needed. + +import json + +import pytest + +from ctt_server import app as app_module +from ctt_server import auto_characterise, characterise, sessions +from ctt_server.auto_capture import StabiliseConfig +from ctt_server.camera import CameraError +from devices import LightboxError, LightmeterError, Measurement + +FAST = StabiliseConfig(min_wait_s=0.0, sample_interval_s=0.0, timeout_s=1.0) + + +class ScriptedMeter: + def __init__(self, script=((1000.0, 5000.0),)): + self.script = list(script) + + def measure(self): + entry = self.script.pop(0) if len(self.script) > 1 else self.script[0] + if entry is None: + raise LightmeterError('scripted failure') + return Measurement(illuminance_lux=entry[0], cct=entry[1]) + + +class FakeBox: + illuminants = {4: 'D65', 7: 'Halogen400'} + + def __init__(self, fail_on=()): + self.calls = [] + self.fail_on = set(fail_on) + + def set_illuminant(self, illuminant, percent=None): + self.calls.append(('set_illuminant', illuminant, percent)) + if illuminant in self.fail_on: + raise LightboxError(f'lamp {illuminant} failed') + + def off(self): + self.calls.append(('off',)) + + def info(self): + return {'channel': 7, 'illuminant_temps': {7: 2856}} + + +class FakeCam: + def __init__(self, fail=False): + self.fail = fail + self.controls = {'exposure': 5000, 'gain': 1.0, 'auto_exposure': True, 'fps': 30, 'exposure_min': 100} + self.bursts = [] + + def get_controls(self): + return dict(self.controls) + + def set_controls(self, c): + self.controls.update({k: v for k, v in c.items() if v is not None}) + return dict(self.controls) + + def capture_burst(self, frames, quality=95): + if self.fail: + raise CameraError('camera gone') + gain = self.controls.get('gain') + self.bursts.append((frames, gain)) + return [(b'DNG', b'JPG', {'exposure': self.controls.get('exposure'), 'gain': gain})] * frames + + +@pytest.fixture +def project(tmp_path): + return sessions.Workspace(tmp_path).create_project('cam') + + +@pytest.fixture(autouse=True) +def stub_characterise(monkeypatch): + """Default: empty project, no results; sweep/analyse succeed and record calls.""" + calls = {'sweep': [], 'analyse': 0} + + monkeypatch.setattr(characterise, 'quick_scan', lambda proj: {'groups': [], 'has_results': False, 'stale': False}) + monkeypatch.setattr(characterise, 'read_results', lambda proj: None) + monkeypatch.setattr(characterise, 'flat_exposure', lambda cam, gain, roi_fraction=0.5, target=0.5: 1000) + + def fake_sweep(proj, cam, gains, points_per_gain=10, frames=8): + calls['sweep'].append(list(gains)) + yield f'sweep {gains}' + yield 'CHAR_EXIT 0' + + def fake_analyse(proj): + calls['analyse'] += 1 + yield 'analysed' + yield 'CHAR_EXIT 0' + + monkeypatch.setattr(characterise, 'sweep_stream', fake_sweep) + monkeypatch.setattr(characterise, 'analyse_stream', fake_analyse) + return calls + + +def drain(gen): + return list(gen) + + +def events(stream, kind): + return [e for e in stream if e['event'] == kind] + + +def run(project, box, meter, cam, **kw): + kw.setdefault('cfg', FAST) + return drain(auto_characterise.run_auto_characterise_stream(project, cam, box, meter, **kw)) + + +# --- gap analysis -------------------------------------------------------------- +def test_gaps_reuse_and_capture(monkeypatch, project): + monkeypatch.setattr( + characterise, + 'quick_scan', + lambda proj: {'groups': [{'kind': 'dark', 'gain': 1.0}], 'has_results': True, 'stale': False}, + ) + monkeypatch.setattr(characterise, 'read_results', lambda proj: {'ptc': {'fits': [{'gain': 1.0, 'reliable': True}]}}) + plan = auto_characterise._gaps(project, [1.0, 4.0], 0.05) + g1, g4 = plan + assert g1 == {'gain': 1.0, 'darks': 'reuse', 'flats': 'capture', 'sweep': 'reuse'} + assert g4 == {'gain': 4.0, 'darks': 'capture', 'flats': 'capture', 'sweep': 'sweep'} + + +def test_gaps_stale_results_force_resweep(monkeypatch, project): + monkeypatch.setattr(characterise, 'quick_scan', lambda proj: {'groups': [], 'has_results': True, 'stale': True}) + monkeypatch.setattr(characterise, 'read_results', lambda proj: {'ptc': {'fits': [{'gain': 1.0, 'reliable': True}]}}) + (g1,) = auto_characterise._gaps(project, [1.0], 0.05) + assert g1['sweep'] == 'sweep' # stale invalidates the reused fit + + +# --- full cycle ---------------------------------------------------------------- +def _run_with_lenscap(gen): + """Drain a generator, releasing the lens-cap pause when it waits.""" + stream = [] + for event in gen: + stream.append(event) + if event['event'] == 'waiting_user': + auto_characterise.request_continue() + return stream + + +def test_full_cycle_captures_all_when_project_empty(project, stub_characterise): + box, meter, cam = FakeBox(), ScriptedMeter(), FakeCam() + gen = auto_characterise.run_auto_characterise_stream( + project, cam, box, meter, [1.0], 'Halogen400', intensity=80.0, cfg=FAST + ) + stream = _run_with_lenscap(gen) + done = stream[-1] + assert done['event'] == 'done' and done['ok'] is True + assert stub_characterise['sweep'] == [[1.0]] and stub_characterise['analyse'] == 1 + assert ('set_illuminant', 'Halogen400', 80.0) in box.calls and ('off',) in box.calls + kinds = {c.image_type for c in project.captures} + assert kinds == {'alsc', 'dark'} + assert not auto_characterise.is_running() + + +def test_reused_inputs_are_not_recaptured(project, monkeypatch, stub_characterise): + # Darks + a reliable sweep already exist at gain 1.0; only the flat is missing. + monkeypatch.setattr( + characterise, + 'quick_scan', + lambda proj: {'groups': [{'kind': 'dark', 'gain': 1.0}], 'has_results': True, 'stale': False}, + ) + monkeypatch.setattr(characterise, 'read_results', lambda proj: {'ptc': {'fits': [{'gain': 1.0, 'reliable': True}]}}) + box, cam = FakeBox(), FakeCam() + stream = _run_with_lenscap( + auto_characterise.run_auto_characterise_stream(project, cam, box, ScriptedMeter(), [1.0], 'D65', cfg=FAST) + ) + assert stub_characterise['sweep'] == [] # sweep reused + assert ('off',) not in box.calls # no dark phase, box left as-is + assert {c.image_type for c in project.captures} == {'alsc'} # only the flat captured, no darks + assert 'dark g1' in stream[-1]['reused'] and 'sweep g1' in stream[-1]['reused'] + + +def test_operating_point_captures_dark_at_requested_gain(project, monkeypatch, stub_characterise): + # Darks exist at gain 1.0; requesting gain 4.0 must capture a fresh dark at 4.0. + monkeypatch.setattr( + characterise, + 'quick_scan', + lambda proj: {'groups': [{'kind': 'dark', 'gain': 1.0}], 'has_results': False, 'stale': False}, + ) + cam = FakeCam() + _run_with_lenscap( + auto_characterise.run_auto_characterise_stream( + project, cam, FakeBox(), ScriptedMeter(), [4.0], 'D65', include_flats=False, include_sweep=False, cfg=FAST + ) + ) + assert cam.bursts and all(g == 4.0 for _, g in cam.bursts) # captured at the requested gain + + +def test_sweep_filtered_to_missing_gains(project, monkeypatch, stub_characterise): + # gain 1.0 already has a reliable fit; requesting [1.0, 4.0] sweeps only 4.0. + monkeypatch.setattr(characterise, 'read_results', lambda proj: {'ptc': {'fits': [{'gain': 1.0, 'reliable': True}]}}) + monkeypatch.setattr(characterise, 'quick_scan', lambda proj: {'groups': [], 'has_results': True, 'stale': False}) + run( + project, + FakeBox(), + ScriptedMeter(), + FakeCam(), + gains=[1.0, 4.0], + lamp='D65', + include_darks=False, + include_flats=False, + ) + assert stub_characterise['sweep'] == [[4.0]] + + +def test_sweep_skipped_when_all_reused(project, monkeypatch, stub_characterise): + monkeypatch.setattr(characterise, 'read_results', lambda proj: {'ptc': {'fits': [{'gain': 1.0, 'reliable': True}]}}) + monkeypatch.setattr(characterise, 'quick_scan', lambda proj: {'groups': [], 'has_results': True, 'stale': False}) + stream = run( + project, + FakeBox(), + ScriptedMeter(), + FakeCam(), + gains=[1.0], + lamp='D65', + include_darks=False, + include_flats=False, + ) + assert stub_characterise['sweep'] == [] and stub_characterise['analyse'] == 0 # nothing to do + assert stream[-1]['ok'] is True + + +# --- control ------------------------------------------------------------------ +def test_cancel_turns_box_off_and_releases(project, stub_characterise): + box = FakeBox() + gen = auto_characterise.run_auto_characterise_stream( + project, FakeCam(), box, ScriptedMeter(), [1.0], 'D65', include_darks=False, include_flats=False, cfg=FAST + ) + stream = [] + for event in gen: + stream.append(event) + if event['event'] == 'plan': + auto_characterise.request_cancel() + assert stream[-1]['event'] == 'done' and stream[-1]['cancelled'] is True + assert ('off',) in box.calls and not auto_characterise.is_running() + + +def test_camera_failure_is_fatal(project, stub_characterise): + box = FakeBox() + stream = _run_with_lenscap( + auto_characterise.run_auto_characterise_stream( + project, + FakeCam(fail=True), + box, + ScriptedMeter(), + [1.0], + 'D65', + include_sweep=False, + include_darks=False, + cfg=FAST, + ) + ) + assert stream[-1]['event'] == 'error' and 'camera failure' in stream[-1]['error'] + assert not auto_characterise.is_running() and ('off',) in box.calls + + +def test_second_cycle_rejected_while_running(project, stub_characterise): + gen = auto_characterise.run_auto_characterise_stream( + project, FakeCam(), FakeBox(), ScriptedMeter(), [1.0], 'D65', include_darks=False, include_flats=False, cfg=FAST + ) + next(gen) # holds the lock + second = run(project, FakeBox(), ScriptedMeter(), FakeCam(), gains=[1.0], lamp='D65') + assert second[0]['event'] == 'error' and 'already running' in second[0]['error'] + gen.close() + + +def test_continue_and_cancel_when_idle(stub_characterise): + assert auto_characterise.request_continue() is False + assert auto_characterise.request_cancel() is False + + +# --- routes ------------------------------------------------------------------- +@pytest.fixture +def client(tmp_path, monkeypatch): + sessions.Workspace(tmp_path).create_project('cam') + monkeypatch.setattr(app_module, 'get_shared_camera', lambda: FakeCam()) + monkeypatch.setattr(app_module, 'get_shared_lightbox', lambda: FakeBox()) + monkeypatch.setattr(app_module, 'get_shared_lightmeter', lambda: ScriptedMeter()) + monkeypatch.setattr( + app_module.auto_characterise, + 'run_auto_characterise_stream', + lambda *a, **kw: iter([{'event': 'start'}, {'event': 'done', 'ok': True}]), + ) + return app_module.create_app(tmp_path).test_client() + + +def sse_events(response): + return [json.loads(line[len('data: ') :]) for line in response.get_data(as_text=True).splitlines() if line] + + +def test_stream_route_emits_sse(client): + r = client.get('/projects/cam/auto-characterise/stream?gains=1,4&lamp=D65&intensity=80') + assert r.status_code == 200 and r.mimetype == 'text/event-stream' + stream = sse_events(r) + assert stream[0]['event'] == 'start' and stream[-1]['event'] == 'done' + + +def test_stream_route_requires_lamp(client): + stream = sse_events(client.get('/projects/cam/auto-characterise/stream?gains=1')) + assert stream[0]['event'] == 'error' and 'lamp' in stream[0]['error'].lower() + + +def test_stream_route_reports_missing_devices(client, monkeypatch): + monkeypatch.setattr(app_module, 'get_shared_lightmeter', lambda: (_ for _ in ()).throw(LightmeterError('no meter'))) + stream = sse_events(client.get('/projects/cam/auto-characterise/stream?gains=1&lamp=D65')) + assert stream[0]['event'] == 'error' and 'meter' in stream[0]['error'].lower() + + +def test_continue_cancel_routes_409_when_idle(client): + assert client.post('/projects/cam/auto-characterise/continue').status_code == 409 + assert client.post('/projects/cam/auto-characterise/cancel').status_code == 409 diff --git a/tests/test_ctt_server_characterise.py b/tests/test_ctt_server_characterise.py new file mode 100644 index 0000000..6eda499 --- /dev/null +++ b/tests/test_ctt_server_characterise.py @@ -0,0 +1,222 @@ +# SPDX-License-Identifier: BSD-2-Clause +# +# Copyright (C) 2026, Raspberry Pi +# +# Tests for the characterisation routes and orchestrator: SSE analysis stream, +# results.json persistence, staleness and locking. DNG loading is stubbed with +# synthetic FrameSets, so these run without real captures. + +import json +from pathlib import Path + +import numpy as np +import pytest + +from ctt.characterisation import FrameSet +from ctt.characterisation.discover import CaptureGroup +from ctt_server import characterise, sessions + +BLACK_16 = 3840.0 + + +def _synth_frameset(n_frames, signal_e, dn_per_e=1.5, read_noise_dn=15.0, exposure_us=10_000, gain=1.0, seed=0): + rng = np.random.default_rng(seed) + frames = [ + np.clip( + BLACK_16 + dn_per_e * rng.poisson(signal_e, (48, 48)) + rng.normal(0, read_noise_dn, (48, 48)), 0, 65535 + ) + for _ in range(n_frames) + ] + return FrameSet(frames=frames, exposure_us=exposure_us, gain=gain, blacklevel_16=BLACK_16, sigbits=16, channel='Gr') + + +def _group(project_dir: Path, label, kind, n, exposure_us, gain=1.0, colour_temp=None): + paths = [] + for i in range(n): + p = project_dir / f'{label}_{i}.dng' + p.write_bytes(b'DNG') + paths.append(p) + return CaptureGroup( + label=label, + kind=kind, + paths=paths, + exposure_us=exposure_us, + gain=gain, + width=1920, + height=1080, + sigbits=16, + blacklevel=3840, + colour_temp=colour_temp, + ) + + +@pytest.fixture +def project(tmp_path): + ws = sessions.Workspace(tmp_path) + proj = ws.create_project('imx662') + proj.path.mkdir(parents=True, exist_ok=True) + return proj + + +@pytest.fixture +def client(tmp_path): + from ctt_server.app import create_app + + return create_app(str(tmp_path)).test_client() + + +@pytest.fixture +def stubbed_pipeline(project, monkeypatch): + """Stub discovery + DNG loading with synthetic groups and framesets.""" + groups = [ + _group(project.path, 'dark', 'dark', 8, 33333), + _group(project.path, 'alsc_3500k', 'flat', 8, 133, gain=1.07, colour_temp=3500), + _group(project.path, 'alsc_6000k', 'flat', 5, 250, gain=1.07, colour_temp=6000), + ] + monkeypatch.setattr(characterise, 'scan_project', lambda directory, excluded=frozenset(): groups) + + def fake_dark_analysis(group, roi_fraction): + fs = _synth_frameset(len(group.paths), 0.0, exposure_us=group.exposure_us, seed=1) + return { + 'available': True, + 'label': group.label, + 'pedestal': {'black_level_16': 3843.0, 'metadata_black_level_16': BLACK_16, 'frames': []}, + 'read_noise_dn': 15.2, + 'read_noise_e': None, + 'dsnu_dn': 2.1, + 'dsnu_e': None, + 'exposure_us': group.exposure_us, + 'gain': group.gain, + 'n_frames': len(group.paths), + 'channel': 'Gr', + }, fs + + monkeypatch.setattr(characterise, '_dark_analysis', fake_dark_analysis) + + def fake_frameset(paths, roi_fraction=0.5): + signal = 4000.0 if '3500' in paths[0] else 9000.0 + return _synth_frameset(len(paths), signal, gain=1.07, exposure_us=133, seed=len(paths)) + + monkeypatch.setattr(characterise, 'frameset_from_dngs', fake_frameset) + return groups + + +def _stream_lines(client, name='imx662'): + r = client.get(f'/projects/{name}/characterisation/analyse/stream') + assert r.status_code == 200 + return [json.loads(line[6:]) for line in r.data.decode().splitlines() if line.startswith('data: ')] + + +def test_data_endpoint_empty_project(client, project, monkeypatch): + monkeypatch.setattr(characterise, 'scan_project', lambda directory, excluded=frozenset(): []) + d = client.get('/projects/imx662/characterisation/data').get_json() + assert d['results'] is None and d['groups'] == [] and d['has_results'] is False and d['stale'] is False + + +def test_analyse_stream_persists_results(client, project, stubbed_pipeline): + lines = _stream_lines(client) + assert lines[-1] == 'CHAR_EXIT 0' + assert any('Found 3 capture groups' in ln for ln in lines) + assert any('black level 3843' in ln for ln in lines) + + results = json.loads(characterise.results_path(project).read_text()) + assert results['version'] == 1 + assert results['dark']['available'] and results['dark']['read_noise_dn'] == 15.2 + # Two flat points at one gain, non-clipped: an (unreliable) 2-point fit. + assert len(results['ptc']['points']) == 2 + assert len(results['ptc']['fits']) == 1 and not results['ptc']['fits'][0]['reliable'] + assert 'run the live sweep' in results['ptc']['unavailable_reason'] + # e- values stay null without a reliable conversion gain. + assert results['dark']['read_noise_e'] is None + assert results['prnu']['available'] and len(results['prnu']['groups']) == 2 + + +def test_data_reports_staleness_after_new_capture(client, project, stubbed_pipeline): + _stream_lines(client) + d = client.get('/projects/imx662/characterisation/data').get_json() + assert d['has_results'] and d['stale'] is False + + # A new/changed capture invalidates the recorded input fingerprint. + characterise._scan_cache.clear() + import os + + dng = project.path / 'dark_0.dng' + os.utime(dng, (dng.stat().st_atime, dng.stat().st_mtime + 10)) + d = client.get('/projects/imx662/characterisation/data').get_json() + assert d['stale'] is True + + +def test_analyse_refuses_while_calibration_runs(client, project, monkeypatch): + from ctt_server import ctt_runner + + monkeypatch.setattr(ctt_runner, 'is_running', lambda: True) + lines = _stream_lines(client) + assert lines == ['ERROR: a calibration is running; wait for it to finish', 'CHAR_EXIT 2'] + + +def test_analyse_refuses_concurrent_analysis(client, project): + assert characterise._char_lock.acquire(blocking=False) + try: + lines = _stream_lines(client) + assert lines == ['ERROR: a characterisation analysis is already running', 'CHAR_EXIT 2'] + finally: + characterise._char_lock.release() + + +def test_analyse_stream_no_captures(client, project, monkeypatch): + monkeypatch.setattr(characterise, 'scan_project', lambda directory, excluded=frozenset(): []) + lines = _stream_lines(client) + assert lines[-1] == 'CHAR_EXIT 1' + assert any('no usable captures' in ln for ln in lines) + + +def test_page_renders(client, project): + r = client.get('/projects/imx662/characterisation') + assert r.status_code == 200 + assert b'characterisationApp' in r.data + + +def test_carry_live_sweep_preserves_sweep_metrics(): + # A fresh offline analysis must not drop a prior live sweep's derived metrics. + prev = { + 'ptc': { + 'points': [ + { + 'mean_dn': 100.0, + 'var_dn2': 50.0, + 'exposure_us': 100, + 'gain': 1.0, + 'n_frames': 8, + 'clipped': False, + 'label': 's', + 'source': 'live', + }, + { + 'mean_dn': 5000.0, + 'var_dn2': 2500.0, + 'exposure_us': 5000, + 'gain': 1.0, + 'n_frames': 8, + 'clipped': False, + 'label': 's', + 'source': 'live', + }, + ], + 'fits': [{'gain': 1.0, 'reliable': True}], + }, + 'linearity': {'available': True, 'r2': 0.999}, + 'full_well': {'available': True, 'full_well_e': 12000}, + 'dynamic_range': {'available': True, 'db': 60.0}, + } + results = {'ptc': {'points': [], 'fits': [], 'unavailable_reason': 'needs a sweep'}, 'dark': {'available': False}} + characterise._carry_live_sweep(results, prev) + assert results['linearity'] == prev['linearity'] + assert results['full_well'] == prev['full_well'] + assert results['dynamic_range'] == prev['dynamic_range'] + assert [p['source'] for p in results['ptc']['points']] == ['live', 'live'] # live points re-added + + +def test_carry_live_sweep_noop_without_previous(): + results = {'ptc': {'points': [], 'fits': []}, 'dark': {'available': False}} + characterise._carry_live_sweep(results, None) + assert results['ptc']['points'] == [] and 'linearity' not in results diff --git a/tests/test_ctt_server_lightmeter.py b/tests/test_ctt_server_lightmeter.py index b65c6c8..f6b97d2 100644 --- a/tests/test_ctt_server_lightmeter.py +++ b/tests/test_ctt_server_lightmeter.py @@ -10,31 +10,41 @@ from ctt_server import app as app_module from ctt_server.sessions import Workspace from devices import LightmeterError, Measurement -from devices.lightmeter import Spectrum +from devices.lightmeter import MeasurementLimits, Spectrum class FakeMeter: """A stand-in light meter recording what the routes ask of it.""" - def __init__(self, fail: bool = False): + LIMITS = MeasurementLimits(1.0, 200_000.0, colour_min_lux=5.0, cct_min=1563.0, cct_max=100_000.0) + + def __init__(self, fail: bool = False, under: bool = False): self.fail = fail + self.under = under # return an out-of-range reading self.calls = [] self._latest = None def info(self): - return {'model': 'FakeMeter', 'serial': 'FM-1'} + return {'model': 'FakeMeter', 'serial': 'FM-1', 'limits': self.LIMITS.to_dict()} + + @property + def limits(self): + return self.LIMITS def measure(self): self.calls.append('measure') if self.fail: raise LightmeterError('measurement failed') - self._latest = Measurement( - illuminance_lux=234.5, - cct=6543.0, - duv=0.0021, - spectrum_5nm=Spectrum(start_nm=380.0, step_nm=5.0, values=(0.5,) * 81), - spectrum_1nm=Spectrum(start_nm=380.0, step_nm=1.0, values=(0.5,) * 401), - ) + if self.under: + self._latest = Measurement(illuminance_lux=-100.0, cct=0.0, in_range=False) + else: + self._latest = Measurement( + illuminance_lux=234.5, + cct=6543.0, + duv=0.0021, + spectrum_5nm=Spectrum(start_nm=380.0, step_nm=5.0, values=(0.5,) * 81), + spectrum_1nm=Spectrum(start_nm=380.0, step_nm=1.0, values=(0.5,) * 401), + ) return self._latest def read_latest(self): @@ -73,6 +83,13 @@ def _raise(): assert r.get_json() == {'present': False} +def test_status_includes_limits(client, monkeypatch): + monkeypatch.setattr(app_module, 'get_shared_lightmeter', lambda: FakeMeter()) + data = client.get('/api/lightmeter').get_json() + assert data['limits']['colour_min_lux'] == 5.0 + assert data['limits']['illuminance_min'] == 1.0 + + def test_post_sample_returns_reading(client, monkeypatch): meter = FakeMeter() monkeypatch.setattr(app_module, 'get_shared_lightmeter', lambda: meter) @@ -81,6 +98,18 @@ def test_post_sample_returns_reading(client, monkeypatch): assert r.status_code == 200 assert 'measure' in meter.calls assert data['reading']['illuminance_lux'] == 234.5 + assert data['reading']['in_range'] is True + assert data['limits']['colour_min_lux'] == 5.0 + + +def test_post_sample_caps_out_of_range(client, monkeypatch): + # The API must never emit the device sentinels: illuminance is clamped to the + # limit and the (invalid) colour metrics are dropped. + monkeypatch.setattr(app_module, 'get_shared_lightmeter', lambda: FakeMeter(under=True)) + reading = client.post('/api/lightmeter', json={'action': 'sample'}).get_json()['reading'] + assert reading['in_range'] is False + assert reading['illuminance_lux'] == 1.0 # clamped to the min, not -100 + assert 'cct' not in reading and 'cri_ra' not in reading # colour dropped def test_post_unknown_action_is_400(client, monkeypatch): @@ -147,3 +176,11 @@ def test_capture_survives_a_meter_failure(capture_client, monkeypatch): r = capture_client.post('/projects/cam/capture', json={'image_type': 'macbeth', 'colour_temp': 5000, 'lux': 800}) assert r.status_code == 200 assert r.get_json()['added'][0]['lightmeter'] is None + + +def test_capture_ignores_out_of_range_reading(capture_client, monkeypatch): + # An under-range reading must not be recorded as if it were a real measurement. + monkeypatch.setattr(app_module, 'get_shared_lightmeter', lambda: FakeMeter(under=True)) + r = capture_client.post('/projects/cam/capture', json={'image_type': 'macbeth', 'colour_temp': 5000, 'lux': 800}) + assert r.status_code == 200 + assert r.get_json()['added'][0]['lightmeter'] is None diff --git a/tests/test_devices_cl70f.py b/tests/test_devices_cl70f.py index 27477bd..bf4269b 100644 --- a/tests/test_devices_cl70f.py +++ b/tests/test_devices_cl70f.py @@ -200,9 +200,39 @@ def test_measure_runs_the_full_sequence(): reading = meter.measure() assert reading.illuminance_lux == pytest.approx(321.0) assert reading.cct == pytest.approx(4200.0) + assert reading.in_range is True # 321 lx is within the rated range assert transport.sent == [b'RT1', b'RM0', b'ST', b'ST', b'NR'] +def _measure_once(nr_red): + """Drive one full measure() cycle returning the given NR payload.""" + transport = FakeTransport( + [ + ACK_REPLY, + _data_reply('RT', sta1=REMOTE), + ACK_REPLY, + _data_reply('RM', sta1=BUSY), + ACK_REPLY, + _data_reply('ST', sta1=REMOTE), + ACK_REPLY, + _data_reply('NR', sta1=REMOTE, red=nr_red), + ] + ) + return CL70F(transport).measure() + + +def test_measure_flags_out_of_range_reading(): + # Below the rated 1 lx floor (the meter's under-range sentinel) → in_range False. + reading = _measure_once(_make_nr_red(illuminance_lx=-100.0, colour_temperature=0.0)) + assert reading.in_range is False + + +def test_limits_reported(): + limits = CL70F(FakeTransport([])).limits + assert limits.illuminance_min == 1.0 and limits.illuminance_max == 200000.0 + assert limits.colour_min_lux == 5.0 + + def test_measure_raises_on_error_state(): transport = FakeTransport( [ diff --git a/tests/test_devices_lightmeter.py b/tests/test_devices_lightmeter.py index 160eac2..960fb96 100644 --- a/tests/test_devices_lightmeter.py +++ b/tests/test_devices_lightmeter.py @@ -61,7 +61,7 @@ def test_measurement_carries_rich_fields(): def test_info_snapshot(): - assert FakeMeter().info() == {'model': 'FakeMeter', 'serial': 'FM-1'} + assert FakeMeter().info() == {'model': 'FakeMeter', 'serial': 'FM-1', 'limits': None} def test_default_read_latest_is_none():