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

9
.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
# Byte-compiled / cached files
__pycache__/
*.py[cod]
*.egg-info/
# Local scratch
*.log
*.tmp
.DS_Store

232
README.md Normal file
View File

@@ -0,0 +1,232 @@
# Microscope Camera
Touchscreen kiosk for the Raspberry Pi 4 + IMX477 (HQ) microscope camera.
* live preview on the 1024x600 HDMI touchscreen from boot
* **REC / STOP** — raw 12-bit Bayer frame recording, 2028x1520, ~29-30 fps
* **PHOTO** — full-resolution 4056x3040 still as DNG + 16-bit TIFF + JPEG
* **⟳ rotate** (bottom-left) — turns the image 90° at a time, on screen *and*
in the files it writes
* **tap the status pill** (top-left) — opens sliders for exposure, gain,
sharpness, contrast, saturation and brightness; tap it again to close
* a red **SSD NOT MOUNTED** badge that appears only when the capture SSD is
missing, and the SSD auto-mounts whenever it is plugged in
* everything lands on the SSD as `date/time/format/`
## On-screen controls
| Control | Where | What it does |
|---|---|---|
| `● REC` / `■ STOP` | bottom-right | starts/stops raw frame capture into a new `date/time/raw` session |
| `◉ PHOTO` | bottom-right | full-resolution still: DNG + 16-bit TIFF + JPEG in one `date/time` folder |
| `⟳ n°` | bottom-left | rotates preview and saved output clockwise in 90° steps (remembered across reboots) |
| status pill | top-left | tap to show/hide the camera settings sliders |
| `⏏ MOUNT SSD` | above REC, only when needed | appears when the drive is present but not mounted |
### Rotating
The rotate button turns both the live preview and everything that gets saved:
* **JPEG / TIFF** pixels are physically rotated before they are written.
* **DNG** keeps its sensor-native Bayer data (rotating a mosaic would change its
colour-filter pattern) and records the rotation in the standard TIFF
`Orientation` tag, which raw converters honour.
* **Raw recordings** store frames sensor-native too and record the angle in
`session.json` as `rotation`; `tools/unpack_raw.py` applies it on export, so
raw frames you unpack on a desktop come out the way the screen showed them.
### Camera settings panel
Tapping the top-left status pill reveals sliders for the controls the sensor
actually exposes (anything unsupported is greyed out):
* **Exposure** — logarithmic, from the sensor minimum (1 µs) up to 66 ms, so the
useful low end is not crammed into the first pixel of travel.
* **Gain** — analogue gain, 1.0x to 16.0x.
* **Sharpness / Contrast / Saturation / Brightness** — ISP tuning.
Touching Exposure or Gain switches **Auto exposure** off automatically (AE would
otherwise overwrite the slider every frame). The button at the top of the panel
hands control back to AE and restores the frame-duration pin.
A manual exposure longer than `1/target_fps` temporarily widens
`FrameDurationLimits`, because the recording pin would otherwise clamp it; the
sensor cannot both expose for 50 ms and deliver 30 fps, so the frame rate drops
for as long as the long exposure is set. Auto exposure restores the pin.
## Layout on the SSD
```
/mnt/microscope/
├── 2026-10-30/ <- date
│ ├── 21-33-05/ <- time (start of the session)
│ │ ├── raw/
│ │ │ ├── frame_000001.raw 4.67 MB packed 12-bit Bayer
│ │ │ └── ...
│ │ ├── index.jsonl one line per frame: timestamp, exposure, gain, drops
│ │ └── session.json totals: frames, bytes, fps, dropped, missed
│ └── 21-40-12/ <- a photo session
│ ├── raw/still_0001.dng 18 MB, untouched 12-bit Bayer
│ ├── tiff/still_0001.tiff 74 MB, 16-bit sRGB (demosaiced, WB+colour matrix)
│ ├── jpeg/still_0001.jpg ISP-processed 8-bit
│ └── photo.json exposure, gain, white balance, timings
└── _archive/ (old test recordings moved off the SD card)
```
## What the record button actually writes
Frames are the sensor's raw stream, stored **verbatim**: MIPI-packed 12-bit
Bayer, 2028x1520 (2x2 binned, full field of view), one file per frame. No
debayer, no compression, no white balance — nothing is baked in. Per frame:
`stride=3072`, `frame_bytes=4669440`, format `SBGGR12_CSI2P`.
Frame rate is governed by the SSD, not the sensor. Measured on this hardware:
| Target | Rate needed | Result |
|---|---|---|
| 40 fps (sensor mode max) | 247 MB/s | not possible |
| **30 fps** | **140 MB/s** | **sustained: 29.0 fps, 0 dropped, 0 missed** |
| 25 fps | 117 MB/s | comfortable |
The drive sustains ~156 MB/s (~145 MB/s after its 220 MB SLC burst), so 30 fps is
close to the limit. Frame integrity is never fudged: `session.json` reports
`dropped` (writer queue overflow) and `missed` (sensor frames that never reached
disk, computed from `SensorTimestamp` gaps).
`record.limit_frame_duration` pins the sensor's frame duration to
`1/target_fps`. Without it auto exposure can pick e.g. 66 ms in dim light and
the recording silently runs at 15 fps. With it, AE compensates with gain
instead. Set it `false` if you would rather have a bright, slow image.
## Reading raw frames on a desktop
```bash
# whole session -> 16-bit .npy per frame (geometry read from session.json)
python3 tools/unpack_raw.py /media/ssd/2026-10-30/21-33-05
# quick look: 8-bit PNG, contrast stretched
python3 tools/unpack_raw.py --png frame_000001.raw
# 16-bit greyscale Bayer TIFF
python3 tools/unpack_raw.py --tiff16 frame_000001.raw
# 16-bit RGB TIFF (bilinear demosaic of the 12-bit data)
python3 tools/unpack_raw.py --rgb16 frame_000001.raw
# session metadata
python3 tools/unpack_raw.py --info /media/ssd/2026-10-30/21-33-05
```
Raw files are loadable without the tool as long as you know the geometry:
```python
import numpy as np
buf = np.fromfile("frame_000001.raw", dtype=np.uint8).reshape(1520, 3072)
trip = buf[:, :2028 * 3 // 2].reshape(1520, 1014, 3).astype(np.uint16)
b0, b1, b2 = trip[:, :, 0], trip[:, :, 1], trip[:, :, 2]
even = (b0 << 4) | (b1 >> 4) # 12-bit unpacked, 0-4095
odd = ((b1 & 0x0F) << 8) | b2
image = np.empty((1520, 2028), np.uint16)
image[:, 0::2], image[:, 1::2] = even, odd
```
Note the byte order: in each 3-byte group byte0 holds bits 11..4 of the first
pixel, byte1's **high** nibble holds its bits 3..0, and byte1's **low** nibble
holds the top of the second pixel. Swapping those nibbles still yields values in
0-4095, so it fails silently as colour-fringed mush — if your Python is
producing that, this is why.
## The 16-bit TIFF on the photo button
The Pi's ISP only ever outputs 8-bit processed images, so a "16-bit TIFF of the
ISP output" would just be 8-bit data in a 16-bit container. Instead the TIFF is
built from the 12-bit Bayer frame through the same chain a raw converter uses:
unpack 12-bit MIPI -> black-level subtract -> white balance
-> colour-correction matrix -> bilinear demosaic -> sRGB gamma -> 16-bit
so it opens as a normal, viewable 16-bit image rather than raw linear data. The
untouched sensor data stays in the DNG beside it, so nothing is lost for
quantitative work. The settings are in `config.json` under `photo`: set
`tiff_apply_white_balance` / `tiff_apply_colour_matrix` / `tiff_apply_gamma` to
`false` for a linear, un-white-balanced export instead. The camera's gains and
matrix are recorded in `photo.json` either way.
## Layout of the software
```
/opt/microscope-camera/
├── app.py GTK3 kiosk UI: preview, buttons, badge, settings sliders
├── camera.py picamera2 wrapper: video (raw+preview) and still configs
├── recorder.py writer thread for raw frames + index.jsonl/session.json
├── photos.py still capture (DNG/JPEG) and the 16-bit TIFF worker
├── storage.py SSD mount state and the date/time/format session paths
├── config.py defaults; config.json holds overrides
├── config.json tunables (edit and restart the service to apply)
├── install.sh reinstalls the app + system integration onto a fresh OS
├── system/ copies of the installed systemd units, udev rule, helper
└── tools/unpack_raw.py desktop-side unpacker / demosaicer
```
The copies in `system/` are the source of truth for the machine-specific files;
`install.sh` puts them back in place (see below).
System integration:
| Piece | Purpose |
|---|---|
| `/etc/systemd/system/microscope-camera.service` | runs `cage -s -- python3 app.py` fullscreen on tty1, `Restart=always` |
| `/usr/local/sbin/microscope-ssd` | `mount` / `umount` / `status` helper (log: `/run/microscope-ssd.log`) |
| `/etc/udev/rules.d/99-microscope-ssd.rules` | on plug-in, starts the mount service |
| `/etc/systemd/system/microscope-ssd-mount.service` | oneshot mount unit triggered by udev |
| `/etc/systemd/system/microscope-boot-check.service` | writes a boot self-test to `~/boot-check.log` + a screenshot |
| `/etc/fstab` | `UUID=...` entry so the SSD also mounts at boot |
| `/etc/sudoers.d/microscope-ssd` | lets the kiosk UI mount without a password |
Why the mount is split this way: udev workers run **without `CAP_SYS_ADMIN`**, so
`mount` inside a `RUN+=` rule always fails with `permission denied`. The mount
has to happen in a service. That service is deliberately oneshot *without*
`RemainAfterExit`, otherwise it stays "active" after the first mount and systemd
silently skips every later replug.
The SSD is mounted by three independent mechanisms, so a missing mount is always
caught: the `fstab` entry mounts it at boot, the udev rule mounts it on hotplug,
and the running app re-tries every few seconds as a backstop.
## Reinstalling
```bash
sudo ./install.sh # packages, app, units, udev rule, fstab, enable
```
`install.sh` is idempotent: it replaces the app and units, leaves an existing
`config.json` alone, and only appends the fstab line if it is not already there.
## Operating
```bash
systemctl status microscope-camera.service # is the UI running
journalctl -u microscope-camera.service -f # live app log
sudo -n /usr/local/sbin/microscope-ssd status # mounted | stale | unmounted | absent
sudo systemctl restart microscope-camera # after editing config.json
```
Touch buttons work directly. With a keyboard the same actions are on
`r` (record), `p` (photo), `t` (rotate), `e` (settings panel) and `Esc`/`q` quit.
Recording refuses to start when the SSD is not mounted, so nothing silently
fills the SD card. If the SSD is yanked mid-recording the writer notices within
~0.5 s, stops the recording, and keeps whatever was already flushed.
`stale` in the helper's status means a mount entry survived a yank; the next
mount clears it automatically.
## Known limits
* **Light.** With the lens capped, AE wants 66 ms and the preview is black
noise. Pin the frame duration (default) or add light for 30 fps.
* The SSD's ~156 MB/s ceiling is the whole reason 40 fps raw is not possible at
2028x1520. A faster drive would raise the ceiling.
* Photos freeze the preview for ~2-3 s (sensor mode switch + settle + capture).
The 74 MB TIFF is written by a background thread afterwards.
* DNG comes from `pidng` (pure Python): ~0.6 s at 12 MP, fine for stills, far
too slow for frames.

