"""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]