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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 163 additions & 4 deletions apps/ctt_server/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 = []
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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 <project>/characterisation/,
# which calibration runs never scan — the same isolation as <project>/mtf/.

@app.route('/projects/<name>/characterisation')
def characterisation_page(name: str):
proj = get_project_or_404(name)
return render_template('characterisation.html', project=proj)

@app.route('/projects/<name>/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/<name>/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/<name>/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/<name>/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/<name>/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/<name>/results/data')
def results_data(name: str):
proj = get_project_or_404(name)
Expand Down
26 changes: 25 additions & 1 deletion apps/ctt_server/auto_capture.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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')
Expand Down
Loading
Loading