1003
app.py Normal file

File diff suppressed because it is too large Load Diff

194
camera.py Normal file
View File

@@ -0,0 +1,194 @@
"""picamera2 wrapper: preview + raw recording pipeline, and the still pipeline.
Two sensor modes are used:
* **video** – 2028x1520 (IMX477 2x2 binned, full field of view, up to 40 fps).
The ISP produces a 1024x600 XRGB8888 preview while the raw stream gives
MIPI-packed 12-bit Bayer frames for recording.
* **still** – the full 4056x3040 mode, used only for the photo button.
Mode switches are cheap (~0.1 s) but they do restart AE/AWB, so they only happen
for stills.
"""
import logging
from picamera2 import Picamera2
log = logging.getLogger("camera")
class MicroscopeCamera:
def __init__(self, cfg):
self.cfg = cfg
self.picam2 = Picamera2()
self._video_config = None
self._still_config = None
self._mode = None
self.build_configs()
# ---------------------------------------------------------------- config
def build_configs(self):
cfg = self.cfg
sensor = cfg["sensor"]
preview = cfg["preview"]
record = cfg["record"]
photo = cfg["photo"]
self._video_config = self.picam2.create_video_configuration(
main={
"size": (int(preview["width"]), int(preview["height"])),
"format": str(preview["format"]),
},
raw={"size": (int(sensor["width"]), int(sensor["height"]))},
sensor={
"output_size": (int(sensor["width"]), int(sensor["height"])),
"bit_depth": int(sensor["bit_depth"]),
},
buffer_count=int(record["buffer_count"]),
)
# Pin the frame duration so auto exposure cannot quietly drop the
# recording rate. In dim light AE would otherwise choose e.g. 66 ms and
# the sensor would only manage ~15 fps; here it must use gain instead.
self._video_config["controls"] = {}
if record.get("limit_frame_duration", True):
frame_us = int(1_000_000 / max(1, int(record.get("target_fps", 30))))
self._video_config["controls"]["FrameDurationLimits"] = (frame_us, frame_us)
log.info("frame duration pinned to %d us (%d fps)", frame_us,
int(record.get("target_fps", 30)))
self._still_config = self.picam2.create_still_configuration(
main={
"size": (int(photo["width"]), int(photo["height"])),
"format": "YUV420",
},
raw={"size": (int(photo["width"]), int(photo["height"]))},
sensor={
"output_size": (int(photo["width"]), int(photo["height"])),
"bit_depth": 12,
},
buffer_count=int(photo["buffer_count"]),
)
return self._video_config, self._still_config
# ------------------------------------------------------------- lifecycle
def start(self, mode="video"):
config = self._video_config if mode == "video" else self._still_config
self.picam2.configure(config)
self.picam2.options["quality"] = int(self.cfg.get("photo.jpeg_quality", 95))
self.picam2.start()
self._mode = mode
log.info("camera started in %s mode", mode)
return config
def stop(self):
try:
self.picam2.stop()
except Exception: # already stopped
log.debug("camera stop raised", exc_info=True)
self._mode = None
def close(self):
try:
self.picam2.close()
except Exception:
log.debug("camera close raised", exc_info=True)
def switch_to_still(self):
if self._mode != "still":
self.picam2.switch_mode(self._still_config)
self._mode = "still"
def switch_to_video(self):
if self._mode != "video":
self.picam2.switch_mode(self._video_config)
self._mode = "video"
# --------------------------------------------------------------- capture
def capture_request(self):
return self.picam2.capture_request()
def preview_array(self, request):
"""Raster preview frame as a writable numpy array (h, w, 4) uint8."""
return request.make_array("main")
def capture_metadata(self):
return self.picam2.capture_metadata()
def stream_config(self, name):
try:
return self.picam2.stream_configuration(name)
except Exception:
return None
# --------------------------------------------------------------- controls
def control_ranges(self):
"""Ranges for the controls the settings panel exposes.
picamera2 reports ``camera_controls`` as {name: (min, max, default)};
anything the sensor/IPA does not offer is simply left out so the panel
can hide that row instead of offering a slider that does nothing.
"""
wanted = (
"ExposureTime", "AnalogueGain", "Sharpness", "Contrast",
"Saturation", "Brightness", "ColourGains", "FrameDurationLimits",
)
ranges = {}
try:
available = self.picam2.camera_controls
except Exception:
return ranges
for name in wanted:
if name in available:
low, high, default = available[name]
ranges[name] = {"min": low, "max": high, "default": default}
return ranges
def apply_controls(self, controls):
"""Apply a control dict; called from the camera thread only."""
if not controls:
return
try:
self.picam2.set_controls(controls)
log.info("controls applied: %s", controls)
except Exception:
log.exception("could not apply controls: %s", controls)
def frame_duration_limits(self, fps):
"""FrameDurationLimits tuple pinning the sensor to *fps*."""
frame_us = int(1_000_000 / max(1, int(fps)))
return (frame_us, frame_us)
def relaxed_frame_duration_limits(self, exposure_us):
"""Frame-duration window that admits a manual exposure of *exposure_us*.
The recording pipeline pins FrameDurationLimits to 1/target_fps; a
longer manual exposure would just be clamped by that pin, so the window
has to be widened. It is clamped to what the sensor actually offers
(IMX477 reports roughly 33 ms .. 120 ms here) because libcamera rejects
out-of-range control values outright.
"""
info = self.control_ranges().get("FrameDurationLimits")
if not info:
return None
low = int(info["min"])
high = int(info["max"])
wanted = int(max(low, exposure_us))
return (min(wanted, high), high)
def raw_geometry(self):
"""Describe the raw stream for the session index."""
cfg = self.stream_config("raw")
if not cfg:
return None
return {
"format": cfg.get("format"),
"width": cfg.get("size", (None, None))[0],
"height": cfg.get("size", (None, None))[1],
"stride": cfg.get("stride"),
"frame_bytes": cfg.get("framesize"),
}
@property
def mode(self):
return self._mode

47
config.json Normal file
View File

