Source code for spacr.settings_spec

"""Map settings to GUI widget specifications without importing a GUI.

The conversion helpers return plain dictionaries that both the Qt and legacy
interfaces can consume. Keeping this module dependency-light lets callers
inspect setting metadata without importing plotting, imaging, or deep-learning
libraries.

``spacr.gui_utils`` re-exports
:func:`convert_settings_dict_for_gui` for compatibility with existing callers.
"""
from __future__ import annotations

import sys

from .organelle_types import ALL_ORGANELLE_ROLES as _ORGANELLE_SLOT_ROLES
from .organelle_types import DEFAULT_NUMBER_OF_ORGANELLES, MAX_ORGANELLES
from .organelle_types import DEFAULT_TYPE as _ORGANELLE_TYPE_DEFAULT
from .organelle_types import TYPE_ORDER as _ORGANELLE_TYPE_ORDER
from .schema import ALL_ROLES

__all__ = ["convert_settings_dict_for_gui"]


_TORCHVISION_MODELS_CURATED = [
    'resnet18', 'resnet34', 'resnet50', 'resnet101', 'resnet152',
    'resnext50_32x4d', 'resnext101_32x8d', 'wide_resnet50_2',
    'vgg11', 'vgg13', 'vgg16', 'vgg19',
    'densenet121', 'densenet169', 'densenet201',
    'efficientnet_b0', 'efficientnet_b1', 'efficientnet_b2', 'efficientnet_b3',
    'efficientnet_b4', 'efficientnet_b5', 'efficientnet_b6', 'efficientnet_b7',
    'efficientnet_v2_s', 'efficientnet_v2_m', 'efficientnet_v2_l',
    'mobilenet_v2', 'mobilenet_v3_small', 'mobilenet_v3_large',
    'convnext_tiny', 'convnext_small', 'convnext_base', 'convnext_large',
    'vit_b_16', 'vit_b_32', 'vit_l_16', 'vit_l_32',
    'swin_t', 'swin_s', 'swin_b', 'swin_v2_t', 'swin_v2_s', 'swin_v2_b',
    'maxvit_t', 'regnet_y_400mf', 'regnet_y_1_6gf', 'regnet_y_8gf',
    'squeezenet1_0', 'squeezenet1_1', 'alexnet', 'googlenet', 'inception_v3',
]


def _regression_type_choices():
    """Every family that fits, as ``(stored value, label)``, grouped.

    :returns: pairs whose first element is the stored ``regression_type`` and
        whose second is the line the dropdown shows -- the family's name, the
        kind of fit it is, and what it assumes.

    A long list of unlabelled names in alphabetical order is a menu that hides
    its own contents: the quantile fit, the two robust losses and the rank
    aggregation were all on it and none of them could be found.
    :func:`spacr.regression_families.regression_family_choices` places each
    family in one of three honest kinds -- parametric,
    robust/semiparametric, rank-based -- and gives it a sentence saying what
    has to be true of the data for its answer to mean anything.

    Mixed leads because it is the default and answers the most central
    question; then the rest of the parametric group, then the robust one,
    then the rank-based one, so a family added to the inventory lands
    somewhere predictable instead of at the end.

    NOTHING IS RENAMED. The stored value is unchanged and leads its own
    label, so a settings CSV written before the grouping asks for exactly the
    fit it always asked for, and a user looking for 'quantile' still finds
    the word.

    `spacr.regression_families` imports only `spacr.regression_spec`, which
    imports nothing -- that is the whole reason the vocabulary was split out
    of `spacr.ml`, which pulls in torch through `spacr.plot` -- so asking it
    here costs a dict lookup rather than 2.2 seconds.
    """
    from .regression_families import regression_family_choices

    return regression_family_choices()


def _regression_backend_choices():
    """Every backend, labelled ``(CPU)`` or ``(GPU)``, in panel order.

    THE OPTIONS ARE THE LABELS, and so is the stored value -- see
    :func:`spacr.settings._resolve_regression_backend` for why. The labels
    state whether a backend uses the CPU or GPU, and both front ends render
    them verbatim.

    Read from :mod:`spacr.regression_backends`, which imports nothing heavier
    than stdlib, so a settings panel still costs a dict lookup rather than
    ``import torch``.

    Every entry is offered, including those that cannot run in the current
    environment. The panel disables unavailable entries and obtains their
    explanations from :func:`spacr.regression_backends.backend_menu`.
    """
    from .regression_backends import backend_choices

    return backend_choices()


