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

120
config.py Normal file
View File

@@ -0,0 +1,120 @@
"""Configuration for the microscope camera app.
Everything tunable lives in ``config.json`` next to this file; missing keys fall
back to ``DEFAULTS`` so an older config file never breaks a new release.
"""
import copy
import json
import os
HERE = os.path.dirname(os.path.abspath(__file__))
CONFIG_PATH = os.path.join(HERE, "config.json")
DEFAULTS = {
# ---- storage ---------------------------------------------------------
"mount_point": "/mnt/microscope",
"fs_uuid": "caf4320b-0fa3-4674-bfa9-f17f6165f681",
"device_label": "MICROSCOPE",
"archive_min_free_mb": 500, # refuse to keep recording below this
# ---- sensor / video pipeline (live preview + raw recording) ----------
# 2028x1520 is the IMX477's 2x2-binned mode: full field of view at up to 40fps.
"sensor": {"width": 2028, "height": 1520, "bit_depth": 12, "sensor_fps": 40},
# Preview is produced by the ISP and handed to cairo, so it must be a
# 4-byte format: XRGB8888 in memory is B,G,R,X == cairo FORMAT_RGB24.
"preview": {"width": 1024, "height": 600, "format": "XRGB8888"},
"record": {
"target_fps": 30, # frame pacing request (SSD sustains ~29 fps at 12-bit packed)
"buffer_count": 12, # camera buffers: absorb SSD write stalls (~300ms at 40fps)
"write_queue": 8, # requests queued for the writer thread
"index_stride": 1, # 1 = log every frame in index.jsonl
# Pin the sensor's frame duration to 1/target_fps. Without this, auto
# exposure is free to pick e.g. 66 ms in dim light and the recording
# silently runs at 15 fps. With it, AE compensates using gain instead.
# Set false if you would rather have a bright slow image than 30 fps.
"limit_frame_duration": True,
},
# ---- still photo pipeline (full 4056x3040) --------------------------
"photo": {
"width": 4056,
"height": 3040,
"buffer_count": 2,
"jpeg_quality": 95,
"write_dng": True,
"write_tiff": True,
"write_jpeg": True,
"settle_frames": 3, # let AE/AWB settle before the frame we keep
# The TIFF is meant to be a viewable 16-bit sRGB image. The DNG remains
# the untouched linear Bayer frame for quantitative work, so applying
# white balance + CCM to the TIFF costs nothing that cannot be recovered.
"tiff_apply_white_balance": True,
"tiff_apply_colour_matrix": True,
"tiff_apply_gamma": True,
# IMX477 12-bit levels; used to normalise the Bayer data before demosaic.
"black_level": 256,
"white_level": 4095,
},
"ui": {
"fullscreen": True,
"hide_cursor": True,
"preview_refresh_ms": 33,
"status_refresh_ms": 250,
"storage_poll_ms": 1000,
# 0/90/180/270, changed by the rotate button and remembered across boots
"rotation": 0,
},
}
def _deep_merge(base, override):
out = copy.deepcopy(base)
for key, value in (override or {}).items():
if isinstance(value, dict) and isinstance(out.get(key), dict):
out[key] = _deep_merge(out[key], value)
else:
out[key] = value
return out
class Config:
"""Dict-like config with attribute-free access and disk persistence."""
def __init__(self, path=CONFIG_PATH):
self.path = path
self.data = copy.deepcopy(DEFAULTS)
self.load()
def load(self):
if os.path.exists(self.path):
try:
with open(self.path, "r", encoding="utf-8") as handle:
user = json.load(handle)
self.data = _deep_merge(DEFAULTS, user)
except (OSError, ValueError):
# A corrupt config must never stop the microscope from booting.
self.data = copy.deepcopy(DEFAULTS)
return self.data
def save(self):
tmp = self.path + ".tmp"
with open(tmp, "w", encoding="utf-8") as handle:
json.dump(self.data, handle, indent=2, sort_keys=True)
handle.write("\n")
os.replace(tmp, self.path)
def get(self, path, default=None):
"""Fetch a nested value: ``cfg.get("record.target_fps")``."""
node = self.data
for part in path.split("."):
if not isinstance(node, dict) or part not in node:
return default
node = node[part]
return node
def __getitem__(self, key):
return self.data[key]