@@ -0,0 +1,47 @@
{
"archive_min_free_mb": 500,
"device_label": "MICROSCOPE",
"fs_uuid": "caf4320b-0fa3-4674-bfa9-f17f6165f681",
"mount_point": "/mnt/microscope",
"photo": {
"black_level": 256,
"buffer_count": 2,
"height": 3040,
"jpeg_quality": 95,
"settle_frames": 3,
"tiff_apply_colour_matrix": true,
"tiff_apply_gamma": true,
"tiff_apply_white_balance": true,
"white_level": 4095,
"width": 4056,
"write_dng": true,
"write_jpeg": true,
"write_tiff": true
},
"preview": {
"format": "XRGB8888",
"height": 600,
"width": 1024
},
"record": {
"buffer_count": 12,
"index_stride": 1,
"limit_frame_duration": true,
"target_fps": 30,
"write_queue": 8
},
"sensor": {
"bit_depth": 12,
"height": 1520,
"sensor_fps": 40,
"width": 2028
},
"ui": {
"fullscreen": true,
"hide_cursor": true,
"preview_refresh_ms": 33,
"rotation": 0,
"status_refresh_ms": 250,
"storage_poll_ms": 1000
}
}

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]

80
install.sh Executable file
View File

@@ -0,0 +1,80 @@
#!/bin/bash
# Install the microscope camera kiosk and its storage plumbing onto this machine.
#
# This is the scripted form of what was originally set up by hand; running it on
# a fresh Raspberry Pi OS install should reproduce the whole system:
#
# sudo ./install.sh
#
# Everything it writes is listed in the README under "System integration".
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PREFIX="${PREFIX:-/opt/microscope-camera}"
MOUNT_POINT="${MOUNT_POINT:-/mnt/microscope}"
if [[ $EUID -ne 0 ]]; then
echo "run me with sudo" >&2
exit 1
fi
echo "==> packages"
apt-get update
apt-get install -y --no-install-recommends \
cage python3-gi python3-gi-cairo gir1.2-gtk-3.0 python3-cairo \
python3-picamera2 python3-pidng python3-simplejpeg python3-tifffile \
python3-pil python3-numpy grim
echo "==> application -> $PREFIX"
mkdir -p "$PREFIX"
install -m 0644 "$HERE/app.py" "$HERE/camera.py" "$HERE/config.py" \
"$HERE/photos.py" "$HERE/recorder.py" "$HERE/storage.py" \
"$HERE/README.md" "$PREFIX/"
if [[ ! -f "$PREFIX/config.json" ]]; then
install -m 0644 "$HERE/config.json" "$PREFIX/"
fi
mkdir -p "$PREFIX/tools"
install -m 0755 "$HERE/tools/unpack_raw.py" "$PREFIX/tools/"
chown -R tyler:tyler "$PREFIX"
echo "==> SSD mount helper"
install -m 0755 "$HERE/system/microscope-ssd" /usr/local/sbin/microscope-ssd
install -m 0755 "$HERE/system/microscope-boot-check" /usr/local/sbin/microscope-boot-check
echo "==> sudoers"
install -m 0440 -o root -g root "$HERE/system/sudoers-microscope-ssd" \
/etc/sudoers.d/microscope-ssd
echo "==> udev rule"
install -m 0644 "$HERE/system/99-microscope-ssd.rules" \
/etc/udev/rules.d/99-microscope-ssd.rules
echo "==> systemd units"
install -m 0644 "$HERE/system/microscope-camera.service" \
"$HERE/system/microscope-ssd-mount.service" \
"$HERE/system/microscope-boot-check.service" \
/etc/systemd/system/
echo "==> fstab (mount the capture SSD at boot when present)"
if ! grep -q "$MOUNT_POINT" /etc/fstab; then
mkdir -p "$MOUNT_POINT"
UUID="$(blkid -s UUID -o value "$(lsblk -no PATH,LABEL | awk '$2=="MICROSCOPE"{print $1; exit}')" 2>/dev/null || true)"
if [[ -n "${UUID:-}" ]]; then
printf '\n# Microscope capture SSD - mounts automatically when plugged in\n'
printf 'UUID=%s %s ext4 nofail,noatime,errors=remount-ro,x-systemd.device-timeout=5 0 2\n' \
"$UUID" "$MOUNT_POINT" >> /etc/fstab
else
echo " no MICROSCOPE-labelled partition found; add the fstab line by hand" >&2
fi
fi
echo "==> enable services"
systemctl daemon-reload
udevadm control --reload-rules
systemctl enable microscope-camera.service microscope-boot-check.service
systemctl restart microscope-camera.service
echo
echo "done. The kiosk should now be on the display."
echo " systemctl status microscope-camera.service"
echo " journalctl -u microscope-camera.service -f"

443
photos.py Normal file
View File

