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:
120
config.py
Normal file
120
config.py
Normal 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]
|
||||
Reference in New Issue
Block a user