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
8 changes: 4 additions & 4 deletions apps/ctt_server/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -476,15 +476,15 @@ def api_preview_default():

@app.route('/projects/<name>/preview-capture')
def preview_capture(name: str):
"""Download a PNG snapshot of the live preview (zero shutter lag).
"""Download a PNG snapshot of the live preview at the selected mode's resolution.

Grabs the frame currently on screen at the selected mode's preview
resolution, so manual exposure/gain is preserved (no mode-switch blip).
Captured at the preview's exposure, gain and white balance, which are left
unchanged on the live preview afterwards.
"""
proj = get_project_or_404(name)
cam = camera_or_503()
try:
png = cam.capture_preview_png()
png = cam.capture_png()
except CameraError as err:
abort(503, str(err))
stamp = datetime.now().strftime('%Y%m%d_%H%M%S')
Expand Down
64 changes: 39 additions & 25 deletions apps/ctt_server/camera.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ def __init__(self, preview_max_width: int = 1920, tuning_file: str | None = None
self._ev = 0.0 # exposure compensation (EV); tracked here as metadata may omit it
self._auto = True # AeEnable state; tracked so we report it reliably (AeLocked is ambiguous)
self._fps = 30.0 # framerate target; 0 = unconstrained (variable frame duration)
# Manual exposure/gain and white balance as last set, re-applied after a still
# capture: reconfiguring the pipeline resets controls to the config's defaults.
self._manual_exposure: dict = {}
self._awb_controls: dict = {}
# Optionally start the pipeline with a specific tuning file (e.g. a freshly
# generated CTT tuning, for the Results-page live preview test). None = the
# camera's built-in default tuning.
Expand Down Expand Up @@ -232,13 +236,15 @@ def set_controls(self, controls: dict) -> dict:
self._auto = bool(controls['auto_exposure'])
if self._auto:
new['AeEnable'] = True
self._manual_exposure = {}
else:
# Manual: disable AEC and apply the requested exposure/gain.
new['AeEnable'] = False
if controls.get('exposure') is not None:
new['ExposureTime'] = int(controls['exposure'])
if controls.get('gain') is not None:
new['AnalogueGain'] = float(controls['gain'])
self._manual_exposure.update({k: new[k] for k in ('ExposureTime', 'AnalogueGain') if k in new})
if 'ev' in controls and controls['ev'] is not None:
self._ev = float(controls['ev'])
new['ExposureValue'] = self._ev # AEC bias; only affects auto-exposure
Expand All @@ -247,11 +253,13 @@ def set_controls(self, controls: dict) -> dict:
new['FrameDurationLimits'] = self._frame_duration_limits()
if 'awb' in controls:
new['AwbEnable'] = bool(controls['awb'])
self._awb_controls = {} if new['AwbEnable'] else {'AwbEnable': False}
if controls.get('colour_gains') is not None:
# Explicit gains imply manual white balance.
r_gain, b_gain = controls['colour_gains']
new['AwbEnable'] = False
new['ColourGains'] = (float(r_gain), float(b_gain))
self._awb_controls = {'AwbEnable': False, 'ColourGains': new['ColourGains']}
if new:
self._picam2.set_controls(new)
time.sleep(0.3) # let the pipeline apply the new controls
Expand All @@ -269,46 +277,52 @@ def capture_jpeg(self, quality: int = 95) -> bytes:
return buf.tobytes()

def capture_png(self) -> bytes:
"""Capture a full-resolution processed still (current tuning applied) as PNG.

Briefly switches the pipeline to a full-sensor-resolution still mode, then
back to the preview/video config — so the result is the full field of view
at native resolution, not the downscaled preview stream.
"""Snapshot the live preview as a PNG at the selected sensor mode's resolution.

The live main stream is only preview-sized, so the frame comes from a brief
switch to a still configuration at the mode's resolution. That still is held
to the preview frame's exposure, gain and colour gains, with the preview's ISP
controls (e.g. denoise), so it matches what is on screen. Switching back
resets the controls to the config's defaults, so the user's exposure and white
balance settings are re-applied and the live preview carries on unchanged.
"""
import cv2 # noqa: PLC0415