@@ -0,0 +1,443 @@
"""Full-resolution still capture: DNG + 16-bit TIFF + JPEG.
The IMX477 is a 12-bit sensor and the Pi's ISP only outputs 8-bit processed
images, so a genuinely 16-bit TIFF cannot come from the ISP. Instead the TIFF is
built from the raw Bayer frame through the same chain a raw converter would use:
unpack 12-bit MIPI -> black-level subtract -> white balance
-> colour-correction matrix -> bilinear demosaic -> sRGB gamma -> 16-bit
The result is a normal, viewable 16-bit image rather than linear sensor data.
The untouched sensor data stays in the DNG next to it, so nothing is lost for
quantitative work.
The slow part (demosaic + 74 MB TIFF write) runs on a worker thread so the live
preview resumes as soon as the capture itself is done.
"""
import datetime as _dt
import json
import logging
import os
import queue
import threading
import time
import numpy as np
log = logging.getLogger("photos")
_PATTERN_OFFSETS = {
# pattern -> (row parity, col parity) of the red sample in the 2x2 tile
"RGGB": (0, 0),
"BGGR": (1, 1),
"GRBG": (0, 1),
"GBRG": (1, 0),
}
def pattern_from_format(fmt: str) -> str:
"""'SRGGB12_CSI2P' -> 'RGGB'."""
if not fmt:
return "RGGB"
text = fmt.upper()
if text.startswith("S"):
text = text[1:]
return text[:4] if text[:4] in _PATTERN_OFFSETS else "RGGB"
def unpack_mipi12(packed: np.ndarray, width: int, height: int, stride: int) -> np.ndarray:
"""Unpack MIPI CSI-2 packed 12-bit Bayer into a uint16 (height, width) array.
Two pixels live in three bytes with the *first* pixel in the high bits:
byte0 byte1 byte2
p0[11:4] p0[3:0] p1[11:8] p1[7:0]
so even pixels are ``(b0 << 4) | (b1 >> 4)`` and odd pixels are
``((b1 & 0x0F) << 8) | b2``. Getting this order wrong still yields values in
the 0-4095 range, but it destroys the image: the tell-tale is that the two
green sites of the Bayer tile stop matching each other.
"""
rows = packed.reshape(height, stride)
useful = rows[:, : (width * 3) // 2]
triplets = useful.reshape(height, width // 2, 3).astype(np.uint16)
b0 = triplets[:, :, 0]
b1 = triplets[:, :, 1]
b2 = triplets[:, :, 2]
even = (b0 << 4) | (b1 >> 4)
odd = ((b1 & 0x0F) << 8) | b2
out = np.empty((height, width), dtype=np.uint16)
out[:, 0::2] = even
out[:, 1::2] = odd
return out
def demosaic(raw: np.ndarray, pattern: str = "BGGR") -> np.ndarray:
"""Bilinear demosaic of a Bayer plane into a uint16 (h, w, 3) RGB image.
Quantisation-free for 12-bit input (max 4095) and cheap enough (~1 s at
12 MP) to run on the Pi.
"""
rp, cp = _PATTERN_OFFSETS.get(pattern, (0, 0))
h, w = raw.shape
tiles = raw.reshape(h // 2, 2, w // 2, 2).transpose(0, 2, 1, 3)
red = tiles[..., rp, cp].astype(np.uint32)
blue = tiles[..., 1 - rp, 1 - cp].astype(np.uint32)
green_r = tiles[..., rp, 1 - cp].astype(np.uint32)
green_b = tiles[..., 1 - rp, cp].astype(np.uint32)
def padded(plane):
return np.pad(plane, 1, mode="edge")
def upsample(plane):
p = padded(plane)
center = p[1:-1, 1:-1]
left = p[1:-1, :-2]
right = p[1:-1, 2:]
up = p[:-2, 1:-1]
down = p[2:, 1:-1]
horiz = (center + (right if cp == 0 else left)) // 2
vert = (center + (down if rp == 0 else up)) // 2
col_n = right if cp == 0 else left
row_n = down if rp == 0 else up
if cp == 0 and rp == 0:
corner = p[2:, 2:]
elif cp == 0 and rp == 1:
corner = p[2:, :-2]
elif cp == 1 and rp == 0:
corner = p[:-2, 2:]
else:
corner = p[:-2, :-2]
diag = (center + col_n + row_n + corner) // 4
full = np.empty((h, w), dtype=np.uint16)
full[rp::2, cp::2] = center
full[rp::2, (1 - cp)::2] = horiz
full[(1 - rp)::2, cp::2] = vert
full[(1 - rp)::2, (1 - cp)::2] = diag
return full
red_full = upsample(red)
blue_full = upsample(blue)
g1 = padded(green_r)
g2 = padded(green_b)
g1_left = (g1[1:-1, 1:-1] + g1[1:-1, :-2]) // 2
g2_right = (g2[1:-1, 1:-1] + g2[1:-1, 2:]) // 2
green_full = np.empty((h, w), dtype=np.uint16)
green_full[rp::2, 1 - cp::2] = green_r
green_full[1 - rp::2, cp::2] = green_b
green_full[rp::2, cp::2] = g1_left
green_full[1 - rp::2, 1 - cp::2] = g2_right
rgb = np.empty((h, w, 3), dtype=np.uint16)
rgb[..., 0] = red_full
rgb[..., 1] = green_full
rgb[..., 2] = blue_full
return rgb
def srgb_encode(linear: np.ndarray) -> np.ndarray:
"""Apply the sRGB transfer function (linear light -> display values)."""
out = np.empty_like(linear)
low = linear <= 0.0031308
np.multiply(linear, 12.92, out=out, where=low)
high = ~low
np.power(np.clip(linear, 1e-8, None), 1.0 / 2.4, out=out, where=high)
np.multiply(out, 1.055, out=out, where=high)
np.subtract(out, 0.055, out=out, where=high)
return out
def render_still(raw, pattern, black=256.0, white=4095.0, gains=None, ccm=None,
gamma=True) -> np.ndarray:
"""Turn a 12-bit Bayer frame into a viewable 16-bit RGB image.
Mirrors what a raw converter does, in the order the ISP does it:
black-level subtract, white balance, colour matrix, demosaic, gamma.
``gains`` is picamera2's ``ColourGains`` (red, blue; green is 1.0) and
``ccm`` is its ``ColourCorrectionMatrix`` (3x3, camera RGB -> sRGB). Either
may be None, in which case that step is skipped.
"""
rgb = demosaic(raw, pattern)
img = rgb.astype(np.float32)
del rgb
scale = 1.0 / float(white - black)
img *= scale
img -= black * scale
np.clip(img, 0.0, None, out=img)
if gains is not None:
img[..., 0] *= float(gains[0])
img[..., 2] *= float(gains[1])
if ccm is not None:
matrix = np.asarray(ccm, dtype=np.float32).reshape(3, 3)
img = img @ matrix.T
np.clip(img, 0.0, None, out=img)
# Highlight clipping happens here, as it does in the ISP.
np.clip(img, 0.0, 1.0, out=img)
if gamma:
img = srgb_encode(img)
return np.clip(img * 65535.0 + 0.5, 0, 65535).astype(np.uint16)
def _rotate_array(rgb, rotation):
"""Rotate an (h, w, 3) array clockwise by 0/90/180/270 degrees."""
rotation = int(rotation) % 360
if rotation == 0:
return rgb
if rotation == 90:
return np.ascontiguousarray(np.rot90(rgb, k=3))
if rotation == 180:
return np.ascontiguousarray(np.rot90(rgb, k=2))
if rotation == 270:
return np.ascontiguousarray(np.rot90(rgb, k=1))
return rgb
def _pil_transpose(rotation):
"""PIL constant that rotates an image clockwise by *rotation* degrees.
PIL's ROTATE_* constants turn counter-clockwise, hence the inversion.
"""
from PIL import Image
transpose = getattr(Image, "Transpose", Image)
return {90: transpose.ROTATE_270, 180: transpose.ROTATE_180,
270: transpose.ROTATE_90}[int(rotation) % 360]
def rotate_jpeg(path, rotation):
"""Rotate a JPEG on disk in place (used for the ISP-written still)."""
rotation = int(rotation) % 360
if rotation == 0:
return False
from PIL import Image
with Image.open(path) as image:
turned = image.transpose(_pil_transpose(rotation))
turned.save(path, quality=95, subsampling=0)
return True
def set_dng_orientation(path, rotation):
"""Write the DNG/TIFF Orientation tag (274) in place.
Rotating the Bayer mosaic itself would change the sensor pattern, so the raw
DNG keeps its pixels and records the rotation the way every raw converter
expects: as metadata. Orientation 1/3/6/8 = 0/180/90/270 degrees.
"""
value = {0: 1, 90: 6, 180: 3, 270: 8}.get(int(rotation) % 360)
if value in (None, 1):
return False
try:
with open(path, "r+b") as handle:
header = handle.read(8)
if header[:2] not in (b"II", b"MM"):
return False
order = "little" if header[:2] == b"II" else "big"
ifd_offset = int.from_bytes(header[4:8], order)
handle.seek(ifd_offset)
count = int.from_bytes(handle.read(2), order)
entries = handle.read(12 * count)
for index in range(count):
entry = entries[index * 12:(index + 1) * 12]
tag = int.from_bytes(entry[0:2], order)
if tag == 274:
handle.seek(ifd_offset + 2 + index * 12 + 8)
handle.write(value.to_bytes(2, order) + b"\x00\x00")
return True
except OSError:
log.exception("could not set DNG orientation on %s", path)
return False
class TiffWorker(threading.Thread):
"""Serial background worker for the expensive demosaic + TIFF write."""
def __init__(self, on_done=None):
super().__init__(name="tiff-writer", daemon=True)
self._jobs = queue.Queue()
self._on_done = on_done
def submit(self, job):
self._jobs.put(job)
def stop(self, timeout=90):
"""Finish any queued job, then end the thread."""
if self.is_alive():
self._jobs.put(None)
self.join(timeout=timeout)
def run(self):
while True:
job = self._jobs.get()
if job is None:
log.info("TIFF worker stopping")
return
try:
self._do(job)
except Exception:
log.exception("TIFF job failed")
if self._on_done:
self._on_done(job, None, "TIFF failed (see log)")
def _do(self, job):
import tifffile
started = time.monotonic()
raw = unpack_mipi12(job["packed"], job["width"], job["height"], job["stride"])
rgb = render_still(
raw,
job["pattern"],
black=job.get("black_level", 256.0),
white=job.get("white_level", 4095.0),
gains=job.get("gains") if job.get("apply_white_balance", True) else None,
ccm=job.get("ccm") if job.get("apply_ccm", True) else None,
gamma=job.get("gamma", True),
)
del raw
rotation = int(job.get("rotation", 0) or 0)
if rotation:
rgb = _rotate_array(rgb, rotation)
description = (
"16-bit RGB from the 12-bit Bayer frame; "
f"pattern={job['pattern']} black={job.get('black_level')} "
f"white={job.get('white_level')} "
f"white_balance={job.get('gains') if job.get('apply_white_balance', True) else None} "
f"colour_matrix={'applied' if job.get('apply_ccm', True) and job.get('ccm') else 'none'} "
f"transfer={'sRGB' if job.get('gamma', True) else 'linear'}; "
"raw sensor data preserved in the DNG alongside."
)
tifffile.imwrite(
job["path"], rgb, photometric="rgb",
description=description, compression=None,
)
elapsed = time.monotonic() - started
log.info("TIFF written in %.1fs: %s", elapsed, job["path"])
if self._on_done:
self._on_done(job, elapsed, None)
def capture_still(camera, storage, cfg, tiff_worker, on_tiff_done=None, rotation=0):
"""Capture one full-resolution still. Must run on the camera thread.
*rotation* (clockwise degrees) is applied to the saved output: the JPEG is
turned on disk, the TIFF is turned before it is written, and the DNG carries
it as an Orientation tag (its Bayer data must stay sensor-native).
"""
photo_cfg = cfg["photo"]
rotation = int(rotation or 0) % 360
session = storage.new_session("photo")
raw_dir = storage.format_dir(session, "raw")
serial = storage.next_serial(raw_dir, "still")
stem = "still_%04d" % serial
t_switch = time.monotonic()
camera.switch_to_still()
settle = max(0, int(photo_cfg.get("settle_frames", 3)))
for _ in range(settle):
stale = camera.capture_request()
stale.release()
switch_seconds = time.monotonic() - t_switch
t_capture = time.monotonic()
request = camera.capture_request()
metadata = request.get_metadata()
written = {}
try:
if photo_cfg.get("write_dng", True):
path = raw_dir / (stem + ".dng")
request.save_dng(str(path), "raw")
if rotation:
set_dng_orientation(str(path), rotation)
written["dng"] = str(path)
if photo_cfg.get("write_jpeg", True):
jpeg_dir = storage.format_dir(session, "jpeg")
path = jpeg_dir / (stem + ".jpg")
request.save("main", str(path))
if rotation:
rotate_jpeg(str(path), rotation)
written["jpeg"] = str(path)
packed = None
if photo_cfg.get("write_tiff", True):
raw_cfg = camera.stream_config("raw") or {}
packed = np.array(request.make_buffer("raw"), dtype=np.uint8)
width = int(raw_cfg.get("size", (photo_cfg["width"], photo_cfg["height"]))[0])
height = int(raw_cfg.get("size", (photo_cfg["width"], photo_cfg["height"]))[1])
stride = int(raw_cfg.get("stride") or (width * 3 // 2))
pattern = pattern_from_format(raw_cfg.get("format", "SRGGB12"))
ccm = metadata.get("ColourCorrectionMatrix")
tiff_dir = storage.format_dir(session, "tiff")
job = {
"packed": packed,
"width": width,
"height": height,
"stride": stride,
"pattern": pattern,
"gains": metadata.get("ColourGains"),
"apply_white_balance": photo_cfg.get("tiff_apply_white_balance", True),
"ccm": list(ccm) if ccm is not None else None,
"apply_ccm": photo_cfg.get("tiff_apply_colour_matrix", True),
"gamma": photo_cfg.get("tiff_apply_gamma", True),
"black_level": photo_cfg.get("black_level", 256),
"white_level": photo_cfg.get("white_level", 4095),
"rotation": rotation,
"path": str(tiff_dir / (stem + ".tiff")),
"stem": stem,
"session": str(session),
}
tiff_worker.submit(job)
written["tiff"] = job["path"]
finally:
request.release()
camera.switch_to_video()
summary = {
"kind": "photo",
"stem": stem,
"session": str(session),
"taken": _dt.datetime.now().astimezone().isoformat(timespec="milliseconds"),
"files": {k: storage.relative(v) for k, v in written.items()},
"mode": "4056x3040",
"rotation": rotation,
"capture_ms": round((time.monotonic() - t_capture) * 1000),
"switch_ms": round(switch_seconds * 1000),
"exposure_us": metadata.get("ExposureTime"),
"gain": metadata.get("AnalogueGain"),
"awb": list(metadata.get("ColourGains")) if metadata.get("ColourGains") else None,
"colour_correction_matrix": list(metadata["ColourCorrectionMatrix"])
if metadata.get("ColourCorrectionMatrix") is not None else None,
"black_level": photo_cfg.get("black_level", 256),
"white_level": photo_cfg.get("white_level", 4095),
"sensor_temperature": metadata.get("SensorTemperature"),
"notes": [
"DNG holds the untouched 12-bit Bayer frame.",
"TIFF is 16-bit sRGB, demosaiced from that Bayer frame with white balance",
"and the colour-correction matrix applied.",
"JPEG is the ISP-processed 8-bit image.",
"rotation is the clockwise display/export rotation in degrees.",
],
}
_write_json(session / "photo.json", summary)
return summary
def _write_json(path, payload):
tmp = str(path) + ".tmp"
try:
with open(tmp, "w", encoding="utf-8") as handle:
json.dump(payload, handle, indent=2, default=str)
handle.write("\n")
os.replace(tmp, path)
except OSError:
log.exception("could not write %s", path)

314
recorder.py Normal file
View File

@@ -0,0 +1,314 @@
"""Raw frame recorder.
Frames come straight off the sensor's raw stream as MIPI-packed 12-bit Bayer
(``SBGGR12_CSI2P``) and are written to disk verbatim: no debayer, no
compression, no loss. One camera buffer per file.
Writes happen on a dedicated thread so the capture loop never blocks on the
SSD. The bounded queue plus the camera's own buffer count provide backpressure;
if the writer falls behind (the SSD sustains ~156 MB/s, i.e. ~29 fps at
2028x1520x12-bit) frames are dropped and counted, never silently reordered.
"""
import json
import logging
import os
import queue
import threading
import time
log = logging.getLogger("recorder")
def _write_raw_buffer(request, path):
"""Write the raw stream buffer to *path*; return (bytes_written, geometry)."""
config = request.config.get("raw", {})
try:
# Zero-copy path: borrow the camera's mapped buffer and write it out.
from picamera2.request import _MappedBuffer
except ImportError: # pragma: no cover - older picamera2
_MappedBuffer = None
if _MappedBuffer is not None:
with _MappedBuffer(request, "raw", write=False) as buf:
view = memoryview(buf)
with open(path, "wb") as handle:
handle.write(view)
return view.nbytes, config
data = request.make_buffer("raw")
with open(path, "wb") as handle:
handle.write(data)
return int(data.nbytes), config
class RawRecorder:
"""Owns the writer thread and the session index for one recording."""
def __init__(self, storage, cfg, on_state_change=None):
self.storage = storage
self.cfg = cfg
self.on_state_change = on_state_change
self.sensor_fps = float(cfg.get("sensor.sensor_fps", 40))
self.frame_period_ns = int(1e9 / self.sensor_fps)
self.target_fps = int(cfg.get("record.target_fps", 30))
self.min_free_mb = int(cfg.get("archive_min_free_mb", 500))
self._queue = queue.Queue(maxsize=int(cfg.get("record.write_queue", 8)))
self._thread = None
self._active = False
self._stop_requested = threading.Event()
self._lock = threading.Lock()
self.session_dir = None
self.rotation = 0
self.stop_reason = None
self._raw_geometry = None
self._index_handle = None
self._start_monotonic = None
self._start_iso = None
self._stats = {}
# ------------------------------------------------------------------ state
@property
def active(self) -> bool:
return self._active
def stats(self):
with self._lock:
data = dict(self._stats)
if data and self._start_monotonic:
elapsed = max(1e-6, time.monotonic() - self._start_monotonic)
data["elapsed"] = elapsed
data["actual_fps"] = data.get("frames", 0) / elapsed
data["mb_per_s"] = data.get("bytes", 0) / elapsed / 1e6
return data
# ------------------------------------------------------------------ start
def start(self, session_dir, rotation=0):
if self._active:
return None
session_dir = os.fspath(session_dir)
os.makedirs(os.path.join(session_dir, "raw"), exist_ok=True)
self.session_dir = session_dir
# Clockwise display rotation in degrees. Raw Bayer frames are stored
# sensor-native, so the rotation is recorded here for whatever reads
# them later (tools/unpack_raw.py applies it and also bakes it in when
# writing .npy/.tiff/.png output).
self.rotation = int(rotation or 0) % 360
self.stop_reason = None
self._stop_requested.clear()
self._start_monotonic = time.monotonic()
self._start_iso = _iso_now()
self._stats = {
"frames": 0, "bytes": 0, "dropped": 0, "missed": 0,
"first_sensor_ts": None, "last_sensor_ts": None, "started": self._start_iso,
}
self._index_handle = open(os.path.join(session_dir, "index.jsonl"), "w", encoding="utf-8")
self._write_session_json(final=False)
self._active = True
self._thread = threading.Thread(target=self._writer_loop, name="raw-writer", daemon=True)
self._thread.start()
log.info("recording started: %s (target %d fps)", session_dir, self.target_fps)
if self.on_state_change:
self.on_state_change()
return session_dir
# ----------------------------------------------------------------- submit
def submit(self, request):
"""Hand a completed request to the writer. Takes ownership of *request*."""
if not self._active:
request.release()
return
try:
self._queue.put_nowait(request)
except queue.Full:
with self._lock:
self._stats["dropped"] += 1
request.release()
# ------------------------------------------------------------------- stop
def stop(self, reason=None):
if not self._active:
return self.stats()
self._active = False
self.stop_reason = reason or "stopped"
self._stop_requested.set()
thread, self._thread = self._thread, None
if thread is not None:
thread.join(timeout=30)
# Anything still queued was never written; count it honestly.
leftovers = 0
while True:
try:
request = self._queue.get_nowait()
except queue.Empty:
break
leftovers += 1
request.release()
if leftovers:
with self._lock:
self._stats["dropped"] += leftovers
if self._index_handle is not None:
self._index_handle.close()
self._index_handle = None
self._write_session_json(final=True)
summary = self.stats()
log.info("recording stopped (%s): %d frames, %.1f MB, %d dropped, %.1f fps",
self.stop_reason, summary.get("frames", 0), summary.get("bytes", 0) / 1e6,
summary.get("dropped", 0), summary.get("actual_fps", 0.0))
if self.on_state_change:
self.on_state_change()
return summary
# ------------------------------------------------------------ writer side
def _writer_loop(self):
free_check_countdown = 15 # ~0.5s at 30fps
while True:
try:
request = self._queue.get(timeout=0.5)
except queue.Empty:
if self._stop_requested.is_set():
break
continue
try:
self._write_one(request)
except Exception: # a bad frame must not kill the recording
log.exception("failed writing frame")
with self._lock:
self._stats["dropped"] += 1
finally:
request.release()
free_check_countdown -= 1
if free_check_countdown <= 0:
free_check_countdown = 15
reason = self._check_environment()
if reason:
self.stop_reason = reason
self._active = False
self._stop_requested.set()
break
if self._stop_requested.is_set() and self._queue.empty():
break
def _check_environment(self):
if not self.storage.is_mounted():
log.warning("SSD disappeared while recording")
return "ssd removed"
free_mb = self.storage.free_mb()
if free_mb and free_mb < self.min_free_mb:
log.warning("SSD below minimum free space (%d MB)", free_mb)
return "disk full"
return None
def _write_one(self, request):
metadata = request.get_metadata()
with self._lock:
stats = self._stats
frame_no = stats["frames"] + 1
missed = 0
sensor_ts = metadata.get("SensorTimestamp")
# Judge gaps against the duration the sensor is actually running at,
# which auto exposure can change; assuming the mode's maximum rate
# would report a "missed" frame on every single frame in dim light.
period_ns = self.frame_period_ns
frame_duration_us = metadata.get("FrameDuration")
if frame_duration_us:
period_ns = max(1, int(float(frame_duration_us) * 1000))
if sensor_ts is not None:
last = stats["last_sensor_ts"]
if last is not None and sensor_ts > last:
gap = sensor_ts - last
missed = max(0, int(round(gap / period_ns)) - 1)
stats["last_sensor_ts"] = sensor_ts
if stats["first_sensor_ts"] is None:
stats["first_sensor_ts"] = sensor_ts
stats["missed"] += missed
filename = "frame_%06d.raw" % frame_no
path = os.path.join(self.session_dir, "raw", filename)
written, raw_cfg = _write_raw_buffer(request, path)
elapsed = time.monotonic() - self._start_monotonic
record = {
"frame": frame_no,
"file": "raw/" + filename,
"t": round(elapsed, 6),
"sensor_ts": sensor_ts,
"exposure_us": metadata.get("ExposureTime"),
"gain": _round(metadata.get("AnalogueGain")),
"awb": _round_pair(metadata.get("ColourGains")),
"lux": _round(metadata.get("Lux")),
"bytes": written,
"missed_before": missed,
}
if frame_no % int(self.cfg.get("record.index_stride", 1)) == 0:
self._index_handle.write(json.dumps(record, separators=(",", ":")) + "\n")
with self._lock:
self._stats["frames"] = frame_no
self._stats["bytes"] += written
return raw_cfg
# --------------------------------------------------------------- metadata
def set_raw_geometry(self, geometry):
"""Remember the raw stream geometry so session.json can describe it."""
self._raw_geometry = geometry
def _write_session_json(self, final: bool):
summary = self.stats()
payload = {
"kind": "record",
"started": self._start_iso,
"ended": _iso_now() if final else None,
"stop_reason": self.stop_reason if final else None,
"sensor": self.cfg.get("sensor", {}),
"raw": getattr(self, "_raw_geometry", None),
"target_fps": self.target_fps,
"rotation": getattr(self, "rotation", 0),
"frames": summary.get("frames", 0),
"bytes": summary.get("bytes", 0),
"dropped": summary.get("dropped", 0),
"missed": summary.get("missed", 0),
"actual_fps": round(summary.get("actual_fps", 0.0), 3),
"mb_per_s": round(summary.get("mb_per_s", 0.0), 2),
"elapsed_s": round(summary.get("elapsed", 0.0), 3),
"layout": "date/HH-MM-SS/<format>/",
"frame_orientation": "sensor-native; apply 'rotation' clockwise for display",
"notes": [
"Frames are MIPI-packed 12-bit Bayer, one file each, no processing.",
"'missed' counts sensor frames that never made it to disk.",
"Use tools/unpack_raw.py on a desktop to unpack to 16-bit .npy/.tiff.",
],
}
path = os.path.join(self.session_dir, "session.json")
tmp = path + ".tmp"
try:
with open(tmp, "w", encoding="utf-8") as handle:
json.dump(payload, handle, indent=2)
handle.write("\n")
os.replace(tmp, path)
except OSError:
log.exception("could not write %s", path)
def _iso_now():
import datetime
return datetime.datetime.now().astimezone().isoformat(timespec="milliseconds")
def _round(value, digits=4):
try:
return round(float(value), digits)
except (TypeError, ValueError):
return None
def _round_pair(value):
if not value:
return None
try:
return [round(float(value[0]), 4), round(float(value[1]), 4)]
except (TypeError, ValueError, IndexError):
return None

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()

View File

@@ -0,0 +1,7 @@
# Auto-mount the microscope capture SSD as soon as it is plugged in.
#
# The mount itself must happen in a systemd service, not in RUN: udev workers
# run without CAP_SYS_ADMIN, so mount(2) there fails with EPERM. The service is
# oneshot *without* RemainAfterExit so it goes inactive again after each run and
# a later replug can start it afresh.
ACTION=="add", SUBSYSTEM=="block", ENV{ID_FS_UUID}=="caf4320b-0fa3-4674-bfa9-f17f6165f681", ENV{SYSTEMD_WANTS}+="microscope-ssd-mount.service", TAG+="systemd"

17
system/microscope-boot-check Executable file
View File

@@ -0,0 +1,17 @@
#!/bin/bash
# One-shot boot verification: proves the kiosk came up and the SSD mounted.
LOG=/home/tyler/boot-check.log
export XDG_RUNTIME_DIR=/run/user/1000 WAYLAND_DISPLAY=wayland-0
sleep 20
{
echo "=== boot check $(date -Is) ==="
echo "camera service : $(systemctl is-active microscope-camera.service) (enabled: $(systemctl is-enabled microscope-camera.service))"
echo "ssd mount : $(/usr/local/sbin/microscope-ssd status)"
echo "findmnt : $(findmnt -no SOURCE,TARGET,FSTYPE /mnt/microscope || echo 'not mounted')"
echo "cage : $(pgrep -a cage | head -1)"
echo "app : $(pgrep -af 'python3 app.py' | head -1)"
} >> "$LOG"
for d in HDMI-A-1 HDMI-A-2 DSI-1 DPI-1 HDMI-1; do
if grim -o "$d" /home/tyler/boot-check.png 2>>"$LOG"; then echo "screenshot : $d" >> "$LOG"; break; fi
done
chown tyler:tyler "$LOG" /home/tyler/boot-check.png 2>/dev/null

View File

@@ -0,0 +1,10 @@
[Unit]
Description=Microscope camera boot verification
After=microscope-camera.service
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/microscope-boot-check
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,35 @@
[Unit]
Description=Microscope camera kiosk (cage + capture UI)
Documentation=file:/opt/microscope-camera/README.md
After=systemd-user-sessions.service systemd-logind.service
Wants=systemd-logind.service
Conflicts=getty@tty1.service
After=getty@tty1.service
ConditionPathExists=/opt/microscope-camera/app.py
[Service]
Type=simple
User=tyler
Group=tyler
# A logind session on tty1 gives cage DRM master plus input device access.
PAMName=login
TTYPath=/dev/tty1
TTYReset=yes
TTYVHangup=yes
TTYVTDisallocate=yes
StandardInput=tty
StandardOutput=journal
StandardError=journal
Environment=XDG_RUNTIME_DIR=/run/user/1000
Environment=HOME=/home/tyler
Environment=PYTHONUNBUFFERED=1
ExecStartPre=/bin/sh -c 'install -d -m 700 -o tyler -g tyler /run/user/1000'
ExecStart=/usr/bin/cage -s -- /usr/bin/python3 /opt/microscope-camera/app.py
Restart=always
RestartSec=2
# Keep the recorder alive long enough to flush its last frames on restart.
KillSignal=SIGTERM
TimeoutStopSec=30
[Install]
WantedBy=multi-user.target

96
system/microscope-ssd Executable file
View File

@@ -0,0 +1,96 @@
#!/bin/bash
# Manage the microscope capture SSD mount.
#
# microscope-ssd mount mount the SSD (waits briefly for it to appear)
# microscope-ssd umount flush and unmount safely
# microscope-ssd status print: mounted | stale | unmounted | absent
#
# Called by the udev-triggered microscope-ssd-mount.service when the drive is
# plugged in, by fstab at boot, and by the kiosk UI (through a NOPASSWD sudoers
# entry) when the operator taps MOUNT SSD.
#
# stdout stays machine-readable (status) while everything also lands in a log
# file, because udev and systemd capture stdout in ways that are awkward to read.
set -u
MOUNT_POINT="${MICROSCOPE_MOUNT:-/mnt/microscope}"
UUID="${MICROSCOPE_UUID:-caf4320b-0fa3-4674-bfa9-f17f6165f681}"
OWNER="${MICROSCOPE_OWNER:-tyler:tyler}"
DEV="/dev/disk/by-uuid/${UUID}"
LOG_FILE="${MICROSCOPE_LOG:-/run/microscope-ssd.log}"
log() {
local message="microscope-ssd: $*"
echo "$message" >>"$LOG_FILE" 2>/dev/null || true
echo "$message"
}
in_mounts() { grep -qs " ${MOUNT_POINT} " /proc/self/mounts; }
do_status() {
# 'stale' means a mount entry survived a yank: the device node is gone.
if in_mounts; then
if [ -e "$DEV" ]; then echo mounted; else echo stale; fi
elif [ -e "$DEV" ]; then
echo unmounted
else
echo absent
fi
}
do_mount() {
mkdir -p "$MOUNT_POINT"
if in_mounts; then
if [ -e "$DEV" ]; then
log "already mounted"
return 0
fi
log "clearing stale mount entry"
umount -l "$MOUNT_POINT" 2>/dev/null || true
fi
for _ in $(seq 1 40); do
[ -e "$DEV" ] && break
sleep 0.25
done
if [ ! -e "$DEV" ]; then
log "SSD not present ($DEV)"
return 1
fi
if mount -t ext4 -o noatime,errors=remount-ro "$DEV" "$MOUNT_POINT"; then
chown "$OWNER" "$MOUNT_POINT" 2>/dev/null || true
chmod 755 "$MOUNT_POINT" 2>/dev/null || true
log "mounted $DEV at $MOUNT_POINT"
return 0
fi
log "mount failed"
return 1
}
do_umount() {
if ! in_mounts; then
log "not mounted"
return 0
fi
sync
if umount "$MOUNT_POINT"; then
log "unmounted (safe to unplug)"
return 0
fi
log "unmount failed - something is still using $MOUNT_POINT"
return 1
}
ACTION="${1:-status}"
case "$ACTION" in
mount|umount) log "--- $(date -Is) action=$ACTION euid=$(id -u) ---" ;;
esac
case "$ACTION" in
mount) do_mount ;;
umount) do_umount ;;
status) do_status ;;
*) echo "usage: $0 {mount|umount|status}" >&2; exit 2 ;;
esac

