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.
233 lines
11 KiB
Markdown
233 lines
11 KiB
Markdown
# 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.
|