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.
121 lines
4.4 KiB
Python
121 lines
4.4 KiB
Python
"""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]
|