with self._lock:
md = self._picam2.capture_metadata()
video = self._video_config()
controls = dict(video.get('controls', {}))
controls.update(
AeEnable=False,
ExposureTime=int(md['ExposureTime']),
AnalogueGain=float(md['AnalogueGain']),
)
if md.get('ColourGains'):
controls.update(AwbEnable=False, ColourGains=tuple(float(g) for g in md['ColourGains']))
still = self._picam2.create_still_configuration(
main={'size': self.resolution, 'format': 'RGB888'},
sensor=self._sensor_config(),
transform=self._transform(),
controls=controls,
)
arr = self._picam2.switch_mode_and_capture_array(still, 'main')
try:
arr = self._picam2.switch_mode_and_capture_array(still, 'main')
finally:
self._picam2.set_controls(
{
'AeEnable': self._auto,
'AwbEnable': True,
'ExposureValue': self._ev,
**self._manual_exposure,
**self._awb_controls,
}
)
# Picamera2 'RGB888' arrays are BGR-ordered, which is exactly what cv2 wants.
ok, buf = cv2.imencode('.png', arr)
if not ok:
raise CameraError('Failed to encode PNG')
return buf.tobytes()

def capture_preview_png(self) -> bytes:
"""Snapshot the current live preview frame as a PNG (zero shutter lag).

Grabs the running main stream straight from the pipeline — no mode switch —
so the snapshot is exactly the frame on screen, at the selected mode's preview
resolution, with the manual exposure/gain (and the ISP denoise state) left
untouched. Switching to a full-resolution still mode (capture_png) reverts
auto-exposure and steps the live preview's brightness, which is why the
on-screen snapshot avoids it.
"""
import cv2 # noqa: PLC0415

with self._lock:
arr = self._picam2.capture_array('main') # RGB888 == BGR-ordered == cv2 native
ok, buf = cv2.imencode('.png', arr)
if not ok:
raise CameraError('Failed to encode PNG')
return buf.tobytes()

