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

173
storage.py Normal file
View File

@@ -0,0 +1,173 @@
"""SSD discovery, mount state and the on-disk session layout.
Layout (date, then time, then file format):
/mnt/microscope/2026-10-30/21-33-05/raw/frame_000001.raw
/mnt/microscope/2026-10-30/21-33-05/index.jsonl
/mnt/microscope/2026-10-30/21-33-05/session.json
/mnt/microscope/2026-10-30/21-40-12/raw/still_0001.dng
/mnt/microscope/2026-10-30/21-40-12/tiff/still_0001.tiff
/mnt/microscope/2026-10-30/21-40-12/jpeg/still_0001.jpg
"""
import datetime as _dt
import logging
import os
import re
import shutil
import subprocess
import time
from pathlib import Path
log = logging.getLogger("storage")
UUID_BY_PATH = "/dev/disk/by-uuid"
MOUNT_HELPER = "/usr/local/sbin/microscope-ssd"
class Storage:
def __init__(self, cfg):
self.cfg = cfg
self.mount_point = Path(cfg.get("mount_point", "/mnt/microscope"))
self.uuid = cfg.get("fs_uuid", "")
self._last_session_key = None
self._last_session_dir = None
# ------------------------------------------------------------------ state
@property
def device_node(self):
if not self.uuid:
return None
node = Path(UUID_BY_PATH) / self.uuid
return node if node.exists() else None
def device_present(self) -> bool:
"""True when the SSD is plugged in and the partition node exists."""
return self.device_node is not None
def mount_source(self):
"""Device backing the mount point, or None when it is a plain directory."""
target = os.fspath(self.mount_point)
try:
with open("/proc/self/mounts", "r", encoding="utf-8") as handle:
for line in handle:
parts = line.split()
if len(parts) >= 2 and parts[1] == target:
return parts[0]
except OSError:
log.debug("could not read /proc/self/mounts", exc_info=True)
return None
def is_mounted(self) -> bool:
"""True only when the SSD is really mounted *and* still present.
Yanking a USB drive leaves a stale entry in /proc/mounts that still
reports as a mount point, so the backing device is checked too.
"""
source = self.mount_source()
if source is None:
return False
try:
os.statvfs(self.mount_point)
except OSError:
return False
if self.uuid:
return (Path(UUID_BY_PATH) / self.uuid).exists()
return os.path.exists(source)
def free_mb(self) -> int:
if not self.is_mounted():
return 0
try:
usage = shutil.disk_usage(self.mount_point)
return int(usage.free // (1024 * 1024))
except OSError:
return 0
def state(self) -> str:
"""One of: 'ready', 'unmounted', 'absent'."""
if self.is_mounted():
return "ready"
if self.device_present():
return "unmounted"
return "absent"
# ------------------------------------------------------------- mount ops
def _run_helper(self, action: str, timeout: float = 20.0):
if not os.access(MOUNT_HELPER, os.X_OK):
return False, f"{MOUNT_HELPER} not installed"
try:
proc = subprocess.run(
["sudo", "-n", MOUNT_HELPER, action],
capture_output=True, text=True, timeout=timeout,
)
except subprocess.TimeoutExpired:
return False, "mount helper timed out"
except OSError as exc:
return False, str(exc)
out = (proc.stdout + proc.stderr).strip()
return proc.returncode == 0, out
def mount(self):
if self.is_mounted():
return True, "already mounted"
return self._run_helper("mount")
def unmount(self):
if not self.is_mounted():
return True, "not mounted"
return self._run_helper("umount")
# --------------------------------------------------------- session paths
def new_session(self, kind: str) -> Path:
"""Create (or reuse) the date/time session directory for a capture.
Two captures in the same second share a directory and continue the
frame/still numbering, which keeps rapid photo bursts tidy.
"""
now = _dt.datetime.now()
date_part = now.strftime("%Y-%m-%d")
time_part = now.strftime("%H-%M-%S")
key = f"{date_part}/{time_part}"
session = self.mount_point / date_part / time_part
if key == self._last_session_key and session.is_dir():
return session
session.mkdir(parents=True, exist_ok=True)
self._last_session_key = key
self._last_session_dir = session
log.info("new %s session: %s", kind, session)
return session
@staticmethod
def format_dir(session: Path, fmt: str) -> Path:
path = session / fmt
path.mkdir(exist_ok=True)
return path
@staticmethod
def next_serial(directory: Path, prefix: str) -> int:
"""Highest existing ``prefix_NNNN`` number in *directory*, plus one."""
highest = 0
pattern = re.compile(rf"^{re.escape(prefix)}_(\d+)\.[A-Za-z0-9]+$")
if directory.is_dir():
for entry in directory.iterdir():
match = pattern.match(entry.name)
if match:
highest = max(highest, int(match.group(1)))
return highest + 1
def relative(self, path: Path) -> str:
try:
return str(Path(path).relative_to(self.mount_point))
except ValueError:
return str(path)
def wait_mounted(self, timeout: float = 15.0) -> bool:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if self.is_mounted():
return True
time.sleep(0.25)
return self.is_mounted()