#!/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( "● REC %s %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("starting camera...") else: sensor = self.cfg["sensor"] self.status_main.set_markup("READY %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("CAMERA ERROR") 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())