def capture_still_frames(self, frames: int) -> list:
"""Capture a burst of full-resolution processed frames as arrays.

Expand Down
2 changes: 1 addition & 1 deletion apps/ctt_server/templates/preview.html
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ <h2>Live preview test</h2>
<span x-text="busy ? 'Starting…' : '▶ Start live preview'"></span>
</button>
<button class="btn" x-show="testing" @click="capturePng()" :disabled="busy">
<span x-text="busy ? 'Capturing…' : '⬇ Capture PNG (full res)'"></span>
<span x-text="busy ? 'Capturing…' : '⬇ Capture PNG'"></span>
</button>
<button class="btn ghost" x-show="testing" @click="restoreDefault()" :disabled="busy">■ Stop (restore default)</button>
</div>
Expand Down
50 changes: 38 additions & 12 deletions ctt/algorithms/alsc.py
Original file line number Diff line number Diff line change
Expand Up @@ -259,21 +259,22 @@ def alsc(
"""Calculate g/r and g/b for grid points for a single image."""
cam.log += f'\nProcessing image: {img.name}'
grid_w, grid_h = grid_size
# Get channels in correct order.
channels = [img.channels[i] for i in img.order]
# Calculate size of single rectangle; divisions ensure final row/column of cells has non-zero pixels.
w, h = img.w / 2, img.h / 2
dx, dy = int((w - 1) // (grid_w - 1)), int((h - 1) // (grid_h - 1))
w, h, dx, dy = alsc_cell_size(img, grid_size)
if img.alsc_grids is not None:
try:
g_grid, r_grid, b_grid = img.alsc_grids[tuple(grid_size)]
except KeyError:
raise ValueError(f'{img.name}: ALSC cell means were not computed for a {grid_size} grid') from None
else:
g_grid, r_grid, b_grid = alsc_cell_means(img, grid_size)

# Average the green channels into one.
av_ch_g = np.mean((channels[1:3]), axis=0)
if do_alsc_colour:
# Obtain grid_w x grid_h grid of intensities for each channel and subtract black level.
# Subtract black level from the grid_w x grid_h cell intensities of each channel.
# Floor at 1 so a dark/vignetted cell at or below black level can't make a ratio
# divide by zero (inf/nan) or go negative (which would flip the min-normalisation).
g = np.maximum(get_grid(av_ch_g, dx, dy, grid_size) - img.blacklevel_16, 1)
r = np.maximum(get_grid(channels[0], dx, dy, grid_size) - img.blacklevel_16, 1)
b = np.maximum(get_grid(channels[3], dx, dy, grid_size) - img.blacklevel_16, 1)
g = np.maximum(g_grid - img.blacklevel_16, 1)
r = np.maximum(r_grid - img.blacklevel_16, 1)
b = np.maximum(b_grid - img.blacklevel_16, 1)
# Calculate ratios as 32-bit for medianBlur; then median blur to remove peaks.
cr = np.reshape(g / r, (grid_h, grid_w)).astype('float32')
cb = np.reshape(g / b, (grid_h, grid_w)).astype('float32')
Expand All @@ -290,7 +291,7 @@ def alsc(

else:
# Only perform calculations for luminance shading. Floor at 1 (see colour branch).
g = np.maximum(get_grid(av_ch_g, dx, dy, grid_size) - img.blacklevel_16, 1)
g = np.maximum(g_grid - img.blacklevel_16, 1)
cg = np.reshape(1 / g, (grid_h, grid_w)).astype('float32')
cg = cv2.medianBlur(cg, 3).astype('float64')
cg = cg / np.min(cg)
Expand All @@ -299,6 +300,31 @@ def alsc(
return img.col, None, None, cg_clamp, (w, h, dx, dy)


def alsc_cell_size(img: Image, grid_size: tuple[int, int]) -> tuple:
"""Channel size and grid cell size; divisions ensure the final row/column of cells has pixels."""
grid_w, grid_h = grid_size
w, h = img.w / 2, img.h / 2
return w, h, int((w - 1) // (grid_w - 1)), int((h - 1) // (grid_h - 1))


def alsc_cell_means(img: Image, grid_size: tuple[int, int]) -> tuple:
"""Green (Gr/Gb averaged), red and blue cell means of an ALSC image, before black level.

These are all that the ALSC calibration reads from an image's channels, so the
loader can keep them in place of the full-resolution channels (Image.alsc_grids).
"""
_, _, dx, dy = alsc_cell_size(img, grid_size)
# Get channels in correct order.
channels = [img.channels[i] for i in img.order]
# Average the green channels into one.
av_ch_g = np.mean((channels[1:3]), axis=0)
return (
get_grid(av_ch_g, dx, dy, grid_size),
get_grid(channels[0], dx, dy, grid_size),
get_grid(channels[3], dx, dy, grid_size),
)


def get_grid(chan: np.ndarray, dx: int, dy: int, grid_size: tuple[int, int]) -> np.ndarray:
"""Compress channel down to a grid of the requested size."""
grid_w, grid_h = grid_size
Expand Down
18 changes: 16 additions & 2 deletions ctt/algorithms/black_level.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,15 +35,29 @@
_METADATA_DELTA_LIMIT = 0.01 * (2**16)


def reduce_dark_image(img: Image, drop_channels: bool = True) -> None:
"""Keep the per-channel mean and std of a dark frame, all that is measured from it.