View File

@@ -0,0 +1,13 @@
[Unit]
Description=Mount the microscope capture SSD
Documentation=man:mount(8)
After=systemd-udevd.service
Before=local-fs.target
[Service]
# No RemainAfterExit: the unit returns to inactive after each run so that every
# subsequent plug-in event can start it again. The helper is idempotent, so an
# already-mounted drive is a no-op.
Type=oneshot
ExecStart=/usr/local/sbin/microscope-ssd mount
TimeoutStartSec=30

View File

@@ -0,0 +1 @@
tyler ALL=(root) NOPASSWD: /usr/local/sbin/microscope-ssd

365
tools/unpack_raw.py Normal file
View File

@@ -0,0 +1,365 @@
#!/usr/bin/env python3
"""Unpack the microscope camera's raw frames on a desktop machine.
Recording writes MIPI-packed 12-bit Bayer frames (``SBGGR12_CSI2P`` style: two
pixels in three bytes) straight off the sensor. This tool turns them into files
you can actually look at:
unpack_raw.py frame_000001.raw -> frame_000001.npy (uint16)
unpack_raw.py --png frame_000001.raw -> frame_000001.png (quick look)
unpack_raw.py --tiff16 frame_000001.raw -> frame_000001.tiff (16-bit mono)
unpack_raw.py --debayer --rgb16 frame_000001.raw-> frame_000001_rgb.tiff (16-bit RGB)
unpack_raw.py /mnt/microscope/2026-10-30/21-33-05 (whole session)
unpack_raw.py --info /mnt/microscope/2026-10-30/21-33-05
Geometry (width, height, stride, Bayer pattern) is picked up automatically from
the session's ``session.json``; if that is missing the tool falls back to the
file size and the command line defaults.
"""
import argparse
import json
import os
import sys
import numpy as np
PATTERNS = {
# pattern -> (row parity, col parity) of the red sample in the 2x2 tile
"RGGB": (0, 0),
"BGGR": (1, 1),
"GRBG": (0, 1),
"GBRG": (1, 0),
}
def pattern_from_format(fmt):
text = (fmt or "SRGGB12").upper()
if text.startswith("S"):
text = text[1:]
head = text[:4]
return head if head in PATTERNS else "RGGB"
def unpack_mipi12(packed, width, height, stride):
"""Unpack MIPI CSI-2 RAW12 (3 bytes -> 2 pixels).
Byte order matters: byte0 holds the low 8 bits of pixel 0, byte1 holds the
high 4 bits of pixel 0 in its HIGH nibble and the high 4 bits of pixel 1 in
its LOW nibble. Treating byte1's low nibble as pixel 0's high bits (and
its high nibble as pixel 1's) transposes the 12-bit words within each pair,
which scrambles the Bayer mosaic and produces colour-fringed output.
"""
rows = packed[: stride * height].reshape(height, stride)
useful = rows[:, : (width * 3) // 2]
triplets = useful.reshape(height, width // 2, 3).astype(np.uint16)
b0 = triplets[:, :, 0]
b1 = triplets[:, :, 1]
b2 = triplets[:, :, 2]
even = (b0 << 4) | (b1 >> 4)
odd = ((b1 & 0x0F) << 8) | b2
out = np.empty((height, width), dtype=np.uint16)
out[:, 0::2] = even
out[:, 1::2] = odd
return out
def demosaic(raw, pattern="RGGB"):
rp, cp = PATTERNS.get(pattern, (0, 0))
h, w = raw.shape
tiles = raw.reshape(h // 2, 2, w // 2, 2).transpose(0, 2, 1, 3)
red = tiles[..., rp, cp].astype(np.uint32)
blue = tiles[..., 1 - rp, 1 - cp].astype(np.uint32)
green_r = tiles[..., rp, 1 - cp]
green_b = tiles[..., 1 - rp, cp]
def upsample(plane):
p = np.pad(plane, 1, mode="edge")
center = p[1:-1, 1:-1]
left, right = p[1:-1, :-2], p[1:-1, 2:]
up, down = p[:-2, 1:-1], p[2:, 1:-1]
col_n = right if cp == 0 else left
row_n = down if rp == 0 else up
corner = p[2:, 2:] if (cp == 0 and rp == 0) else \
p[2:, :-2] if (cp == 0 and rp == 1) else \
p[:-2, 2:] if (cp == 1 and rp == 0) else p[:-2, :-2]
full = np.empty((h, w), dtype=np.uint16)
full[rp::2, cp::2] = center
full[rp::2, (1 - cp)::2] = (center + col_n) // 2
full[(1 - rp)::2, cp::2] = (center + row_n) // 2
full[(1 - rp)::2, (1 - cp)::2] = (center + col_n + row_n + corner) // 4
return full
red_full, blue_full = upsample(red), upsample(blue)
g1 = np.pad(green_r.astype(np.uint32), 1, mode="edge")
g2 = np.pad(green_b.astype(np.uint32), 1, mode="edge")
green_full = np.empty((h, w), dtype=np.uint16)
green_full[rp::2, (1 - cp)::2] = green_r
green_full[(1 - rp)::2, cp::2] = green_b
green_full[rp::2, cp::2] = (g1[1:-1, 1:-1] + g1[1:-1, :-2]) // 2
green_full[(1 - rp)::2, (1 - cp)::2] = (g2[1:-1, 1:-1] + g2[1:-1, 2:]) // 2
rgb = np.empty((h, w, 3), dtype=np.uint16)
rgb[..., 1] = green_full
return rgb, red_full, blue_full
def apply_rotation(image, rotation):
"""Rotate an array clockwise by 0/90/180/270 degrees."""
rotation = int(rotation or 0) % 360
if rotation == 0:
return image
if rotation == 90:
return np.ascontiguousarray(np.rot90(image, k=3))
if rotation == 180:
return np.ascontiguousarray(np.rot90(image, k=2))
if rotation == 270:
return np.ascontiguousarray(np.rot90(image, k=1))
return image
_PATTERN_TILE = {
"RGGB": (("R", "G"), ("G", "B")),
"BGGR": (("B", "G"), ("G", "R")),
"GRBG": (("G", "R"), ("B", "G")),
"GBRG": (("G", "B"), ("R", "G")),
}
def rotate_pattern(pattern, rotation):
"""CFA pattern after rotating the Bayer plane clockwise.
Rotating a mosaic without moving its pattern would put red samples where
blue belong, so a rotated .npy/.tiff must record the rotated pattern.
"""
steps = (int(rotation or 0) % 360) // 90
if not steps:
return pattern
tile = [list(row) for row in _PATTERN_TILE.get(pattern, _PATTERN_TILE["RGGB"])]
for _ in range(steps):
tile = [[tile[1][0], tile[0][0]], [tile[1][1], tile[0][1]]] # 90 deg clockwise
text = "".join(tile[0] + tile[1])
for name, known in _PATTERN_TILE.items():
if "".join(known[0] + known[1]) == text:
return name
return pattern
def find_session(path):
"""Walk up from *path* looking for session.json / photo.json."""
current = os.path.abspath(path if os.path.isdir(path) else os.path.dirname(path))
for _ in range(4):
for name in ("session.json", "photo.json"):
candidate = os.path.join(current, name)
if os.path.isfile(candidate):
return current, candidate
parent = os.path.dirname(current)
if parent == current:
break
current = parent
return None, None
def geometry_for(path, args):
width, height, stride, pattern = args.width, args.height, args.stride, args.pattern
session_dir, meta_file = find_session(path)
meta = {}
if meta_file:
try:
with open(meta_file, "r", encoding="utf-8") as handle:
meta = json.load(handle)
except (OSError, ValueError):
meta = {}
raw = meta.get("raw") or {}
if raw:
width = width or raw.get("width")
height = height or raw.get("height")
stride = stride or raw.get("stride")
pattern = args.pattern or pattern_from_format(raw.get("format"))
path_size = os.path.getsize(path)
height = height or 1520
if not width:
# packed 12-bit: bytes per row = width * 3/2
if stride:
width = int(stride * 2 / 3)
else:
width = 2028
if not stride:
stride = path_size // height
expected = width * height if args.unpacked else stride * height
if path_size < expected:
raise SystemExit(
"%s is %d bytes but %dx%d needs %d — pass --width/--height/--stride"
% (os.path.basename(path), path_size, width, height, expected)
)
rotation = args.rotate if args.rotate is not None else int(meta.get("rotation") or 0)
return width, height, stride, pattern, session_dir, meta, rotation
def contrast_stretch(image):
lo, hi = np.percentile(image, (0.5, 99.5))
if hi <= lo:
hi = lo + 1
scaled = np.clip((image.astype(np.float32) - lo) * (255.0 / (hi - lo)), 0, 255)
return scaled.astype(np.uint8)
def srgb_encode(rgb16):
"""Linear 16-bit RGB -> display-ready 8-bit sRGB.
Without this the frame looks flat and washed out, because linear sensor data
held in a 16-bit container has all its mid-tones crammed into the dark end.
"""
x = np.clip(rgb16.astype(np.float32) / 65535.0, 0.0, 1.0)
x = np.where(x <= 0.0031308, x * 12.92,
1.055 * np.power(x, 1.0 / 2.4) - 0.055)
return np.clip(x * 255.0 + 0.5, 0, 255).astype(np.uint8)
def white_balance(rgb, red_full, blue_full, gains):
"""Apply camera white balance. Only called when --white-balance is given:
gains above 1.0 clip at 65535, which loses highlight data."""
if not gains:
gains = (1.0, 1.0)
out = rgb
out[..., 0] = np.clip(red_full.astype(np.float32) * float(gains[0]), 0, 65535).astype(np.uint16)
out[..., 2] = np.clip(blue_full.astype(np.float32) * float(gains[1]), 0, 65535).astype(np.uint16)
return out
def process(path, args):
width, height, stride, pattern, session_dir, meta, rotation = geometry_for(path, args)
stem = os.path.splitext(os.path.basename(path))[0]
outdir = args.outdir or os.path.dirname(os.path.abspath(path))
os.makedirs(outdir, exist_ok=True)
payload = np.fromfile(path, dtype=np.uint8)
if args.unpacked:
image = payload[: width * height * 2].view(np.uint16).reshape(height, width)
else:
image = unpack_mipi12(payload, width, height, stride)
print("%-28s %dx%d %s stride=%d rotation=%d° max=%d mean=%.1f"
% (os.path.basename(path), width, height, pattern, stride, rotation,
int(image.max()), float(image.mean())))
# The session's rotation is the operator's display orientation, so honour it
# in everything this tool writes. Bayer exports are rotated *with* their CFA
# pattern; the RGB path is demosaiced first and then turned, which is
# equivalent and avoids re-deriving the pattern.
bayer = apply_rotation(image, rotation)
bayer_pattern = rotate_pattern(pattern, rotation)
made = []
if args.npy or not (args.png or args.tiff16 or args.rgb16):
out = os.path.join(outdir, stem + ".npy")
np.save(out, bayer)
made.append(out)
if args.png:
from PIL import Image
out = os.path.join(outdir, stem + ".png")
Image.fromarray(contrast_stretch(bayer)).save(out)
made.append(out)
if args.tiff16:
import tifffile
out = os.path.join(outdir, stem + ".tiff")
tifffile.imwrite(out, bayer,
description="16-bit unpacked Bayer %s (rotated %d deg clockwise)"
% (bayer_pattern, rotation))
made.append(out)
if args.rgb16:
import tifffile
gains = meta.get("awb")
# Demosaic in sensor orientation, fill all three channels, then rotate:
# rotating an RGB triple is equivalent to rotating the mosaic and
# re-deriving its CFA pattern, without the pattern bookkeeping.
rgb, red_full, blue_full = demosaic(image, pattern)
rgb[..., 0] = np.clip(red_full.astype(np.float32) * 16.0, 0, 65535).astype(np.uint16)
rgb[..., 2] = np.clip(blue_full.astype(np.float32) * 16.0, 0, 65535).astype(np.uint16)
rgb[..., 1] = np.clip(rgb[..., 1].astype(np.float32) * 16.0, 0, 65535).astype(np.uint16)
del red_full, blue_full, image
if rotation:
rgb = apply_rotation(rgb, rotation)
applied = False
if args.white_balance:
rgb = white_balance(rgb, rgb[..., 0], rgb[..., 2], gains)
applied = True
out = os.path.join(outdir, stem + "_rgb.tiff")
tifffile.imwrite(out, rgb, photometric="rgb",
description="linear 16-bit RGB, %s, rotation=%d deg clockwise, "
"white_balance_applied=%s camera_white_balance=%s"
% (pattern, rotation, applied, gains))
made.append(out)
for item in made:
print(" -> %s (%.1f MB)" % (item, os.path.getsize(item) / 1e6))
return made
def show_info(target):
session_dir, meta_file = find_session(target)
if not meta_file:
raise SystemExit("no session.json/photo.json found near %s" % target)
with open(meta_file, "r", encoding="utf-8") as handle:
meta = json.load(handle)
print(json.dumps(meta, indent=2))
def collect_inputs(paths, recursive):
files = []
for path in paths:
if os.path.isdir(path):
raw_dir = os.path.join(path, "raw")
scan = raw_dir if os.path.isdir(raw_dir) else path
for name in sorted(os.listdir(scan)):
if name.endswith(".raw"):
files.append(os.path.join(scan, name))
else:
files.append(path)
return files
def main():
parser = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("paths", nargs="+", help="raw files, session dirs, or date dirs")
parser.add_argument("--outdir", help="where to write results (default: next to input)")
parser.add_argument("--npy", action="store_true", help="write 16-bit .npy (default)")
parser.add_argument("--png", action="store_true", help="write 8-bit contrast-stretched PNG")
parser.add_argument("--tiff16", action="store_true", help="write 16-bit Bayer TIFF")
parser.add_argument("--rgb16", action="store_true", help="write 16-bit RGB TIFF (demosaiced)")
parser.add_argument("--debayer", action="store_true", help="alias for --rgb16")
parser.add_argument("--rotate", type=int, choices=(0, 90, 180, 270),
help="override the session's display rotation (degrees clockwise)")
parser.add_argument("--no-rotate", dest="rotate", action="store_const", const=0,
help="ignore the rotation recorded in the session metadata")
parser.add_argument("--white-balance", action="store_true",
help="apply the camera's colour gains to the RGB output "
"(clips highlights; off by default, gains are recorded anyway)")
parser.add_argument("--info", action="store_true", help="print session metadata and exit")
parser.add_argument("--pattern", choices=sorted(PATTERNS), help="Bayer pattern override")
parser.add_argument("--width", type=int, help="frame width override")
parser.add_argument("--height", type=int, help="frame height override")
parser.add_argument("--stride", type=int, help="row stride in bytes override")
parser.add_argument("--unpacked", action="store_true",
help="input is already unpacked 16-bit little-endian")
args = parser.parse_args()
if args.debayer:
args.rgb16 = True
if args.info:
for path in args.paths:
show_info(path)
return 0
files = collect_inputs(args.paths, recursive=True)
if not files:
raise SystemExit("no .raw files found")
total = 0
for path in files:
total += len(process(path, args))
print("done: %d file(s) from %d input(s)" % (total, len(files)))
return 0
if __name__ == "__main__":
sys.exit(main())