Files
Microscope-Display/photos.py
Microscope Camera 34cd247dca Microscope camera kiosk
Touchscreen kiosk for the Raspberry Pi HQ camera (IMX477) used as a
microscope: live preview from boot, raw frame recording, full-resolution
stills, and automatic capture-SSD mounting.

- app.py: GTK3 fullscreen UI over cage — preview, REC/STOP, PHOTO,
  90-degree rotate button, tap-to-open exposure/gain/settings sliders,
  and an "SSD NOT MOUNTED" badge
- camera.py: picamera2 wrapper with separate video (ISP preview + packed
  12-bit raw) and still (4056x3040) configurations
- recorder.py: writer thread turning the raw stream into one Bayer frame
  per file plus index.jsonl/session.json, with dropped/missed accounting
- photos.py: still capture to DNG + JPEG, and a background worker that
  renders a viewable 16-bit sRGB TIFF from the 12-bit Bayer frame
- storage.py: SSD mount state and the date/time/format session layout
- tools/unpack_raw.py: desktop-side unpacker/demosaicer
- system/ + install.sh: systemd units, udev rule, mount helper, sudoers

Note: raw frames are written byte-for-byte as the sensor produced them, so
they stay decodeable regardless of how the stills pipeline evolves.
2026-10-31 00:44:46 -04:00

444 lines
16 KiB
Python

