Files
Microscope-Display/app.py
Microscope Camera 34cd247dca 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.
2026-10-31 00:44:46 -04:00

1004 lines
38 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Microscope camera kiosk UI.
Full-screen live preview with two touch buttons:
REC / STOP – start/stop raw frame recording (packed 12-bit Bayer, 2028x1520)
PHOTO – full-resolution still (4056x3040) as DNG + 16-bit TIFF + JPEG
An SSD badge appears in the corner whenever the capture SSD is not mounted, and
recording refuses to start when it is missing so nothing lands on the SD card by
accident.
Run directly (``python3 app.py``) or through cage via the systemd unit.
"""
import logging
import os
import queue
import signal
import sys
import threading
import time
import numpy as np
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gdk, GLib, Gtk # noqa: E402 (must follow gi.require_version)
import cairo # noqa: E402
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from camera import MicroscopeCamera # noqa: E402
from config import Config # noqa: E402
from photos import TiffWorker, capture_still # noqa: E402
from recorder import RawRecorder # noqa: E402
from storage import Storage # noqa: E402
log = logging.getLogger("app")
CSS = b"""
window, .root { background-color: #000000; }
drawingarea { background-color: #000000; }
.status {
background-color: rgba(0,0,0,0.55);
color: #e6e6e6;
padding: 10px 16px;
border-radius: 10px;
font-size: 17px;
}
.status-dim { color: #9aa0a6; font-size: 15px; }
.status-rec { color: #ff6b6b; }
.badge {
background-color: rgba(176,0,32,0.92);
color: #ffffff;
padding: 14px 20px;
border-radius: 12px;
font-size: 22px;
font-weight: bold;
}
.badge-hint { font-size: 15px; font-weight: normal; }
.toast {
background-color: rgba(20,20,20,0.94);
color: #ffffff;
padding: 16px 24px;
border-radius: 14px;
font-size: 22px;
}
.toast-error { background-color: rgba(150,20,20,0.95); }
button {
background-image: none;
background-color: rgba(28,28,28,0.88);
color: #f2f2f2;
border: 2px solid #5f6368;
border-radius: 12px;
font-size: 20px;
font-weight: bold;
padding: 8px 18px;
text-shadow: none;
}
button.rec { border-color: #d93025; color: #ff8a80; }
button.rec.recording { background-color: #c62828; color: #ffffff; border-color: #ff8a80; }
button.photo { border-color: #1a73e8; color: #8ab4f8; }
button.mount { border-color: #f9ab00; color: #fdd663; font-size: 16px; }
button.rotate { border-color: #34a853; color: #81c995; }
button:disabled { color: #5f6368; border-color: #303134; background-color: rgba(20,20,20,0.7); }
/* ---- exposure / gain settings panel ---- */
.settings {
background-color: rgba(10,10,10,0.95);
border: 1px solid #3c4043;
border-radius: 14px;
padding: 12px 16px;
}
.settings-title { font-size: 17px; font-weight: bold; color: #e6e6e6; }
.settings-hint { font-size: 13px; color: #9aa0a6; }
.setting-name { font-size: 14px; color: #c8c8c8; }
.setting-value { font-size: 14px; color: #8ab4f8; font-family: monospace; }
scale trough { min-height: 12px; min-width: 120px; border-radius: 6px; background-color: #303134; }
scale highlight { border-radius: 6px; background-color: #1a73e8; }
scale slider { min-width: 26px; min-height: 26px; border-radius: 13px;
background-color: #e8eaed; border: 1px solid #5f6368; }
.pill-hint { font-size: 13px; color: #8ab4f8; }
.pill-shut { font-size: 13px; color: #6b7075; }
"""
class PreviewArea(Gtk.DrawingArea):
"""Draws the newest camera frame, letterboxed into the window.
Rotation is applied to the raster here (not by the camera), so the button
responds instantly and recording keeps its full frame rate.
"""
def __init__(self):
super().__init__()
self._frame = None
self._lock = threading.Lock()
self._sequence = 0
self._rotation = 0
self.set_hexpand(True)
self.set_vexpand(True)
self.connect("draw", self._on_draw)
def set_rotation(self, degrees):
with self._lock:
self._rotation = int(degrees) % 360
def push_frame(self, array):
with self._lock:
self._frame = array
self._sequence += 1
def frame_state(self):
with self._lock:
rotation = self._rotation
frame = self._frame
if frame is None or rotation == 0:
return frame, rotation
# np.rot90 turns counter-clockwise, so invert the sign for clockwise UI degrees
return np.ascontiguousarray(np.rot90(frame, k=(-(rotation // 90)) % 4)), rotation
def _on_draw(self, _widget, cr):
cr.set_source_rgb(0, 0, 0)
cr.paint()
frame, _rotation = self.frame_state()
if frame is None:
cr.set_source_rgb(0.55, 0.58, 0.62)
cr.select_font_face("sans", cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_NORMAL)
cr.set_font_size(28)
cr.move_to(320, 300)
cr.show_text("waiting for camera...")
return False
height, width = frame.shape[:2]
stride = frame.strides[0]
surface = cairo.ImageSurface.create_for_data(
frame, cairo.FORMAT_RGB24, width, height, stride
)
alloc_w = self.get_allocated_width()
alloc_h = self.get_allocated_height()
scale = min(alloc_w / width, alloc_h / height)
cr.save()
cr.translate((alloc_w - width * scale) / 2.0, (alloc_h - height * scale) / 2.0)
cr.scale(scale, scale)
cr.set_source_surface(surface, 0, 0)
cr.get_source().set_filter(cairo.FILTER_BILINEAR)
cr.paint()
cr.restore()
return False
class CameraWorker(threading.Thread):
"""Owns the camera, the preview feed and the raw recorder."""
def __init__(self, cfg, storage, ui):
super().__init__(name="camera", daemon=True)
self.cfg = cfg
self.storage = storage
self.ui = ui
self.camera = None
self.recorder = RawRecorder(storage, cfg, on_state_change=self._recorder_changed)
self.tiff_worker = TiffWorker(on_done=self._tiff_done)
self.commands = queue.Queue()
self._stop = threading.Event()
self._preview_lock = threading.Lock()
self._latest_frame = None
self._pending_controls = {}
self._pending_lock = threading.Lock()
self.auto_exposure = True
self.error = None
self.last_metadata = {}
# ------------------------------------------------------------- controls
def control_ranges(self):
if self.camera is None:
return {}
return self.camera.control_ranges()
def push_controls(self, changes):
"""Queue camera control changes; they are applied on the camera thread."""
with self._pending_lock:
self._pending_controls.update(changes)
def set_auto_exposure(self, enabled):
self.auto_exposure = bool(enabled)
def frame_duration_limits(self):
"""The pinned FrameDurationLimits used to hold the recording rate."""
if self.camera is not None:
return self.camera.frame_duration_limits(
int(self.cfg.get("record.target_fps", 30))
)
fps = max(1, int(self.cfg.get("record.target_fps", 30)))
return (int(1_000_000 / fps), int(1_000_000 / fps))
def relaxed_frame_duration_limits(self, exposure_us):
if self.camera is None:
return None
return self.camera.relaxed_frame_duration_limits(exposure_us)
def _apply_pending_controls(self):
with self._pending_lock:
pending = dict(self._pending_controls)
self._pending_controls.clear()
if pending and self.camera is not None:
self.camera.apply_controls(pending)
# ------------------------------------------------------------- plumbing
def _recorder_changed(self):
GLib.idle_add(self.ui.on_recorder_state_change)
def _tiff_done(self, job, elapsed, error):
GLib.idle_add(self.ui.on_tiff_done, job, elapsed, error)
def submit_command(self, command):
self.commands.put(command)
def stop(self):
self._stop.set()
self.commands.put("stop")
def preview_frame(self):
with self._preview_lock:
return self._latest_frame
@property
def recording(self):
return self.recorder.active
# ------------------------------------------------------------------ loop
def run(self):
# The TIFF worker is a threaded object, not a thread until it is started.
self.tiff_worker.start()
try:
self.camera = MicroscopeCamera(self.cfg)
self.camera.start("video")
geometry = self.camera.raw_geometry()
self.recorder.set_raw_geometry(geometry)
log.info("raw stream: %s", geometry)
except Exception as exc: # camera missing / in use / locked
self.error = str(exc)
log.exception("camera failed to start")
GLib.idle_add(self.ui.on_camera_error, str(exc))
return
GLib.idle_add(self.ui.on_camera_ready)
self._apply_pending_controls()
while not self._stop.is_set():
self._drain_commands()
self._apply_pending_controls()
if self._stop.is_set():
break
try:
request = self.camera.capture_request()
except Exception as exc:
log.exception("capture_request failed")
self.error = str(exc)
GLib.idle_add(self.ui.on_camera_error, str(exc))
break
try:
frame = self.camera.preview_array(request)
with self._preview_lock:
self._latest_frame = frame
try:
self.last_metadata = request.get_metadata()
except Exception:
pass
except Exception:
log.debug("preview array failed", exc_info=True)
if self.recorder.active:
self.recorder.submit(request) # writer owns the request now
else:
request.release()
self.recorder.stop("app exiting")
try:
self.camera.stop()
self.camera.close()
except Exception:
log.debug("camera shutdown raised", exc_info=True)
# Flush a still's TIFF before the process goes away.
self.tiff_worker.stop()
log.info("camera thread finished")
def _drain_commands(self):
while True:
try:
command = self.commands.get_nowait()
except queue.Empty:
return
if command == "photo":
self._take_photo()
elif command == "stop":
return
def _take_photo(self):
if self.recorder.active:
GLib.idle_add(self.ui.toast, "Stop recording before taking a photo")
return
if self.storage.state() != "ready":
GLib.idle_add(self.ui.toast, "SSD not mounted — photo not saved", True)
return
GLib.idle_add(self.ui.on_photo_started)
try:
summary = capture_still(
self.camera, self.storage, self.cfg, self.tiff_worker,
on_tiff_done=self._tiff_done,
rotation=self.ui.rotation,
)
except Exception as exc:
log.exception("still capture failed")
GLib.idle_add(self.ui.on_photo_failed, str(exc))
return
GLib.idle_add(self.ui.on_photo_saved, summary)
class MicroscopeApp:
def __init__(self, cfg):
self.cfg = cfg
self.storage = Storage(cfg)
self.worker = CameraWorker(cfg, self.storage, self)
self._preview_sequence = -1
self._camera_ready = False
self._toast_timer = None
self._last_storage_state = None
self._last_mount_attempt = 0.0
self._session_dir = None
self.rotation = int(cfg.get("ui.rotation", 0) or 0) % 360
self._settings_visible = False
self._control_rows = {}
self._suspend_control_writes = False
self.window = Gtk.Window(title="Microscope Camera")
self.window.set_default_size(
int(cfg.get("preview.width", 1024)), int(cfg.get("preview.height", 600))
)
self.window.connect("destroy", self._on_destroy)
self.window.connect("key-press-event", self._on_key_press)
self.preview = PreviewArea()
self.preview.set_rotation(self.rotation)
self.overlay = Gtk.Overlay()
self.overlay.add(self.preview)
self.overlay.add_overlay(self._build_status_pill())
self.overlay.add_overlay(self._build_settings_panel())
self.overlay.add_overlay(self._build_ssd_badge())
self.overlay.add_overlay(self._build_button_bar())
self.overlay.add_overlay(self._build_rotate_button())
self.overlay.add_overlay(self._build_toast())
self.window.add(self.overlay)
self._apply_css()
# ------------------------------------------------------------------- UI
def _apply_css(self):
provider = Gtk.CssProvider()
provider.load_from_data(CSS)
screen = Gdk.Screen.get_default()
if screen is not None:
Gtk.StyleContext.add_provider_for_screen(
screen, provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
)
def _build_status_pill(self):
# The pill doubles as the settings button: tapping it toggles the
# exposure/gain sliders (an EventBox is used so the whole pill is a
# target, which matters on a touchscreen).
event_box = Gtk.EventBox()
event_box.set_visible_window(False)
event_box.set_halign(Gtk.Align.START)
event_box.set_valign(Gtk.Align.START)
event_box.set_margin_top(14)
event_box.set_margin_start(14)
event_box.connect("button-press-event", self._on_status_clicked)
self.status_event_box = event_box
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
box.get_style_context().add_class("status")
self.status_main = Gtk.Label(label="starting...")
self.status_main.set_xalign(0)
self.status_main.get_style_context().add_class("status-rec")
self.status_info = Gtk.Label(label="")
self.status_info.set_xalign(0)
self.status_info.get_style_context().add_class("status-dim")
self.status_action = Gtk.Label()
self.status_action.set_xalign(0)
self.status_action.get_style_context().add_class("pill-hint")
self.status_action.set_markup("tap for exposure / gain")
box.pack_start(self.status_main, False, False, 0)
box.pack_start(self.status_info, False, False, 0)
box.pack_start(self.status_action, False, False, 0)
event_box.add(box)
return event_box
# ------------------------------------------------------- settings panel
def _build_settings_panel(self):
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
box.get_style_context().add_class("settings")
box.set_halign(Gtk.Align.START)
box.set_valign(Gtk.Align.START)
box.set_margin_start(14)
# Clear of the status pill, which grew a third line (the tap hint).
box.set_margin_top(122)
box.set_size_request(430, -1)
title = Gtk.Label(label="Camera settings")
title.set_xalign(0)
title.get_style_context().add_class("settings-title")
box.pack_start(title, False, False, 0)
self.auto_button = Gtk.Button(label="Auto exposure: ON")
self.auto_button.set_size_request(200, 44)
self.auto_button.connect("clicked", self._on_auto_toggled)
box.pack_start(self.auto_button, False, False, 0)
hint = Gtk.Label(label="tap the status pill again to hide")
hint.set_xalign(0)
hint.get_style_context().add_class("settings-hint")
box.pack_start(hint, False, False, 0)
# Exposure spans microseconds to seconds, so its slider is logarithmic:
# a linear one would waste 95% of its travel above 1 s/1000.
for name, label, kind in (
("ExposureTime", "Exposure", "log"),
("AnalogueGain", "Gain", "linear"),
("Sharpness", "Sharpness", "linear"),
("Contrast", "Contrast", "linear"),
("Saturation", "Saturation", "linear"),
("Brightness", "Brightness", "linear"),
):
row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
row.set_halign(Gtk.Align.FILL)
name_label = Gtk.Label(label=label)
name_label.set_xalign(0)
name_label.set_size_request(96, -1)
name_label.get_style_context().add_class("setting-name")
scale = Gtk.Scale.new_with_range(Gtk.Orientation.HORIZONTAL, 0.0, 1.0, 0.001)
scale.set_draw_value(False)
scale.set_hexpand(True)
scale.set_size_request(200, -1)
scale.connect("value-changed", self._on_slider_changed, name)
value_label = Gtk.Label(label="-")
value_label.set_xalign(1)
value_label.set_size_request(104, -1)
value_label.get_style_context().add_class("setting-value")
row.pack_start(name_label, False, False, 0)
row.pack_start(scale, True, True, 0)
row.pack_start(value_label, False, False, 0)
box.pack_start(row, False, False, 0)
self._control_rows[name] = {"scale": scale, "value": value_label,
"kind": kind, "range": None}
self.settings_box = box
box.set_visible(False)
return box
def _build_rotate_button(self):
button = Gtk.Button(label="⟳ 0°")
button.get_style_context().add_class("rotate")
button.set_size_request(150, 62)
button.set_halign(Gtk.Align.START)
button.set_valign(Gtk.Align.END)
button.set_margin_start(20)
button.set_margin_bottom(20)
button.connect("clicked", self._on_rotate_clicked)
self.rotate_button = button
return button
def _build_ssd_badge(self):
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
box.get_style_context().add_class("badge")
box.set_halign(Gtk.Align.END)
box.set_valign(Gtk.Align.START)
box.set_margin_top(14)
box.set_margin_end(14)
self.ssd_label = Gtk.Label()
self.ssd_label.set_markup("⚠ SSD NOT MOUNTED")
self.ssd_hint = Gtk.Label()
self.ssd_hint.get_style_context().add_class("badge-hint")
self.ssd_hint.set_visible(False)
box.pack_start(self.ssd_label, False, False, 0)
box.pack_start(self.ssd_hint, False, False, 0)
self.ssd_badge = box
box.set_visible(False)
return box
def _build_button_bar(self):
# Vertical stack pinned to the bottom-right corner. The optional MOUNT
# button sits on top, so showing or hiding it never shifts REC/PHOTO.
bar = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
bar.set_halign(Gtk.Align.END)
bar.set_valign(Gtk.Align.END)
bar.set_margin_end(20)
bar.set_margin_bottom(20)
self.record_button = Gtk.Button(label="● REC")
self.record_button.get_style_context().add_class("rec")
self.record_button.set_size_request(150, 62)
self.record_button.connect("clicked", self._on_record_clicked)
self.photo_button = Gtk.Button(label="◉ PHOTO")
self.photo_button.get_style_context().add_class("photo")
self.photo_button.set_size_request(150, 62)
self.photo_button.connect("clicked", self._on_photo_clicked)
self.mount_button = Gtk.Button(label="⏏ MOUNT SSD")
self.mount_button.get_style_context().add_class("mount")
self.mount_button.set_size_request(150, 54)
self.mount_button.set_visible(False)
self.mount_button.connect("clicked", self._on_mount_clicked)
bar.pack_start(self.mount_button, False, False, 0)
bar.pack_start(self.record_button, False, False, 0)
bar.pack_start(self.photo_button, False, False, 0)
return bar
def _build_toast(self):
self.toast_label = Gtk.Label()
self.toast_label.get_style_context().add_class("toast")
self.toast_label.set_halign(Gtk.Align.CENTER)
self.toast_label.set_valign(Gtk.Align.CENTER)
self.toast_box = self.toast_label
self.toast_label.set_visible(False)
return self.toast_label
# -------------------------------------------------------------- lifecycle
def run(self):
if self.cfg.get("ui.fullscreen", True):
self.window.fullscreen()
self.window.show_all()
# show_all() would re-show things that must start hidden.
self.ssd_badge.set_visible(False)
self.toast_label.set_visible(False)
self.settings_box.set_visible(False)
self._settings_visible = False
self.rotate_button.set_label("⟳ %d°" % self.rotation)
if self.cfg.get("ui.hide_cursor", True):
self._hide_cursor()
self.worker.start()
interval = int(self.cfg.get("ui.preview_refresh_ms", 33))
GLib.timeout_add(interval, self._on_preview_tick)
GLib.timeout_add(int(self.cfg.get("ui.status_refresh_ms", 250)), self._on_status_tick)
GLib.timeout_add(int(self.cfg.get("ui.storage_poll_ms", 1000)), self._on_storage_tick)
# While the panel is open, keep the slider positions following the
# camera (AE keeps adjusting exposure/gain underneath).
GLib.timeout_add(500, self._on_controls_tick)
GLib.unix_signal_add(GLib.PRIORITY_DEFAULT, signal.SIGTERM, self._on_signal)
GLib.unix_signal_add(GLib.PRIORITY_DEFAULT, signal.SIGINT, self._on_signal)
self._update_storage_ui()
Gtk.main()
def _hide_cursor(self):
display = Gdk.Display.get_default()
if display is None:
return
window = self.window.get_window()
if window is None:
return
try:
cursor = Gdk.Cursor.new_for_display(display, Gdk.CursorType.BLANK_CURSOR)
window.set_cursor(cursor)
except Exception:
log.debug("could not hide cursor", exc_info=True)
def shutdown(self):
log.info("shutting down")
try:
self.worker.stop()
self.worker.join(timeout=20)
except Exception:
log.debug("worker shutdown raised", exc_info=True)
Gtk.main_quit()
def _on_signal(self, *_args):
self.shutdown()
return False
def _on_destroy(self, *_args):
self.shutdown()
def _on_key_press(self, _widget, event):
key = event.keyval
if key in (Gdk.KEY_Escape, Gdk.KEY_q):
self.shutdown()
return True
if key in (Gdk.KEY_r, Gdk.KEY_R):
self._on_record_clicked(None)
return True
if key in (Gdk.KEY_p, Gdk.KEY_P):
self._on_photo_clicked(None)
return True
if key in (Gdk.KEY_t, Gdk.KEY_T):
self._cycle_rotation()
return True
if key in (Gdk.KEY_e, Gdk.KEY_E):
self._on_status_clicked(None, None)
return True
return False
# --------------------------------------------------------- rotate button
def _on_rotate_clicked(self, _button):
self._cycle_rotation()
def _cycle_rotation(self):
self.set_rotation((self.rotation + 90) % 360)
def set_rotation(self, degrees):
"""Rotate preview and all saved output by *degrees* clockwise."""
self.rotation = int(degrees) % 360
self.preview.set_rotation(self.rotation)
self.rotate_button.set_label("⟳ %d°" % self.rotation)
self.preview.queue_draw()
self.cfg.data.setdefault("ui", {})["rotation"] = self.rotation
try:
self.cfg.save()
except OSError:
log.debug("could not persist rotation", exc_info=True)
self.toast("Rotation %d°" % self.rotation)
# ------------------------------------------------------ settings panel
def _on_status_clicked(self, _widget, _event):
self._settings_visible = not self._settings_visible
if self._settings_visible:
self._refresh_control_ranges()
self.settings_box.set_visible(self._settings_visible)
self.status_action.set_markup(
"hide settings" if self._settings_visible else "tap for exposure / gain"
)
return False
def _on_controls_tick(self):
if self._settings_visible and self.worker.auto_exposure:
# Only track the camera automatically while AE is in charge; once the
# operator has taken manual control the sliders must not be yanked
# back by our own refresh.
self._refresh_control_values()
return True
def _refresh_control_values(self):
"""Update slider positions/labels from the latest camera metadata."""
metadata = self.worker.last_metadata
if not metadata:
return
self._suspend_control_writes = True
for name, row in self._control_rows.items():
info = row.get("range")
if not info:
continue
current = metadata.get(name)
if current is None:
continue
current = float(current)
if row["kind"] == "log":
low = max(1.0, float(info["min"]))
high = max(low + 1.0, float(info["max"]))
position = (np.log(max(low, current)) - np.log(low)) / (np.log(high) - np.log(low))
row["scale"].set_value(float(np.clip(position, 0.0, 1.0)))
else:
row["scale"].set_value(current)
row["value"].set_text(self._format_control_value(name, current))
self._suspend_control_writes = False
def _refresh_control_ranges(self):
"""Populate slider ranges from the camera and show current values."""
ranges = self.worker.control_ranges()
metadata = self.worker.last_metadata
self._suspend_control_writes = True
for name, row in self._control_rows.items():
info = ranges.get(name)
if not info or float(info["max"]) <= float(info["min"]):
row["scale"].set_sensitive(False)
row["value"].set_text("n/a")
continue
row["scale"].set_sensitive(True)
row["range"] = info
current = metadata.get(name)
if current is None:
current = info["default"]
current = float(current)
if row["kind"] == "log":
# Slider travels 0..1 in log space across the control's range.
low = max(1.0, float(info["min"]))
high = max(low + 1.0, float(info["max"]))
position = (np.log(max(low, current)) - np.log(low)) / (np.log(high) - np.log(low))
row["scale"].set_value(float(np.clip(position, 0.0, 1.0)))
else:
row["scale"].set_value(current)
row["value"].set_text(self._format_control_value(name, current))
self._suspend_control_writes = False
self.auto_button.set_label(
"Auto exposure: ON" if self.worker.auto_exposure else "Auto exposure: OFF"
)
@staticmethod
def _format_control_value(name, value):
if name == "ExposureTime":
if value >= 1_000_000:
return "%.2f s" % (value / 1_000_000.0)
return "%.2f ms" % (value / 1000.0)
if name == "AnalogueGain":
return "%.2fx" % value
return "%.2f" % value
@staticmethod
def _slider_to_value(row, position):
info = row["range"]
low, high = float(info["min"]), float(info["max"])
if row["kind"] == "log":
low = max(1.0, low)
high = max(low + 1.0, high)
return float(np.exp(np.log(low) + position * (np.log(high) - np.log(low))))
return low + position * (high - low)
def _on_slider_changed(self, scale, name):
if self._suspend_control_writes:
return
row = self._control_rows.get(name)
if not row or not row["range"]:
return
position = float(scale.get_value())
value = self._slider_to_value(row, position)
row["value"].set_text(self._format_control_value(name, value))
changes = {name: value}
# Touching exposure or gain means the operator wants manual control, so
# AE has to stand down or it would fight the slider every frame.
if name in ("ExposureTime", "AnalogueGain") and self.worker.auto_exposure:
self.worker.set_auto_exposure(False)
self.auto_button.set_label("Auto exposure: OFF")
if name == "ExposureTime":
changes["AeEnable"] = False
# The video config pins FrameDurationLimits to 1/target_fps to keep
# the recording rate honest. A manual exposure longer than that
# period would simply be clamped, so widen the window to match.
relaxed = self.worker.relaxed_frame_duration_limits(value)
if relaxed:
changes["FrameDurationLimits"] = relaxed
self.worker.push_controls(changes)
def _on_auto_toggled(self, _button):
enable = not self.worker.auto_exposure
self.worker.set_auto_exposure(enable)
self.auto_button.set_label(
"Auto exposure: ON" if enable else "Auto exposure: OFF"
)
changes = {"AeEnable": enable}
if enable:
# Hand exposure and gain back to the AE algorithm and restore the
# frame-duration pin that keeps recording at the target rate.
changes["FrameDurationLimits"] = self.worker.frame_duration_limits()
self._suspend_control_writes = True
for name in ("ExposureTime", "AnalogueGain"):
row = self._control_rows.get(name)
if row:
row["scale"].set_value(0.0)
self._suspend_control_writes = False
self.worker.push_controls(changes)
self._refresh_control_ranges()
else:
self.worker.push_controls(changes)
self.toast("Auto exposure %s" % ("enabled" if enable else "disabled"))
# ---------------------------------------------------------------- actions
def _on_record_clicked(self, _button):
if self.worker.recording:
summary = self.worker.recorder.stop("stopped by user")
self.toast("Saved %d frames (%.1f GB)" % (
summary.get("frames", 0), summary.get("bytes", 0) / 1e9))
return
state = self.storage.state()
if state != "ready" and state == "unmounted":
self.toast("Mounting SSD...")
self.storage.mount()
self._update_storage_ui()
state = self.storage.state()
if state != "ready":
self.toast("SSD not mounted — recording disabled", True)
return
if not self._camera_ready:
self.toast("Camera not ready", True)
return
session = self.storage.new_session("record")
self._session_dir = session
self.worker.recorder.start(session, rotation=self.rotation)
self.toast("Recording to %s" % self.storage.relative(session))
self._refresh_record_button()
def _on_photo_clicked(self, _button):
self.worker.submit_command("photo")
def _on_mount_clicked(self, _button):
ok, message = self.storage.mount()
if not ok:
self.toast("Mount failed: %s" % message, True)
self._update_storage_ui()
# ------------------------------------------------------------ UI updates
def _on_preview_tick(self):
frame = self.worker.preview_frame()
if frame is not None:
self.preview.push_frame(frame)
self.preview.queue_draw()
return True
def _on_status_tick(self):
stats = self.worker.recorder.stats()
metadata = self.worker.last_metadata
recording = self.worker.recording
if recording:
elapsed = stats.get("elapsed", 0.0)
self.status_main.set_markup(
"<b>● REC %s</b> %d frames %.1f fps %.0f MB/s%s" % (
_mmss(elapsed), stats.get("frames", 0), stats.get("actual_fps", 0.0),
stats.get("mb_per_s", 0.0),
(" dropped %d" % stats["dropped"]) if stats.get("dropped") else "",
)
)
elif not self._camera_ready:
self.status_main.set_markup("<b>starting camera...</b>")
else:
sensor = self.cfg["sensor"]
self.status_main.set_markup("<b>READY</b> %dx%d 12-bit RAW @ %d fps target" % (
sensor["width"], sensor["height"], self.cfg.get("record.target_fps", 30)))
free_mb = self.storage.free_mb()
free_text = ("%.0f GB free" % (free_mb / 1024)) if free_mb else "SSD not mounted"
exposure = metadata.get("ExposureTime")
gain = metadata.get("AnalogueGain")
temp = metadata.get("SensorTemperature")
parts = [free_text, "mode %s" % (self.worker.camera.mode if self.worker.camera else "?")]
if exposure:
parts.append("exp %.1f ms" % (exposure / 1000.0))
if gain:
parts.append("gain %.1f" % gain)
if temp:
parts.append("%.0f °C" % temp)
self.status_info.set_text(" ".join(parts))
self._refresh_record_button()
return True
def _on_storage_tick(self):
# Backstop for the udev rule: if the drive is present but nothing has
# mounted it (udev missed the event, boot-time race, manual umount) then
# mount it here. The helper returns immediately when the drive is ready.
state = self.storage.state()
if state == "unmounted":
now = time.monotonic()
if now - self._last_mount_attempt > 5.0:
self._last_mount_attempt = now
ok, message = self.storage.mount()
if ok:
log.info("auto-mounted the capture SSD")
self.toast("SSD mounted")
else:
log.warning("auto-mount failed: %s", message)
self._update_storage_ui()
if self.worker.recording and self.storage.state() != "ready":
summary = self.worker.recorder.stop("ssd removed")
self.toast("SSD removed — recording stopped (%d frames)" % summary.get("frames", 0), True)
return True
def _update_storage_ui(self):
state = self.storage.state()
self._last_storage_state = state
if state == "ready":
self.ssd_badge.set_visible(False)
self.mount_button.set_visible(False)
else:
self.ssd_badge.set_visible(True)
self.ssd_label.set_markup("⚠ SSD NOT MOUNTED")
if state == "unmounted":
self.ssd_hint.set_text("plugged in — tap MOUNT SSD")
self.ssd_hint.set_visible(True)
self.mount_button.set_visible(True)
else:
self.ssd_hint.set_text("plug the capture SSD into USB")
self.ssd_hint.set_visible(True)
self.mount_button.set_visible(False)
def _refresh_record_button(self):
recording = self.worker.recording
ctx = self.record_button.get_style_context()
if recording:
if not ctx.has_class("recording"):
ctx.add_class("recording")
self.record_button.set_label("■ STOP")
else:
if ctx.has_class("recording"):
ctx.remove_class("recording")
self.record_button.set_label("● REC")
self.photo_button.set_sensitive(not recording)
def toast(self, message, error=False):
ctx = self.toast_label.get_style_context()
if error and not ctx.has_class("toast-error"):
ctx.add_class("toast-error")
elif not error and ctx.has_class("toast-error"):
ctx.remove_class("toast-error")
self.toast_label.set_text(message)
self.toast_label.set_visible(True)
if self._toast_timer:
GLib.source_remove(self._toast_timer)
self._toast_timer = GLib.timeout_add(2600, self._hide_toast)
def _hide_toast(self):
self.toast_label.set_visible(False)
self._toast_timer = None
return False
# ------------------------------------------------------- worker callbacks
def on_camera_ready(self):
self._camera_ready = True
self._update_storage_ui()
return False
def on_camera_error(self, message):
self._camera_ready = False
self.status_main.set_markup("<b>CAMERA ERROR</b>")
self.status_info.set_text(message)
self.toast("Camera error: %s" % message, True)
return False
def on_recorder_state_change(self):
self._refresh_record_button()
return False
def on_photo_started(self):
self.toast("Capturing 4056x3040 still...")
return False
def on_photo_saved(self, summary):
files = ", ".join(sorted(k.upper() for k in summary.get("files", {})))
self.toast("%s saved (%s)" % (summary["stem"], files))
return False
def on_photo_failed(self, message):
self.toast("Photo failed: %s" % message, True)
return False
def on_tiff_done(self, job, elapsed, error):
if error:
self.toast(error, True)
else:
self.toast("16-bit TIFF written in %.1fs" % (elapsed or 0.0))
return False
def _mmss(seconds):
seconds = max(0.0, float(seconds))
minutes, secs = divmod(seconds, 60)
return "%02d:%04.1f" % (int(minutes), secs)
def main():
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
stream=sys.stdout,
)
cfg = Config()
app = MicroscopeApp(cfg)
try:
app.run()
except KeyboardInterrupt:
app.shutdown()
return 0
if __name__ == "__main__":
sys.exit(main())