With drop_channels the full-resolution channels are then released, so a run can
hold its dark frames without holding their pixels.
"""
img.channel_means = [float(np.mean(ch)) for ch in img.channels]
img.channel_stds = [float(np.std(ch)) for ch in img.channels]
if drop_channels:
img.channels = []


def measure_dark_image(img: Image) -> dict:
"""Per-channel black level means of a loaded dark frame, 16-bit scaled.

The spatial channels are reordered via img.order into (R, Gr, Gb, B) - the
mapping established in image_loader.dng_load_image. Mono sensors (pattern
128) report a single 'y' value instead of 'r'/'g'/'b'.
"""
means = [float(np.mean(img.channels[i])) for i in img.order]
stds = [float(np.std(img.channels[i])) for i in img.order]
if img.channel_means is None:
reduce_dark_image(img, drop_channels=False)
means = [img.channel_means[i] for i in img.order]
stds = [img.channel_stds[i] for i in img.order]
out = {
'name': img.name,
'black_level': float(np.mean(means)),
Expand Down
2 changes: 1 addition & 1 deletion ctt/algorithms/lux.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ def run(self) -> dict | None:
# in the metrics so the Results page can plot it.
samples = []
for img in cam.imgs:
y = lux_calc(cam, img, [img.patches[i] for i in img.order], [img.channels[i] for i in img.order])
y = lux_calc(cam, img, [img.patches[i] for i in img.order], [img.channel_values(i) for i in img.order])
slope = y / (img.lux * img.exposure * img.againQ8_norm)
samples.append({'name': img.name, 'ct': int(img.col), 'lux': int(img.lux), 'y': y, 'slope': slope})

Expand Down
21 changes: 20 additions & 1 deletion ctt/core/camera.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
import time
from pathlib import Path

from ..algorithms.alsc import alsc_cell_means
from ..algorithms.black_level import reduce_dark_image
from ..output.json_formatter import pretty_print
from ..utils.tools import get_photos
from .image_loader import load_image, load_image_group
Expand Down Expand Up @@ -124,8 +126,20 @@ def write_log(self, filename: str | None) -> None:
logfile.write(str(self.log))

def add_imgs(
self, directory: str, mac_config: tuple, blacklevel: int = -1, images: list[str] | None = None
self,
directory: str,
mac_config: tuple,
blacklevel: int = -1,
images: list[str] | None = None,
alsc_grid_size: tuple[int, int] | None = None,
) -> None:
"""Load and classify the calibration images in directory.