"""Full-resolution still capture: DNG + 16-bit TIFF + JPEG.
The IMX477 is a 12-bit sensor and the Pi's ISP only outputs 8-bit processed
images, so a genuinely 16-bit TIFF cannot come from the ISP. Instead the TIFF is
built from the raw Bayer frame through the same chain a raw converter would use:
unpack 12-bit MIPI -> black-level subtract -> white balance
-> colour-correction matrix -> bilinear demosaic -> sRGB gamma -> 16-bit
The result is a normal, viewable 16-bit image rather than linear sensor data.
The untouched sensor data stays in the DNG next to it, so nothing is lost for
quantitative work.
The slow part (demosaic + 74 MB TIFF write) runs on a worker thread so the live
preview resumes as soon as the capture itself is done.
"""
import datetime as _dt
import json
import logging
import os
import queue
import threading
import time
import numpy as np
log = logging.getLogger("photos")
_PATTERN_OFFSETS = {
# pattern -> (row parity, col parity) of the red sample in the 2x2 tile
"RGGB": (0, 0),
"BGGR": (1, 1),
"GRBG": (0, 1),
"GBRG": (1, 0),
}
def pattern_from_format(fmt: str) -> str:
"""'SRGGB12_CSI2P' -> 'RGGB'."""
if not fmt:
return "RGGB"
text = fmt.upper()
if text.startswith("S"):
text = text[1:]
return text[:4] if text[:4] in _PATTERN_OFFSETS else "RGGB"
def unpack_mipi12(packed: np.ndarray, width: int, height: int, stride: int) -> np.ndarray:
"""Unpack MIPI CSI-2 packed 12-bit Bayer into a uint16 (height, width) array.
Two pixels live in three bytes with the *first* pixel in the high bits:
byte0 byte1 byte2
p0[11:4] p0[3:0] p1[11:8] p1[7:0]
so even pixels are ``(b0 << 4) | (b1 >> 4)`` and odd pixels are
``((b1 & 0x0F) << 8) | b2``. Getting this order wrong still yields values in
the 0-4095 range, but it destroys the image: the tell-tale is that the two
green sites of the Bayer tile stop matching each other.
"""
rows = packed.reshape(height, stride)
useful = rows[:, : (width * 3) // 2]
triplets = useful.reshape(height, width // 2, 3).astype(np.uint16)
b0 = triplets[:, :, 0]
b1 = triplets[:, :, 1]
b2 = triplets[:, :, 2]
even = (b0 << 4) | (b1 >> 4)
odd = ((b1 & 0x0F) << 8) | b2
out = np.empty((height, width), dtype=np.uint16)
out[:, 0::2] = even
out[:, 1::2] = odd
return out
def demosaic(raw: np.ndarray, pattern: str = "BGGR") -> np.ndarray:
"""Bilinear demosaic of a Bayer plane into a uint16 (h, w, 3) RGB image.
Quantisation-free for 12-bit input (max 4095) and cheap enough (~1 s at
12 MP) to run on the Pi.
"""
rp, cp = _PATTERN_OFFSETS.get(pattern, (0, 0))
h, w = raw.shape
tiles = raw.reshape(h // 2, 2, w // 2, 2).transpose(0, 2, 1, 3)
red = tiles[..., rp, cp].astype(np.uint32)
blue = tiles[..., 1 - rp, 1 - cp].astype(np.uint32)
green_r = tiles[..., rp, 1 - cp].astype(np.uint32)
green_b = tiles[..., 1 - rp, cp].astype(np.uint32)
def padded(plane):
return np.pad(plane, 1, mode="edge")
def upsample(plane):
p = padded(plane)
center = p[1:-1, 1:-1]
left = p[1:-1, :-2]
right = p[1:-1, 2:]
up = p[:-2, 1:-1]
down = p[2:, 1:-1]
horiz = (center + (right if cp == 0 else left)) // 2
vert = (center + (down if rp == 0 else up)) // 2
col_n = right if cp == 0 else left
row_n = down if rp == 0 else up
if cp == 0 and rp == 0:
corner = p[2:, 2:]
elif cp == 0 and rp == 1:
corner = p[2:, :-2]
elif cp == 1 and rp == 0:
corner = p[:-2, 2:]
else:
corner = p[:-2, :-2]
diag = (center + col_n + row_n + corner) // 4
full = np.empty((h, w), dtype=np.uint16)
full[rp::2, cp::2] = center
full[rp::2, (1 - cp)::2] = horiz
full[(1 - rp)::2, cp::2] = vert
full[(1 - rp)::2, (1 - cp)::2] = diag
return full
red_full = upsample(red)
blue_full = upsample(blue)
g1 = padded(green_r)
g2 = padded(green_b)
g1_left = (g1[1:-1, 1:-1] + g1[1:-1, :-2]) // 2
g2_right = (g2[1:-1, 1:-1] + g2[1:-1, 2:]) // 2
green_full = np.empty((h, w), dtype=np.uint16)
green_full[rp::2, 1 - cp::2] = green_r
green_full[1 - rp::2, cp::2] = green_b
green_full[rp::2, cp::2] = g1_left
green_full[1 - rp::2, 1 - cp::2] = g2_right
rgb = np.empty((h, w, 3), dtype=np.uint16)
rgb[..., 0] = red_full
rgb[..., 1] = green_full
rgb[..., 2] = blue_full
return rgb
def srgb_encode(linear: np.ndarray) -> np.ndarray:
"""Apply the sRGB transfer function (linear light -> display values)."""
out = np.empty_like(linear)
low = linear <= 0.0031308
np.multiply(linear, 12.92, out=out, where=low)
high = ~low
np.power(np.clip(linear, 1e-8, None), 1.0 / 2.4, out=out, where=high)
np.multiply(out, 1.055, out=out, where=high)
np.subtract(out, 0.055, out=out, where=high)
return out
def render_still(raw, pattern, black=256.0, white=4095.0, gains=None, ccm=None,
gamma=True) -> np.ndarray:
"""Turn a 12-bit Bayer frame into a viewable 16-bit RGB image.
Mirrors what a raw converter does, in the order the ISP does it:
black-level subtract, white balance, colour matrix, demosaic, gamma.
``gains`` is picamera2's ``ColourGains`` (red, blue; green is 1.0) and
``ccm`` is its ``ColourCorrectionMatrix`` (3x3, camera RGB -> sRGB). Either
may be None, in which case that step is skipped.
"""
rgb = demosaic(raw, pattern)
img = rgb.astype(np.float32)
del rgb
scale = 1.0 / float(white - black)
img *= scale
img -= black * scale
np.clip(img, 0.0, None, out=img)
if gains is not None:
img[..., 0] *= float(gains[0])
img[..., 2] *= float(gains[1])
if ccm is not None:
matrix = np.asarray(ccm, dtype=np.float32).reshape(3, 3)
img = img @ matrix.T
np.clip(img, 0.0, None, out=img)
# Highlight clipping happens here, as it does in the ISP.
np.clip(img, 0.0, 1.0, out=img)
if gamma:
img = srgb_encode(img)
return np.clip(img * 65535.0 + 0.5, 0, 65535).astype(np.uint16)
def _rotate_array(rgb, rotation):
"""Rotate an (h, w, 3) array clockwise by 0/90/180/270 degrees."""
rotation = int(rotation) % 360
if rotation == 0:
return rgb
if rotation == 90:
return np.ascontiguousarray(np.rot90(rgb, k=3))
if rotation == 180:
return np.ascontiguousarray(np.rot90(rgb, k=2))
if rotation == 270:
return np.ascontiguousarray(np.rot90(rgb, k=1))
return rgb
def _pil_transpose(rotation):
"""PIL constant that rotates an image clockwise by *rotation* degrees.
PIL's ROTATE_* constants turn counter-clockwise, hence the inversion.
"""
from PIL import Image
transpose = getattr(Image, "Transpose", Image)
return {90: transpose.ROTATE_270, 180: transpose.ROTATE_180,
270: transpose.ROTATE_90}[int(rotation) % 360]
def rotate_jpeg(path, rotation):
"""Rotate a JPEG on disk in place (used for the ISP-written still)."""
rotation = int(rotation) % 360
if rotation == 0:
return False
from PIL import Image
with Image.open(path) as image:
turned = image.transpose(_pil_transpose(rotation))
turned.save(path, quality=95, subsampling=0)
return True
def set_dng_orientation(path, rotation):
"""Write the DNG/TIFF Orientation tag (274) in place.
Rotating the Bayer mosaic itself would change the sensor pattern, so the raw
DNG keeps its pixels and records the rotation the way every raw converter
expects: as metadata. Orientation 1/3/6/8 = 0/180/90/270 degrees.
"""
value = {0: 1, 90: 6, 180: 3, 270: 8}.get(int(rotation) % 360)
if value in (None, 1):
return False
try:
with open(path, "r+b") as handle:
header = handle.read(8)
if header[:2] not in (b"II", b"MM"):
return False
order = "little" if header[:2] == b"II" else "big"
ifd_offset = int.from_bytes(header[4:8], order)
handle.seek(ifd_offset)
count = int.from_bytes(handle.read(2), order)
entries = handle.read(12 * count)
for index in range(count):
entry = entries[index * 12:(index + 1) * 12]
tag = int.from_bytes(entry[0:2], order)
if tag == 274:
handle.seek(ifd_offset + 2 + index * 12 + 8)
handle.write(value.to_bytes(2, order) + b"\x00\x00")
return True
except OSError:
log.exception("could not set DNG orientation on %s", path)
return False
class TiffWorker(threading.Thread):
"""Serial background worker for the expensive demosaic + TIFF write."""
def __init__(self, on_done=None):
super().__init__(name="tiff-writer", daemon=True)
self._jobs = queue.Queue()
self._on_done = on_done
def submit(self, job):
self._jobs.put(job)
def stop(self, timeout=90):
"""Finish any queued job, then end the thread."""
if self.is_alive():
self._jobs.put(None)
self.join(timeout=timeout)
def run(self):
while True:
job = self._jobs.get()
if job is None:
log.info("TIFF worker stopping")
return
try:
self._do(job)
except Exception:
log.exception("TIFF job failed")
if self._on_done:
self._on_done(job, None, "TIFF failed (see log)")
def _do(self, job):
import tifffile
started = time.monotonic()
raw = unpack_mipi12(job["packed"], job["width"], job["height"], job["stride"])
rgb = render_still(
raw,
job["pattern"],
black=job.get("black_level", 256.0),
white=job.get("white_level", 4095.0),
gains=job.get("gains") if job.get("apply_white_balance", True) else None,
ccm=job.get("ccm") if job.get("apply_ccm", True) else None,
gamma=job.get("gamma", True),
)
del raw
rotation = int(job.get("rotation", 0) or 0)
if rotation:
rgb = _rotate_array(rgb, rotation)
description = (
"16-bit RGB from the 12-bit Bayer frame; "
f"pattern={job['pattern']} black={job.get('black_level')} "
f"white={job.get('white_level')} "
f"white_balance={job.get('gains') if job.get('apply_white_balance', True) else None} "
f"colour_matrix={'applied' if job.get('apply_ccm', True) and job.get('ccm') else 'none'} "
f"transfer={'sRGB' if job.get('gamma', True) else 'linear'}; "
"raw sensor data preserved in the DNG alongside."
)
tifffile.imwrite(
job["path"], rgb, photometric="rgb",
description=description, compression=None,
)
elapsed = time.monotonic() - started
log.info("TIFF written in %.1fs: %s", elapsed, job["path"])
if self._on_done:
self._on_done(job, elapsed, None)
def capture_still(camera, storage, cfg, tiff_worker, on_tiff_done=None, rotation=0):
"""Capture one full-resolution still. Must run on the camera thread.
*rotation* (clockwise degrees) is applied to the saved output: the JPEG is
turned on disk, the TIFF is turned before it is written, and the DNG carries
it as an Orientation tag (its Bayer data must stay sensor-native).
"""
photo_cfg = cfg["photo"]
rotation = int(rotation or 0) % 360
session = storage.new_session("photo")
raw_dir = storage.format_dir(session, "raw")
serial = storage.next_serial(raw_dir, "still")
stem = "still_%04d" % serial
t_switch = time.monotonic()
camera.switch_to_still()
settle = max(0, int(photo_cfg.get("settle_frames", 3)))
for _ in range(settle):
stale = camera.capture_request()
stale.release()
switch_seconds = time.monotonic() - t_switch
t_capture = time.monotonic()
request = camera.capture_request()
metadata = request.get_metadata()
written = {}
try:
if photo_cfg.get("write_dng", True):
path = raw_dir / (stem + ".dng")
request.save_dng(str(path), "raw")
if rotation:
set_dng_orientation(str(path), rotation)
written["dng"] = str(path)
if photo_cfg.get("write_jpeg", True):
jpeg_dir = storage.format_dir(session, "jpeg")
path = jpeg_dir / (stem + ".jpg")
request.save("main", str(path))
if rotation:
rotate_jpeg(str(path), rotation)
written["jpeg"] = str(path)
packed = None
if photo_cfg.get("write_tiff", True):
raw_cfg = camera.stream_config("raw") or {}
packed = np.array(request.make_buffer("raw"), dtype=np.uint8)
width = int(raw_cfg.get("size", (photo_cfg["width"], photo_cfg["height"]))[0])
height = int(raw_cfg.get("size", (photo_cfg["width"], photo_cfg["height"]))[1])
stride = int(raw_cfg.get("stride") or (width * 3 // 2))
pattern = pattern_from_format(raw_cfg.get("format", "SRGGB12"))
ccm = metadata.get("ColourCorrectionMatrix")
tiff_dir = storage.format_dir(session, "tiff")
job = {
"packed": packed,
"width": width,
"height": height,
"stride": stride,
"pattern": pattern,
"gains": metadata.get("ColourGains"),
"apply_white_balance": photo_cfg.get("tiff_apply_white_balance", True),
"ccm": list(ccm) if ccm is not None else None,
"apply_ccm": photo_cfg.get("tiff_apply_colour_matrix", True),
"gamma": photo_cfg.get("tiff_apply_gamma", True),
"black_level": photo_cfg.get("black_level", 256),
"white_level": photo_cfg.get("white_level", 4095),
"rotation": rotation,
"path": str(tiff_dir / (stem + ".tiff")),
"stem": stem,
"session": str(session),
}
tiff_worker.submit(job)
written["tiff"] = job["path"]
finally:
request.release()
camera.switch_to_video()
summary = {
"kind": "photo",
"stem": stem,
"session": str(session),
"taken": _dt.datetime.now().astimezone().isoformat(timespec="milliseconds"),
"files": {k: storage.relative(v) for k, v in written.items()},
"mode": "4056x3040",
"rotation": rotation,
"capture_ms": round((time.monotonic() - t_capture) * 1000),
"switch_ms": round(switch_seconds * 1000),
"exposure_us": metadata.get("ExposureTime"),
"gain": metadata.get("AnalogueGain"),
"awb": list(metadata.get("ColourGains")) if metadata.get("ColourGains") else None,
"colour_correction_matrix": list(metadata["ColourCorrectionMatrix"])
if metadata.get("ColourCorrectionMatrix") is not None else None,
"black_level": photo_cfg.get("black_level", 256),
"white_level": photo_cfg.get("white_level", 4095),
"sensor_temperature": metadata.get("SensorTemperature"),
"notes": [
"DNG holds the untouched 12-bit Bayer frame.",
"TIFF is 16-bit sRGB, demosaiced from that Bayer frame with white balance",
"and the colour-correction matrix applied.",
"JPEG is the ISP-processed 8-bit image.",
"rotation is the clockwise display/export rotation in degrees.",
],
}
_write_json(session / "photo.json", summary)
return summary
def _write_json(path, payload):
tmp = str(path) + ".tmp"
try:
with open(tmp, "w", encoding="utf-8") as handle:
json.dump(payload, handle, indent=2, default=str)
handle.write("\n")
os.replace(tmp, path)
except OSError:
log.exception("could not write %s", path)