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.
This commit is contained in:
Microscope Camera
2026-10-31 00:44:46 -04:00
commit 34cd247dca
18 changed files with 3159 additions and 0 deletions

365
tools/unpack_raw.py Normal file
View File

@@ -0,0 +1,365 @@
#!/usr/bin/env python3
"""Unpack the microscope camera's raw frames on a desktop machine.
Recording writes MIPI-packed 12-bit Bayer frames (``SBGGR12_CSI2P`` style: two
pixels in three bytes) straight off the sensor. This tool turns them into files
you can actually look at:
unpack_raw.py frame_000001.raw -> frame_000001.npy (uint16)
unpack_raw.py --png frame_000001.raw -> frame_000001.png (quick look)
unpack_raw.py --tiff16 frame_000001.raw -> frame_000001.tiff (16-bit mono)
unpack_raw.py --debayer --rgb16 frame_000001.raw-> frame_000001_rgb.tiff (16-bit RGB)
unpack_raw.py /mnt/microscope/2026-10-30/21-33-05 (whole session)
unpack_raw.py --info /mnt/microscope/2026-10-30/21-33-05
Geometry (width, height, stride, Bayer pattern) is picked up automatically from
the session's ``session.json``; if that is missing the tool falls back to the
file size and the command line defaults.
"""
import argparse
import json
import os
import sys
import numpy as np
PATTERNS = {
# 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):
text = (fmt or "SRGGB12").upper()
if text.startswith("S"):
text = text[1:]
head = text[:4]
return head if head in PATTERNS else "RGGB"
def unpack_mipi12(packed, width, height, stride):
"""Unpack MIPI CSI-2 RAW12 (3 bytes -> 2 pixels).
Byte order matters: byte0 holds the low 8 bits of pixel 0, byte1 holds the
high 4 bits of pixel 0 in its HIGH nibble and the high 4 bits of pixel 1 in
its LOW nibble. Treating byte1's low nibble as pixel 0's high bits (and
its high nibble as pixel 1's) transposes the 12-bit words within each pair,
which scrambles the Bayer mosaic and produces colour-fringed output.
"""
rows = packed[: stride * height].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, pattern="RGGB"):
rp, cp = PATTERNS.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]
green_b = tiles[..., 1 - rp, cp]
def upsample(plane):
p = np.pad(plane, 1, mode="edge")
center = p[1:-1, 1:-1]
left, right = p[1:-1, :-2], p[1:-1, 2:]
up, down = p[:-2, 1:-1], p[2:, 1:-1]
col_n = right if cp == 0 else left
row_n = down if rp == 0 else up
corner = p[2:, 2:] if (cp == 0 and rp == 0) else \
p[2:, :-2] if (cp == 0 and rp == 1) else \
p[:-2, 2:] if (cp == 1 and rp == 0) else p[:-2, :-2]
full = np.empty((h, w), dtype=np.uint16)
full[rp::2, cp::2] = center
full[rp::2, (1 - cp)::2] = (center + col_n) // 2
full[(1 - rp)::2, cp::2] = (center + row_n) // 2
full[(1 - rp)::2, (1 - cp)::2] = (center + col_n + row_n + corner) // 4
return full
red_full, blue_full = upsample(red), upsample(blue)
g1 = np.pad(green_r.astype(np.uint32), 1, mode="edge")
g2 = np.pad(green_b.astype(np.uint32), 1, mode="edge")
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[1:-1, 1:-1] + g1[1:-1, :-2]) // 2
green_full[(1 - rp)::2, (1 - cp)::2] = (g2[1:-1, 1:-1] + g2[1:-1, 2:]) // 2
rgb = np.empty((h, w, 3), dtype=np.uint16)
rgb[..., 1] = green_full
return rgb, red_full, blue_full
def apply_rotation(image, rotation):
"""Rotate an array clockwise by 0/90/180/270 degrees."""
rotation = int(rotation or 0) % 360
if rotation == 0:
return image
if rotation == 90:
return np.ascontiguousarray(np.rot90(image, k=3))
if rotation == 180:
return np.ascontiguousarray(np.rot90(image, k=2))
if rotation == 270:
return np.ascontiguousarray(np.rot90(image, k=1))
return image
_PATTERN_TILE = {
"RGGB": (("R", "G"), ("G", "B")),
"BGGR": (("B", "G"), ("G", "R")),
"GRBG": (("G", "R"), ("B", "G")),
"GBRG": (("G", "B"), ("R", "G")),
}
def rotate_pattern(pattern, rotation):
"""CFA pattern after rotating the Bayer plane clockwise.
Rotating a mosaic without moving its pattern would put red samples where
blue belong, so a rotated .npy/.tiff must record the rotated pattern.
"""
steps = (int(rotation or 0) % 360) // 90
if not steps:
return pattern
tile = [list(row) for row in _PATTERN_TILE.get(pattern, _PATTERN_TILE["RGGB"])]
for _ in range(steps):
tile = [[tile[1][0], tile[0][0]], [tile[1][1], tile[0][1]]] # 90 deg clockwise
text = "".join(tile[0] + tile[1])
for name, known in _PATTERN_TILE.items():
if "".join(known[0] + known[1]) == text:
return name
return pattern
def find_session(path):
"""Walk up from *path* looking for session.json / photo.json."""
current = os.path.abspath(path if os.path.isdir(path) else os.path.dirname(path))
for _ in range(4):
for name in ("session.json", "photo.json"):
candidate = os.path.join(current, name)
if os.path.isfile(candidate):
return current, candidate
parent = os.path.dirname(current)
if parent == current:
break
current = parent
return None, None
def geometry_for(path, args):
width, height, stride, pattern = args.width, args.height, args.stride, args.pattern
session_dir, meta_file = find_session(path)
meta = {}
if meta_file:
try:
with open(meta_file, "r", encoding="utf-8") as handle:
meta = json.load(handle)
except (OSError, ValueError):
meta = {}
raw = meta.get("raw") or {}
if raw:
width = width or raw.get("width")
height = height or raw.get("height")
stride = stride or raw.get("stride")
pattern = args.pattern or pattern_from_format(raw.get("format"))
path_size = os.path.getsize(path)
height = height or 1520
if not width:
# packed 12-bit: bytes per row = width * 3/2
if stride:
width = int(stride * 2 / 3)
else:
width = 2028
if not stride:
stride = path_size // height
expected = width * height if args.unpacked else stride * height
if path_size < expected:
raise SystemExit(
"%s is %d bytes but %dx%d needs %d — pass --width/--height/--stride"
% (os.path.basename(path), path_size, width, height, expected)
)
rotation = args.rotate if args.rotate is not None else int(meta.get("rotation") or 0)
return width, height, stride, pattern, session_dir, meta, rotation
def contrast_stretch(image):
lo, hi = np.percentile(image, (0.5, 99.5))
if hi <= lo:
hi = lo + 1
scaled = np.clip((image.astype(np.float32) - lo) * (255.0 / (hi - lo)), 0, 255)
return scaled.astype(np.uint8)
def srgb_encode(rgb16):
"""Linear 16-bit RGB -> display-ready 8-bit sRGB.
Without this the frame looks flat and washed out, because linear sensor data
held in a 16-bit container has all its mid-tones crammed into the dark end.
"""
x = np.clip(rgb16.astype(np.float32) / 65535.0, 0.0, 1.0)
x = np.where(x <= 0.0031308, x * 12.92,
1.055 * np.power(x, 1.0 / 2.4) - 0.055)
return np.clip(x * 255.0 + 0.5, 0, 255).astype(np.uint8)
def white_balance(rgb, red_full, blue_full, gains):
"""Apply camera white balance. Only called when --white-balance is given:
gains above 1.0 clip at 65535, which loses highlight data."""
if not gains:
gains = (1.0, 1.0)
out = rgb
out[..., 0] = np.clip(red_full.astype(np.float32) * float(gains[0]), 0, 65535).astype(np.uint16)
out[..., 2] = np.clip(blue_full.astype(np.float32) * float(gains[1]), 0, 65535).astype(np.uint16)
return out
def process(path, args):
width, height, stride, pattern, session_dir, meta, rotation = geometry_for(path, args)
stem = os.path.splitext(os.path.basename(path))[0]
outdir = args.outdir or os.path.dirname(os.path.abspath(path))
os.makedirs(outdir, exist_ok=True)
payload = np.fromfile(path, dtype=np.uint8)
if args.unpacked:
image = payload[: width * height * 2].view(np.uint16).reshape(height, width)
else:
image = unpack_mipi12(payload, width, height, stride)
print("%-28s %dx%d %s stride=%d rotation=%d° max=%d mean=%.1f"
% (os.path.basename(path), width, height, pattern, stride, rotation,
int(image.max()), float(image.mean())))
# The session's rotation is the operator's display orientation, so honour it
# in everything this tool writes. Bayer exports are rotated *with* their CFA
# pattern; the RGB path is demosaiced first and then turned, which is
# equivalent and avoids re-deriving the pattern.
bayer = apply_rotation(image, rotation)
bayer_pattern = rotate_pattern(pattern, rotation)
made = []
if args.npy or not (args.png or args.tiff16 or args.rgb16):
out = os.path.join(outdir, stem + ".npy")
np.save(out, bayer)
made.append(out)
if args.png:
from PIL import Image
out = os.path.join(outdir, stem + ".png")
Image.fromarray(contrast_stretch(bayer)).save(out)
made.append(out)
if args.tiff16:
import tifffile
out = os.path.join(outdir, stem + ".tiff")
tifffile.imwrite(out, bayer,
description="16-bit unpacked Bayer %s (rotated %d deg clockwise)"
% (bayer_pattern, rotation))
made.append(out)
if args.rgb16:
import tifffile
gains = meta.get("awb")
# Demosaic in sensor orientation, fill all three channels, then rotate:
# rotating an RGB triple is equivalent to rotating the mosaic and
# re-deriving its CFA pattern, without the pattern bookkeeping.
rgb, red_full, blue_full = demosaic(image, pattern)
rgb[..., 0] = np.clip(red_full.astype(np.float32) * 16.0, 0, 65535).astype(np.uint16)
rgb[..., 2] = np.clip(blue_full.astype(np.float32) * 16.0, 0, 65535).astype(np.uint16)
rgb[..., 1] = np.clip(rgb[..., 1].astype(np.float32) * 16.0, 0, 65535).astype(np.uint16)
del red_full, blue_full, image
if rotation:
rgb = apply_rotation(rgb, rotation)
applied = False
if args.white_balance:
rgb = white_balance(rgb, rgb[..., 0], rgb[..., 2], gains)
applied = True
out = os.path.join(outdir, stem + "_rgb.tiff")
tifffile.imwrite(out, rgb, photometric="rgb",
description="linear 16-bit RGB, %s, rotation=%d deg clockwise, "
"white_balance_applied=%s camera_white_balance=%s"
% (pattern, rotation, applied, gains))
made.append(out)
for item in made:
print(" -> %s (%.1f MB)" % (item, os.path.getsize(item) / 1e6))
return made
def show_info(target):
session_dir, meta_file = find_session(target)
if not meta_file:
raise SystemExit("no session.json/photo.json found near %s" % target)
with open(meta_file, "r", encoding="utf-8") as handle:
meta = json.load(handle)
print(json.dumps(meta, indent=2))
def collect_inputs(paths, recursive):
files = []
for path in paths:
if os.path.isdir(path):
raw_dir = os.path.join(path, "raw")
scan = raw_dir if os.path.isdir(raw_dir) else path
for name in sorted(os.listdir(scan)):
if name.endswith(".raw"):
files.append(os.path.join(scan, name))
else:
files.append(path)
return files
def main():
parser = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("paths", nargs="+", help="raw files, session dirs, or date dirs")
parser.add_argument("--outdir", help="where to write results (default: next to input)")
parser.add_argument("--npy", action="store_true", help="write 16-bit .npy (default)")
parser.add_argument("--png", action="store_true", help="write 8-bit contrast-stretched PNG")
parser.add_argument("--tiff16", action="store_true", help="write 16-bit Bayer TIFF")
parser.add_argument("--rgb16", action="store_true", help="write 16-bit RGB TIFF (demosaiced)")
parser.add_argument("--debayer", action="store_true", help="alias for --rgb16")
parser.add_argument("--rotate", type=int, choices=(0, 90, 180, 270),
help="override the session's display rotation (degrees clockwise)")
parser.add_argument("--no-rotate", dest="rotate", action="store_const", const=0,
help="ignore the rotation recorded in the session metadata")
parser.add_argument("--white-balance", action="store_true",
help="apply the camera's colour gains to the RGB output "
"(clips highlights; off by default, gains are recorded anyway)")
parser.add_argument("--info", action="store_true", help="print session metadata and exit")
parser.add_argument("--pattern", choices=sorted(PATTERNS), help="Bayer pattern override")
parser.add_argument("--width", type=int, help="frame width override")
parser.add_argument("--height", type=int, help="frame height override")
parser.add_argument("--stride", type=int, help="row stride in bytes override")
parser.add_argument("--unpacked", action="store_true",
help="input is already unpacked 16-bit little-endian")
args = parser.parse_args()
if args.debayer:
args.rgb16 = True
if args.info:
for path in args.paths:
show_info(path)
return 0
files = collect_inputs(args.paths, recursive=True)
if not files:
raise SystemExit("no .raw files found")
total = 0
for path in files:
total += len(process(path, args))
print("done: %d file(s) from %d input(s)" % (total, len(files)))
return 0
if __name__ == "__main__":
sys.exit(main())