def _metadata_type_choices():
    """Every filename convention, as ``(stored value, label)``, by vendor.

    :returns: pairs whose first element is the stored ``metadata_type`` and
        whose second is the line a plain dropdown shows -- the vendor, the
        instrument family, and, for a convention spaCR is guessing about,
        the word "provisional".

    A user with a Zeiss, a Nikon, a Leica or a Thermo Fisher instrument used
    to meet a dropdown offering two Yokogawas and "write your own regular
    expression". The stored values are UNCHANGED for the four that were
    already there, so a settings CSV written before this asks for exactly
    the convention it always asked for.

    PROVISIONAL IS ON SCREEN ON PURPOSE. Some of these patterns were read
    off a vendor manual or off Bio-Formats' own reader source; others were
    reconstructed from a handful of real filenames found in public datasets
    and forum posts. A user whose instrument is in the second group should
    know that before a run, not after a plate has been mislabelled -- and
    the "test it on my folder" button beside the dropdown is what turns the
    warning into an answer.

    Read from :mod:`spacr.regex_infer`, which imports nothing outside the
    standard library, so building a settings panel still costs a dict
    lookup.
    """
    from .regex_infer import _metadata_convention_menu

    choices = []
    for vendor, rows in _metadata_convention_menu():
        for key, label, status in rows:
            suffix = "" if status == "confirmed" else "  [provisional]"
            choices.append((key, f"{vendor} -- {label}{suffix}"))
    return choices


#: ``cam_type`` menu when :mod:`spacr.attribution` is not imported yet, in
#: the order :func:`spacr.attribution.cam_type_choices` gives. Kept here so
#: building a settings panel does not import torch; a test holds the two
#: equal.
_CAM_TYPE_CHOICES = (
    'gradcam', 'gradcam_pp', 'saliency_image', 'saliency_channel',
    'torchcam_gradcam', 'torchcam_gradcam_pp', 'ablation_cam',
    'attention_rollout', 'chefer', 'deeplift', 'deeplift_shap', 'eigencam',
    'feature_ablation', 'gradient_shap', 'guided_backprop', 'hirescam',
    'input_x_gradient', 'integrated_gradients', 'layercam', 'occlusion',
    'saliency', 'scorecam', 'xgradcam',
)


def _cam_type_choices():
    """Every ``cam_type`` the Activation Maps form offers.

    Read from :func:`spacr.attribution.cam_type_choices` when that module is
    already loaded, and from :data:`_CAM_TYPE_CHOICES` otherwise, so the
    panel never pays for importing torch.
    """
    module = sys.modules.get("spacr.attribution")
    if module is not None:
        try:
            return list(module.cam_type_choices())
        except Exception:
            pass
    return list(_CAM_TYPE_CHOICES)


def _torchvision_model_names():
    """Return model names for the combo WITHOUT importing torchvision. If
    torchvision is already loaded (e.g. after a training run) use its full zoo;
    otherwise fall back to the curated static list."""
    mods = sys.modules.get("torchvision.models")
    if mods is not None:
        try:
            names = [n for n, o in mods.__dict__.items()
                     if callable(o) and not n.startswith("_")]
            if names:
                return sorted(set(names) | set(_TORCHVISION_MODELS_CURATED))
        except Exception:
            pass
    return list(_TORCHVISION_MODELS_CURATED)


def _cellpose_model_names():
    """Return live Cellpose choices without loading the numerical stack.

    A cold settings-panel build needs only the shipped fallback.  Once either
    Cellpose or :mod:`spacr.settings` is already loaded, the lightweight
    accessor in ``settings`` can add installed and user-registered models
    without making this module responsible for a heavy first import.
    """
    settings_name = f"{__package__}.settings"
    if (settings_name not in sys.modules
            and "cellpose.models" not in sys.modules):
        return ["cpsam"]

    from .settings import cellpose_model_choices

    return list(cellpose_model_choices())