Frames are reduced at load to what their calibrations read, as a run cannot
hold every full-resolution frame of a high-resolution sensor: dark frames keep
their channel statistics, Macbeth bursts their exact sums (load_image_group),
and, when alsc_grid_size is given, ALSC frames their cell means on that grid.
"""
self.log_new_sec('Image Loading', cal=False)
logger.info(f'\nLoading images from {directory}')
self.log += f'\nDirectory: {directory}'
Expand Down Expand Up @@ -164,6 +178,10 @@ def add_imgs(
if col is not None:
img.col = col
img.name = filename
if alsc_grid_size is not None:
grid = tuple(alsc_grid_size)
img.alsc_grids = {grid: alsc_cell_means(img, grid)}
img.channels = []
self.log += f'\nColour temperature: {col} K'
self.imgs_alsc.append(img)
if blacklevel != -1:
Expand All @@ -189,6 +207,7 @@ def add_imgs(
# Dark frames need no chart detection or demosaic; only the raw
# channel statistics are consumed (black level measurement).
img = load_image(self, address, mac=False, demosaic=False)
reduce_dark_image(img)
self.log += '\nIdentified as a dark frame'
img.name = filename
self.imgs_dark.append(img)
Expand Down
12 changes: 12 additions & 0 deletions ctt/core/image.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,20 @@ class Image:
patch_size: int | None = None
frames_averaged: int = 1 # burst frames averaged into this image (in-CTT, by filename group)
patches_single: list | None = None # patches from one burst frame (true noise statistics)
# Reductions held in place of the full-resolution channels once those are dropped
# at load (a frame's channels are ~100 MB at 50 MP, so a run cannot keep them all).
channel_sums: list | None = None # exact integer burst sums; see channel_values()
channel_means: list | None = None # per-channel mean, storage order (dark frames)
channel_stds: list | None = None # per-channel std, storage order (dark frames)
alsc_grids: dict | None = None # grid_size -> (g, r, b) cell means, before black level
ver: int = 0

def channel_values(self, i: int) -> np.ndarray:
"""Channel i in storage order, rebuilding a burst average from its exact sums."""
if self.channel_sums is not None:
return self.channel_sums[i] / self.frames_averaged
return self.channels[i]

def get_patches(self, cen_coords: list, size: int | None = None) -> int:
cen_coords = list(np.array(cen_coords[0]).astype(np.int32))
self.cen_coords = cen_coords
Expand Down
17 changes: 12 additions & 5 deletions ctt/core/image_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -251,20 +251,27 @@ def load_image_group(
return load_image(cam, im_strs[0], mac_config, demosaic=demosaic)

# Average the frames one at a time: holding a whole burst in memory at once
# is a sizeable chunk of a Pi's RAM. float64 sums of uint16 data are exact,
# so this matches np.mean over the stacked frames bit for bit.
# is a sizeable chunk of a Pi's RAM. The uint16 frames are summed exactly in
# uint32 (enough for 65537 frames), and float64 division of the exact sums
# matches np.mean over the stacked frames bit for bit.
base = dng_load_image(cam, im_strs[0], demosaic=demosaic)
single_channels = base.channels
sums = [ch.astype(np.float64) for ch in single_channels]
sums = [ch.astype(np.uint32) for ch in single_channels]
for im_str in im_strs[1:]:
img = dng_load_image(cam, im_str, demosaic=False)
for i, ch in enumerate(img.channels):
sums[i] += ch
base.channels = [s / len(im_strs) for s in sums]
del img
base.channel_sums = sums
base.frames_averaged = len(im_strs)
base.channels = [base.channel_values(i) for i in range(len(sums))]
cam.log += f'\nAveraged {len(im_strs)} burst frames'

if not _detect_macbeth(cam, base, mac_config, base.name):
detected = _detect_macbeth(cam, base, mac_config, base.name)
# Chart detection and patch sampling were the last full-resolution uses of the
# float64 average; later consumers rebuild it from the sums via channel_values().
base.channels = []
if not detected:
return None

# Patches of one un-averaged frame, sampled at the same chart coordinates
Expand Down
2 changes: 1 addition & 1 deletion ctt/core/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ def _mode_label() -> str:
cam = Camera(json_output, json=json_template)
cam.output_dir = output_dir
cam.log_user_input(json_output, directory, config, log_output)
cam.add_imgs(directory, mac_config, blacklevel, images=images)
cam.add_imgs(directory, mac_config, blacklevel, images=images, alsc_grid_size=grid_size)
# Infer ALSC-only when only ALSC images present (e.g. mono LSC-only from DNGs),
# and black-level-only when the directory holds nothing but dark frames.
if len(cam.imgs) == 0 and len(cam.imgs_cac) == 0 and len(cam.imgs_alsc) > 0:
Expand Down
6 changes: 5 additions & 1 deletion ctt/detection/macbeth.py
Original file line number Diff line number Diff line change
Expand Up @@ -413,4 +413,8 @@ def get_macbeth_chart(img: np.ndarray, ref_data: tuple) -> tuple:
return (max_cor, best_map_col_norm, fit_coords, success_msg)

except MacbethError as error:
return (0, None, None, error)
# Return the text, not the exception: its traceback references this frame and,
# through it, every caller up to the run itself. The caller keeps the message in
# a local, which would close that into a reference cycle and pin the (large)
# image arrays in those frames until the cyclic garbage collector happens to run.
return (0, None, None, str(error))
Loading
Loading