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:
173
storage.py
Normal file
173
storage.py
Normal 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()
|
||||
Reference in New Issue
Block a user