#: Settings whose widget cannot be decided from the NAME alone, because two
#: modules use that name for two different closed vocabularies. The value in
#: hand decides; anything not listed falls through to the name-keyed table.
#:
#: ``level`` is the only one. The proportion and endodyogeny plots have meant
#: 'object'/'well'/'plate' by it for years, while regression uses
#: 'both'/'grna'/'gene'. The shared tables here and in :mod:`spacr.settings`
#: are keyed by name with no module scope, so dispatch uses the value already
#: present on the panel.
#:
#: Deliberately NOT a fallback: a value in neither vocabulary returns None and
#: takes the ordinary path, so no module's existing widget changes shape.
_VALUE_SPECIAL_CASES = {
    'level': (
        (('both', 'grna', 'gene'),
         ('combo', [('both', 'both — gRNA and gene effects, each corrected '
                             'as its own family'),
                    ('grna', 'gRNA effects — one estimate per guide'),
                    ('gene', 'gene effects — one estimate per gene, its '
                             'guides pooled')],
          'both')),
        (('object', 'well', 'plate'),
         ('combo', ['object', 'well', 'plate'], 'object')),
    ),
}


def _value_special_cases(key, value):
    """The widget spec for ``key`` when its VALUE decides, else ``None``.

    :param key: the setting name.
    :param value: the value the panel is being built from.
    :returns: a ``(kind, options, default)`` triple, or ``None`` to fall
        through to the name-keyed ``special_cases`` table.
    """
    table = _VALUE_SPECIAL_CASES.get(key)
    if not table:
        return None
    if not isinstance(value, str):
        return None
    current = value.strip().lower()
    for vocabulary, spec in table:
        if current in vocabulary:
            kind, options, _default = spec
            return (kind, list(options), current)
    return None


