Source code for magscope.settings

from __future__ import annotations

import copy
import os
import re
from dataclasses import dataclass
from typing import Any, Callable, Iterable, Mapping, MutableMapping

from PyQt6.QtCore import QSettings
import yaml


[docs] DEFAULT_GUI_ACCENT_COLOR = '#78c7ff'
[docs] GUI_ACCENT_COLOR_SETTING = 'gui accent color'
[docs] GUI_LIVE_PLOT_PROGRESS_BAR_SETTING = 'gui live plot progress bar'
[docs] PREFERENCES_BUNDLE_VERSION = 1
[docs] TRACKING_OPTIONS_QSETTINGS_GROUP = 'TrackingOptions'
[docs] TRACKING_OPTIONS_QSETTINGS_KEY = 'options_yaml'
[docs] _HEX_COLOR_RE = re.compile(r'^#[0-9a-fA-F]{6}$')
[docs] DEFAULT_TRACKING_OPTIONS: dict[str, Any] = { 'center_of_mass': {'background': 'median'}, 'n auto_conv_multiline_sub_pixel': 5, 'auto_conv_multiline_sub_pixel': {'line_ratio': 0.1, 'n_local': 5}, 'use fft_profile': False, 'fft_profile': {'oversample': 4, 'rmin': 0.0, 'rmax': 0.5, 'gaus_factor': 6.0}, 'radial_profile': {'oversample': 1}, 'lookup_z': {'n_local': 7}, }
[docs] def normalize_hex_color(value: str) -> str: value = value.strip() if not _HEX_COLOR_RE.fullmatch(value): raise ValueError("Accent color must use #RRGGBB hex format.") return value.lower()
[docs] def default_tracking_options() -> dict[str, Any]: return copy.deepcopy(DEFAULT_TRACKING_OPTIONS)
[docs] def _coerce_tracking_int_value( raw: Any, *, name: str, fallback: int, minimum: int | None = None, enforce_odd: bool = False, ) -> int: if raw is None: return fallback try: value = int(raw) except (TypeError, ValueError): raise ValueError(f'{name} must be an integer') if minimum is not None and value < minimum: raise ValueError(f'{name} must be at least {minimum}') if enforce_odd and value % 2 == 0: value += 1 return value
[docs] def _coerce_tracking_float_value( raw: Any, *, name: str, fallback: float, minimum: float | None = None, ) -> float: if raw is None: return fallback try: value = float(raw) except (TypeError, ValueError): raise ValueError(f'{name} must be a number') if minimum is not None and value < minimum: raise ValueError(f'{name} must be at least {minimum}') return value
[docs] def _coerce_tracking_bool_value(raw: Any, *, fallback: bool) -> bool: if raw is None: return fallback if isinstance(raw, bool): return raw if isinstance(raw, str): normalized = raw.strip().lower() if normalized in {'true', '1', 'yes'}: return True if normalized in {'false', '0', 'no'}: return False if isinstance(raw, (int, float)): return bool(raw) raise ValueError('use fft_profile must be a boolean')
[docs] def tracking_options_from_mapping(loaded: Any) -> dict[str, Any]: if loaded is None: raise ValueError('Tracking options file is empty') if not isinstance(loaded, Mapping): raise ValueError('Tracking options file must be a YAML mapping') options = default_tracking_options() center_of_mass = loaded.get('center_of_mass') if center_of_mass is not None: if not isinstance(center_of_mass, Mapping): raise ValueError('center_of_mass must be a mapping') background = center_of_mass.get('background', options['center_of_mass']['background']) if background not in {'none', 'mean', 'median'}: raise ValueError('center_of_mass.background must be one of none, mean, median') options['center_of_mass']['background'] = background options['n auto_conv_multiline_sub_pixel'] = _coerce_tracking_int_value( loaded.get('n auto_conv_multiline_sub_pixel'), name='n auto_conv_multiline_sub_pixel', fallback=options['n auto_conv_multiline_sub_pixel'], minimum=1, ) auto_conv_multiline = loaded.get('auto_conv_multiline_sub_pixel') if auto_conv_multiline is not None: if not isinstance(auto_conv_multiline, Mapping): raise ValueError('auto_conv_multiline_sub_pixel must be a mapping') options['auto_conv_multiline_sub_pixel']['line_ratio'] = _coerce_tracking_float_value( auto_conv_multiline.get('line_ratio'), name='auto_conv_multiline_sub_pixel.line_ratio', fallback=options['auto_conv_multiline_sub_pixel']['line_ratio'], minimum=0.0, ) options['auto_conv_multiline_sub_pixel']['n_local'] = _coerce_tracking_int_value( auto_conv_multiline.get('n_local'), name='auto_conv_multiline_sub_pixel.n_local', fallback=options['auto_conv_multiline_sub_pixel']['n_local'], minimum=3, enforce_odd=True, ) options['use fft_profile'] = _coerce_tracking_bool_value( loaded.get('use fft_profile'), fallback=options['use fft_profile'], ) fft_profile = loaded.get('fft_profile') if fft_profile is not None: if not isinstance(fft_profile, Mapping): raise ValueError('fft_profile must be a mapping') options['fft_profile']['oversample'] = _coerce_tracking_int_value( fft_profile.get('oversample'), name='fft_profile.oversample', fallback=options['fft_profile']['oversample'], minimum=1, ) options['fft_profile']['rmin'] = _coerce_tracking_float_value( fft_profile.get('rmin'), name='fft_profile.rmin', fallback=options['fft_profile']['rmin'], minimum=0.0, ) options['fft_profile']['rmax'] = _coerce_tracking_float_value( fft_profile.get('rmax'), name='fft_profile.rmax', fallback=options['fft_profile']['rmax'], minimum=0.0, ) options['fft_profile']['gaus_factor'] = _coerce_tracking_float_value( fft_profile.get('gaus_factor'), name='fft_profile.gaus_factor', fallback=options['fft_profile']['gaus_factor'], minimum=0.0, ) radial_profile = loaded.get('radial_profile') if radial_profile is not None: if not isinstance(radial_profile, Mapping): raise ValueError('radial_profile must be a mapping') options['radial_profile']['oversample'] = _coerce_tracking_int_value( radial_profile.get('oversample'), name='radial_profile.oversample', fallback=options['radial_profile']['oversample'], minimum=1, ) lookup_z = loaded.get('lookup_z') if lookup_z is not None: if not isinstance(lookup_z, Mapping): raise ValueError('lookup_z must be a mapping') options['lookup_z']['n_local'] = _coerce_tracking_int_value( lookup_z.get('n_local'), name='lookup_z.n_local', fallback=options['lookup_z']['n_local'], minimum=3, enforce_odd=True, ) return options
[docs] def tracking_options_from_qsettings() -> dict[str, Any]: settings = QSettings('MagScope', 'MagScope') settings.beginGroup(TRACKING_OPTIONS_QSETTINGS_GROUP) raw_value = settings.value(TRACKING_OPTIONS_QSETTINGS_KEY, '', type=str) settings.endGroup() if not raw_value: return default_tracking_options() try: loaded = yaml.safe_load(raw_value) return tracking_options_from_mapping(loaded) except (ValueError, yaml.YAMLError): return default_tracking_options()
[docs] def save_tracking_options_to_qsettings(options: Mapping[str, Any]) -> None: validated = tracking_options_from_mapping(options) settings = QSettings('MagScope', 'MagScope') settings.beginGroup(TRACKING_OPTIONS_QSETTINGS_GROUP) settings.setValue(TRACKING_OPTIONS_QSETTINGS_KEY, yaml.safe_dump(validated)) settings.endGroup() settings.sync()
[docs] def build_preferences_bundle( *, magscope_settings: MagScopeSettings, tracking_options: Mapping[str, Any], appearance_layout: Mapping[str, Any] | None = None, ) -> dict[str, Any]: return { 'version': PREFERENCES_BUNDLE_VERSION, 'magscope': magscope_settings.to_dict(), 'tracking': tracking_options_from_mapping(tracking_options), 'appearance_layout': copy.deepcopy(dict(appearance_layout or {})), }
[docs] def load_preferences_bundle_mapping(data: Any) -> dict[str, Any]: if data is None: raise ValueError('Preferences file is empty') if not isinstance(data, Mapping): raise ValueError('Preferences file must be a YAML mapping') version = data.get('version') if version != PREFERENCES_BUNDLE_VERSION: raise ValueError(f'Unsupported preferences file version: {version!r}') magscope = data.get('magscope') if not isinstance(magscope, Mapping): raise ValueError('Preferences file must include a magscope mapping') tracking = data.get('tracking') if not isinstance(tracking, Mapping): raise ValueError('Preferences file must include a tracking mapping') appearance_layout = data.get('appearance_layout', {}) if not isinstance(appearance_layout, Mapping): raise ValueError('appearance_layout must be a mapping') try: magscope_settings = MagScopeSettings(magscope) except KeyError as exc: raise ValueError(exc.args[0]) from exc return { 'version': PREFERENCES_BUNDLE_VERSION, 'magscope': magscope_settings, 'tracking': tracking_options_from_mapping(tracking), 'appearance_layout': copy.deepcopy(dict(appearance_layout)), }
[docs] def export_preferences_bundle( path: str | os.PathLike[str], *, magscope_settings: MagScopeSettings, tracking_options: Mapping[str, Any], appearance_layout: Mapping[str, Any] | None = None, ) -> None: bundle = build_preferences_bundle( magscope_settings=magscope_settings, tracking_options=tracking_options, appearance_layout=appearance_layout, ) with open(path, 'w', encoding='utf-8') as file: yaml.safe_dump(bundle, file)
[docs] def import_preferences_bundle(path: str | os.PathLike[str]) -> dict[str, Any]: with open(path, 'r', encoding='utf-8') as file: try: data = yaml.safe_load(file) except yaml.YAMLError as exc: raise ValueError(f'Invalid preferences YAML: {exc}') from exc return load_preferences_bundle_mapping(data)
@dataclass(frozen=True)
[docs] class SettingSpec:
[docs] key: str
[docs] value_type: type | tuple[type, ...]
[docs] default: Any | None = None
[docs] display_name: str | None = None
[docs] minimum: float | None = None
[docs] maximum: float | None = None
[docs] must_be_even: bool = False
[docs] validator: Callable[[Any], Any] | None = None
[docs] def coerce(self, value: Any) -> Any: if isinstance(value, str): value = value.strip() if value == "": raise ValueError(f"Setting '{self.key}' cannot be empty") if bool in self._candidate_types: normalized = value.lower() if normalized in {'true', '1', 'yes'}: coerced = True elif normalized in {'false', '0', 'no'}: coerced = False else: coerced = value else: try: if float in self._candidate_types: coerced = float(value) else: coerced = int(value) except (TypeError, ValueError): coerced = value else: coerced = value if not isinstance(coerced, self.value_type): raise ValueError( f"Setting '{self.key}' must be of type {self.value_type}, not {type(coerced)}." ) if self.minimum is not None and isinstance(coerced, (int, float)): if coerced < self.minimum: raise ValueError( f"Setting '{self.key}' must be at least {self.minimum}, not {coerced}." ) if self.maximum is not None and isinstance(coerced, (int, float)): if coerced > self.maximum: raise ValueError( f"Setting '{self.key}' must be at most {self.maximum}, not {coerced}." ) if self.must_be_even and isinstance(coerced, int): if coerced % 2 != 0: raise ValueError( f"Setting '{self.key}' must be an even integer, not {coerced}." ) if self.validator is not None: coerced = self.validator(coerced) return coerced
[docs] def default_value(self) -> Any: return copy.deepcopy(self.default)
@property
[docs] def _candidate_types(self) -> tuple[type, ...]: if isinstance(self.value_type, tuple): return self.value_type return (self.value_type,)
@property
[docs] def label(self) -> str: return self.display_name or self.key
[docs] class MagScopeSettings(MutableMapping[str, Any]):
[docs] _QSETTINGS_ORGANIZATION = "MagScope"
[docs] _QSETTINGS_APPLICATION = "MagScope"
[docs] _QSETTINGS_GROUP = "MagScopeSettings"
[docs] _MAG_SCOPE_PANEL_EXCLUDED_KEYS = { GUI_ACCENT_COLOR_SETTING, GUI_LIVE_PLOT_PROGRESS_BAR_SETTING, }
[docs] _SETTING_SPECS: dict[str, SettingSpec] = { "ROI": SettingSpec( "ROI", value_type=int, default=50, display_name="ROI (pixels)", minimum=8, maximum=256, must_be_even=True, ), "magnification": SettingSpec( "magnification", value_type=(int, float), default=1, display_name="Magnification (x)", minimum=1, ), "tracks max datapoints": SettingSpec( "tracks max datapoints", value_type=int, default=1_000_000, display_name="Tracks max datapoints", minimum=1, ), "video buffer n images": SettingSpec( "video buffer n images", value_type=int, default=40, display_name="Video buffer n images", minimum=1, ), "video buffer n stacks": SettingSpec( "video buffer n stacks", value_type=int, default=5, display_name="Video buffer n stacks", minimum=1, ), "video processors n": SettingSpec( "video processors n", value_type=int, default=3, display_name="Video processors n", minimum=1, ), "xy-lock default interval": SettingSpec( "xy-lock default interval", value_type=(int, float), default=10, display_name="XY-lock default interval", minimum=0, ), "xy-lock default max": SettingSpec( "xy-lock default max", value_type=(int, float), default=10, display_name="XY-lock default max", minimum=0, ), "xy-lock default window": SettingSpec( "xy-lock default window", value_type=int, default=10, display_name="XY-lock default window", minimum=1, ), "z-lock default interval": SettingSpec( "z-lock default interval", value_type=(int, float), default=10, display_name="Z-lock default interval", minimum=0, ), "z-lock default max": SettingSpec( "z-lock default max", value_type=(int, float), default=1_000, display_name="Z-lock default max", minimum=0, ), "z-lock default window": SettingSpec( "z-lock default window", value_type=int, default=10, display_name="Z-lock default window", minimum=1, ), GUI_ACCENT_COLOR_SETTING: SettingSpec( GUI_ACCENT_COLOR_SETTING, value_type=str, default=DEFAULT_GUI_ACCENT_COLOR, display_name="Accent color", validator=normalize_hex_color, ), GUI_LIVE_PLOT_PROGRESS_BAR_SETTING: SettingSpec( GUI_LIVE_PLOT_PROGRESS_BAR_SETTING, value_type=bool, default=True, display_name="Show live plot loading indicator", ), }
def __init__( self, values: Mapping[str, Any] | None = None, *, persistence_available: bool = True, persistence_enabled: bool = False, ):
[docs] self._persistence_enabled = persistence_enabled
[docs] self._persistence_listeners: list[Callable[["MagScopeSettings"], None]] = []
[docs] self.persistence_available = persistence_available
[docs] self._values: dict[str, Any] = {}
self.update(self._load_defaults()) if values: self.update(values) @classmethod
[docs] def _load_defaults(cls) -> dict[str, Any]: return {key: spec.default_value() for key, spec in cls._SETTING_SPECS.items()}
@classmethod
[docs] def _qsettings(cls) -> QSettings: return QSettings(cls._QSETTINGS_ORGANIZATION, cls._QSETTINGS_APPLICATION)
[docs] def _load_qsettings_values(self) -> dict[str, Any]: settings = self._qsettings() settings.beginGroup(self._QSETTINGS_GROUP) loaded: dict[str, Any] = {} for key, spec in self._SETTING_SPECS.items(): if not settings.contains(key): continue raw_value = settings.value(key) try: loaded[key] = spec.coerce(raw_value) except ValueError: continue settings.endGroup() self._update_persistence_availability( settings.isWritable() and settings.status() == QSettings.Status.NoError ) return loaded
[docs] def _update_persistence_availability(self, available: bool) -> None: was_available = self.persistence_available self.persistence_available = available if was_available and not available: for listener in list(self._persistence_listeners): listener(self)
[docs] def add_persistence_listener( self, callback: Callable[["MagScopeSettings"], None] ) -> None: self._persistence_listeners.append(callback)
@classmethod
[docs] def from_qsettings( cls, values: Mapping[str, Any] | None = None ) -> "MagScopeSettings": settings = cls(persistence_enabled=True) settings.update(settings._load_qsettings_values()) if values: settings.update(values) return settings
[docs] def save_to_qsettings(self) -> None: if not self._persistence_enabled: return settings = self._qsettings() settings.beginGroup(self._QSETTINGS_GROUP) settings.remove("") for key, value in self._values.items(): settings.setValue(key, value) settings.endGroup() settings.sync() self._update_persistence_availability( settings.isWritable() and settings.status() == QSettings.Status.NoError )
[docs] def reset_to_defaults(self) -> None: self._values = {} self.update(self._load_defaults())
[docs] def to_dict(self) -> dict[str, Any]: return copy.deepcopy(self._values)
[docs] def clone(self) -> "MagScopeSettings": return MagScopeSettings( self._values, persistence_available=self.persistence_available, )
[docs] def persistent_copy(self) -> "MagScopeSettings": return MagScopeSettings( self._values, persistence_available=self.persistence_available, persistence_enabled=True, )
[docs] def _coerce_setting(self, key: str, value: Any) -> Any: if key not in self._SETTING_SPECS: raise KeyError(f"Unknown setting '{key}'.") spec = self._SETTING_SPECS[key] return spec.coerce(value)
[docs] def update(self, mapping: Mapping[str, Any] | Iterable[tuple[str, Any]] = (), **kwargs: Any) -> None: # type: ignore[override] items: Iterable[tuple[str, Any]] if isinstance(mapping, Mapping): items = mapping.items() else: items = mapping for key, value in items: self[key] = value for key, value in kwargs.items(): self[key] = value
[docs] def __getitem__(self, key: str) -> Any: return self._values[key]
[docs] def __setitem__(self, key: str, value: Any) -> None: coerced = self._coerce_setting(key, value) self._values[key] = coerced
[docs] def __delitem__(self, key: str) -> None: raise TypeError("MagScopeSettings does not support deleting settings")
[docs] def __iter__(self): return iter(self._values)
[docs] def __len__(self): return len(self._values)
[docs] def export_yaml(self, path: str | os.PathLike[str]) -> None: with open(path, "w", encoding="utf-8") as file: yaml.safe_dump(self._values, file)
@classmethod
[docs] def import_yaml(cls, path: str | os.PathLike[str]) -> "MagScopeSettings": with open(path, "r", encoding="utf-8") as file: data = yaml.safe_load(file) if data is None: raise ValueError(f"Settings file {path} is empty") if not isinstance(data, dict): raise ValueError(f"Settings file {path} must be a YAML mapping") return cls(data)
@classmethod
[docs] def spec_for(cls, key: str) -> SettingSpec: return cls._SETTING_SPECS[key]
@classmethod
[docs] def defined_keys(cls) -> Iterable[str]: return cls._SETTING_SPECS.keys()
@classmethod
[docs] def magscope_panel_keys(cls) -> Iterable[str]: return ( key for key in cls._SETTING_SPECS.keys() if key not in cls._MAG_SCOPE_PANEL_EXCLUDED_KEYS )