[docs] def convert_settings_dict_for_gui(settings): """Convert a plain settings dict into the GUI variable spec. Maps each key to a ``(widget_type, options, default_value)`` triple, using combo boxes for keys with known enumerated options and inferring check/entry widgets otherwise. :param settings: mapping of setting names to default values. :returns: mapping ``key -> (var_type, options, default_value)`` ready for :func:`spacr.gui_utils.create_input_field` or for :meth:`spacr.qt.screens.settings_model.SettingsWidgets.build_sections`. """ torchvision_models = _torchvision_model_names() cellpose_models = _cellpose_model_names() chan_list = ['[0,1,2,3,4,5,6,7,8]','[0,1,2,3,4,5,6,7]','[0,1,2,3,4,5,6]','[0,1,2,3,4,5]','[0,1,2,3,4]','[0,1,2,3]', '[0,1,2]', '[0,1]', '[0]', '[0,0]'] variables = {} special_cases = { 'analysis_mode': ('combo', [('regression', 'regression — fit every guide at ' 'once in the chosen model'), ('guide_permutation', 'guide permutation — test ' 'each guide on its own, ' 'wells reshuffled within ' 'each plate')], 'regression'), 'grna_statistic': ('combo', ['pearson', 'rank'], 'pearson'), 'p_threshold_kind': ('combo', ['adjusted', 'raw'], 'adjusted'), 'metadata_type': ('combo', _metadata_type_choices(), 'cellvoyager'), 'plaque_mode': ('combo', ['plaque', 'figure'], 'plaque'), 'watch_pipeline': ('combo', ['mask', 'mask_measure', 'mask_measure_classify'], 'mask'), 'watch_normalization_pool': ('combo', ['per_field', 'fixed_map'], 'per_field'), 'microscope_driver': ('combo', ['simulated', 'pycromanager'], 'simulated'), 'anndata_format': ('combo', ['h5ad', 'parquet', 'r', 'all'], 'h5ad'), 'channels': ('combo', chan_list, '[0,1,2,3]'), 'train_channels': ('combo', ["['r','g','b']", "['r','g']", "['r','b']", "['g','b']", "['r']", "['g']", "['b']"], "['r','g','b']"), 'channel_dims': ('combo', chan_list, '[0,1,2,3]'), 'dataset_mode': ('combo', ['annotation', 'metadata'], 'metadata'), 'cov_type': ('combo', ['HC0', 'HC1', 'HC2', 'HC3', None], None), 'crop_mode': ('combo', [repr([role]) for role in ALL_ROLES] + [repr(['cell', role]) for role in ALL_ROLES if role != 'cell'], "['cell']"), 'timelapse_mode': ('combo', ['trackastra', 'ultrack', 'trackpy', 'iou', 'btrack', 'timeflows', 'sam2'], 'trackastra'), 'timelapse_events_encoder': ('combo', ['small', 'videomae'], 'small'), 'timelapse_events_video_device': ('combo', ['auto', 'cpu', 'cuda', 'mps'], 'auto'), 'train_mode': ('combo', ['erm', 'irm'], 'erm'), 'clustering': ('combo', ['dbscan', 'kmean'], 'dbscan'), 'reduction_method': ('combo', ['umap', 'tsne'], 'umap'), 'model_name': ('combo', cellpose_models, cellpose_models[0]), 'regression_type': ('combo', _regression_type_choices(), 'mixed'), 'regression_backend': ('combo', _regression_backend_choices(), 'statsmodels (CPU)'), 'timelapse_objects': ('combo', ["['cell']", "['nucleus']", "['pathogen']", "['organelle']", "['cell', 'nucleus']", "['cell', 'pathogen']", "['cell', 'organelle']", "['nucleus', 'pathogen']", "['nucleus', 'organelle']", "['cell', 'nucleus', 'pathogen']", "['cell', 'nucleus', 'organelle']", "['cell', 'nucleus', 'pathogen', 'organelle']"], "['cell']"), 'model_type': ('combo', torchvision_models, 'resnet50'), 'cam_type': ('combo', _cam_type_choices(), 'gradcam'), 'compression': ('combo', ['lzw', 'zlib', 'none'], 'lzw'), 'model_type_ml': ('combo', ['xgboost', 'lightgbm', 'catboost', 'random_forest', 'extra_trees', 'gradient_boosting', 'logistic_regression', 'svm', 'mlp'], 'xgboost'), 'optimizer_type': ('combo', ['adamw', 'adam', 'adamax', 'sgd', 'rmsprop', 'nadam', 'radam', 'adagrad', 'adadelta', 'asgd'], 'adamw'), 'schedule': ('combo', ['cosine', 'cosine_warm_restarts', 'reduce_lr_on_plateau', 'step_lr', 'exponential', 'linear', 'none'], 'cosine'), 'loss_type': ('combo', ['auto', 'cross_entropy', 'label_smoothing', 'focal_loss', 'ce_weighted', 'logit_adjust_ce', 'asl', 'binary_cross_entropy_with_logits'], 'auto'), 'class_balance': ('combo', ['none', 'weighted_sampler', 'sqrt_weighted_sampler', 'weighted_loss'], 'none'), 'cv_group_by': ('combo', ['cell', 'field', 'well', 'plate'], 'well'), 'seg_qc': ('combo', ['off', 'report', 'flag', 'stop'], 'report'), 'psf_measurement_source': ('combo', ['original', 'processed'], 'original'), 'confluency_source': ('combo', ['auto', 'masks', 'texture', 'intensity', 'phase'], 'auto'), 'colony_polarity': ('combo', ['auto', 'bright', 'dark'], 'auto'), 'bleach_correction': ('combo', ['none', 'ratio', 'exponential', 'histogram'], 'none'), 'measurement_backend': ('combo', ['sqlite', 'duckdb', 'parquet', 'postgres'], 'sqlite'), 'profiling_normalization': ('combo', ['mad_robustize', 'standardize', 'robustize', 'none'], 'mad_robustize'), 'cell_cycle_method': ('combo', ['measurements', 'xgboost', 'torch', 'all'], 'measurements'), 'wound_source': ('combo', ['texture', 'intensity', 'masks'], 'texture'), 'intensity_calibration_statistic': ('combo', ['foreground', 'median'], 'foreground'), 'time_to_event_mode': ('combo', ['track_end', 'annotated', 'above', 'below', 'fold_change'], 'track_end'), 'time_to_event_origin': ('combo', ['track', 'movie'], 'track'), 'time_to_event_object': ('combo', ['cell', 'nucleus', 'pathogen', 'cytoplasm'], 'cell'), 'psf_operation': ('combo', ['none', 'convolve', 'deconvolve'], 'none'), 'psf_source': ('combo', ['gaussian', 'measured'], 'gaussian'), 'psf_objective': ('combo', ['auto', '10x/0.30 air', '10x/0.45 air', '20x/0.45 air', '20x/0.75 air', '40x/0.95 air', '40x/1.30 oil', '60x/1.20 water', '60x/1.40 oil', '63x/1.40 oil', '100x/1.40 oil', '100x/1.45 oil'], 'auto'), 'psf_path': ('entry', None, None), 'psf_image_sampling_um': ('entry', None, None), 'psf_kernel_sampling_um': ('entry', None, None), 'psf_fwhm_um': ('entry', None, None), 'psf_iterations': ('entry', None, 20), 'enhance_background': ('combo', ['none', 'rolling_ball', 'tophat'], 'none'), 'enhance_denoise': ('combo', ['none', 'gaussian', 'median', 'bilateral', 'nlm', 'tv'], 'none'), 'image_qc_mode': ('combo', ['off', 'report', 'exclude'], 'off'), 'tta_aggregation': ('combo', ['probability_mean', 'majority_vote'], 'probability_mean'), 'replication_method': ('combo', [ ('direct_count', 'Direct parasite counts'), ('size_proxy', 'Area-derived size proxy (legacy)'), ('deep_learning_coming_soon', 'Whole-vacuole deep learning classification — coming soon'), ], 'direct_count'), 'strict_errors': ('combo', [None, True, False], None), 'normalize_by': ('combo', ['fov', 'png'], 'png'), 'agg_type': ('combo', ['mean', 'median'], 'mean'), 'grouping': ('combo', ['mean', 'median'], 'mean'), 'min_max': ('combo', ['allq', 'all'], 'allq'), 'transform': ('combo', ['log', 'sqrt', 'square', 'beta', None], None), 'intercept': ('combo', ['fitted', 'zero', 'control', 'value'], 'fitted'), 'number_of_organelles': ('combo', list(range(MAX_ORGANELLES + 1)), DEFAULT_NUMBER_OF_ORGANELLES), 'organelle_type': ('combo', list(_ORGANELLE_TYPE_ORDER), _ORGANELLE_TYPE_DEFAULT), 'organelle_morphology': ('combo', ['spots', 'network', 'irregular', 'ring'], 'spots'), 'organelle_method': ('combo', ['otsu', 'adaptive', 'log', 'dog', 'ridge', 'hysteresis', 'cellpose', 'unet'], 'otsu'), 'organelle_model_name': ('combo', cellpose_models, cellpose_models[0]), 'organelle_ridge_filter': ('combo', ['frangi', 'sato', 'meijering'], 'frangi'), 'organelle_network_threshold': ('combo', ['otsu', 'adaptive'], 'otsu'), 'organelle_ring_fill_method': ('combo', ['flood', 'convex'], 'flood'), 'summarize_organelles_by': ('combo', ["['cell']","['nucleus']","['pathogen']","['cytoplasm']","['cell', 'nucleus']","['cell', 'pathogen']","['cell', 'cytoplasm']","['cell', 'nucleus', 'pathogen']","['cell', 'nucleus', 'pathogen', 'cytoplasm']",None], None) } primary_widget_keys = tuple( key for key in special_cases if key.startswith('organelle_')) for role in _ORGANELLE_SLOT_ROLES[1:]: for key in primary_widget_keys: slot_key = f"{role}_{key[len('organelle_'):]}" kind, options, default = special_cases[key] special_cases[slot_key] = ( kind, list(options) if isinstance(options, list) else options, default) for key, value in settings.items(): by_value = _value_special_cases(key, value) if by_value is not None: variables[key] = by_value elif key in special_cases: variables[key] = special_cases[key] elif isinstance(value, bool): variables[key] = ('check', None, value) elif isinstance(value, int) or isinstance(value, float): variables[key] = ('entry', None, value) elif isinstance(value, str): variables[key] = ('entry', None, value) elif value is None: variables[key] = ('entry', None, value) elif isinstance(value, list): variables[key] = ('entry', None, str(value)) else: variables[key] = ('entry', None, str(value)) return variables