Source code for spacr.qt.widgets.umap_figure_settings

"""Every Image UMAP setting, live-editable against the STATIC figure.

The live explorer has a settings window whose display options apply to the
figure on screen. This module provides the equivalent for
the screen -- the ordinary, non-live figure the run leaves in
:class:`spacr.qt.widgets.figure_queue.FigureQueue`::

    "the non live image UMAP figure settings should have all the image UMAP
     settings live editable (you should see changes in the graph directly)"

THE COST OF A SETTING IS NOT UNIFORM, and pretending it is would make the
panel unusable. Three tiers, and every field declares which one it is in:

``TIER_STYLE``
    settable on artists that already exist -- dot size, dot colour, opacity,
    outline width. Microseconds; nothing is re-read from disk.
``TIER_REDRAW``
    needs the figure replotted, but **from the same embedding** -- image
    count, image zoom, figure size, which layers are drawn. Tenths of a
    second, because the thumbnails are re-read, so these are debounced.
``TIER_RERUN``
    kept for a later run rather than applied to the finished figure. This
    tier includes ``n_neighbors``, ``min_dist``, the metric and feature filter,
    along with clustering, view-layout and output options. Not every setting
    in this tier changes embedding coordinates. The containing dialog can
    copy these values to the module through its Propagate settings action
    when a propagation callback is available.

Each field declares how this panel applies its changes.
"""
from __future__ import annotations

import logging
from typing import Any, Callable, Dict, List, NamedTuple, Optional, Tuple

from PySide6.QtCore import QTimer, Signal
from PySide6.QtWidgets import (
    QComboBox, QDoubleSpinBox, QFormLayout, QLabel, QLineEdit,
    QSpinBox, QVBoxLayout, QWidget,
)

from .toggle import Toggle
from ..i18n import tr
from ...hyperparam import UMAP_METRICS

LOG = logging.getLogger("spacr.qt.umap_figure_settings")

#: Applied to the artists already on the figure.
TIER_STYLE = "style"
#: Replotted from the SAME embedding.
TIER_REDRAW = "redraw"
TIER_RERUN = "rerun"

#: How long a value has to stop changing before the figure is redrawn. A
#: spin box drag emits a value per step and replotting a montage is not a
#: per-step cost, so every edit coalesces into one render.
APPLY_DEBOUNCE_MS = 250


[docs] class Field(NamedTuple): """One editable Image UMAP setting. :param key: the settings key. :param label: the label shown beside the editor. :param kind: the editor type: ``"int"``, ``"float"``, ``"bool"``, ``"text"``, ``"choice"`` or ``"int_or_none"``. The nullable integer uses a text editor and the ``_int_or_none`` parser. :param low: lower bound for a numeric editor (unused otherwise). :param high: upper bound for a numeric editor (unused otherwise). :param tier: what applying it takes: :data:`TIER_STYLE`, :data:`TIER_REDRAW` or :data:`TIER_RERUN`. :param choices: the options of a ``"choice"`` field. """ key: str label: str kind: str low: float high: float tier: str choices: Tuple[str, ...] = ()
#: Every Image UMAP setting that decides what the figure looks like. #: #: The display half is exactly the "UMAP Display" group the settings panel #: builds (``settings_model._regroup`` for ``umap``), so the two surfaces #: cannot drift into offering different sets of knobs. The rest are the #: reduction and clustering settings needed to reproduce or rerun the view. IMAGE_UMAP_FIELDS: Tuple[Field, ...] = ( Field("dot_size", "Dot size", "int", 1, 4000, TIER_STYLE), Field("point_color", "Dot colour", "text", 0, 0, TIER_STYLE), Field("point_alpha", "Dot opacity", "float", 0.0, 1.0, TIER_STYLE), Field("outline_width", "Outline width", "float", 0.0, 10.0, TIER_STYLE), Field("figuresize", "Figure size", "float", 1.0, 60.0, TIER_REDRAW), Field("image_nr", "Images shown", "int", 0, 100000, TIER_REDRAW), Field("img_zoom", "Image zoom", "float", 0.001, 5.0, TIER_REDRAW), Field("plot_images", "Draw images", "bool", 0, 0, TIER_REDRAW), Field("plot_points", "Draw points", "bool", 0, 0, TIER_REDRAW), Field("plot_outlines", "Draw outlines", "bool", 0, 0, TIER_REDRAW), Field("smooth_lines", "Smooth outlines", "bool", 0, 0, TIER_REDRAW), Field("plot_by_cluster", "Sample per cluster", "bool", 0, 0, TIER_REDRAW), Field("remove_image_canvas", "Cut image canvas", "bool", 0, 0, TIER_REDRAW), Field("black_background", "Black background", "bool", 0, 0, TIER_REDRAW), Field("reduction_method", "Reduction", "choice", 0, 0, TIER_RERUN, ("umap", "tsne", "pca", "isomap", "spectral")), Field("n_neighbors", "Neighbours", "int", 2, 1000000, TIER_RERUN), Field("min_dist", "Minimum distance", "float", 0.0, 1.0, TIER_RERUN), Field("tsne_perplexity", "t-SNE perplexity", "float", 0.01, 1000000, TIER_RERUN), Field("tsne_learning_rate", "t-SNE learning rate", "float", 0.01, 1000000, TIER_RERUN), Field("tsne_early_exaggeration", "t-SNE early exaggeration", "float", 0.01, 1000000, TIER_RERUN), Field("tsne_max_iter", "t-SNE iterations", "int", 250, 10000000, TIER_RERUN), Field("pca_whiten", "PCA whiten", "bool", 0, 0, TIER_RERUN), Field("pca_svd_solver", "PCA solver", "choice", 0, 0, TIER_RERUN, ("auto", "full", "covariance_eigh", "arpack", "randomized")), Field("isomap_n_neighbors", "Isomap neighbours", "int", 2, 1000000, TIER_RERUN), Field("isomap_path_method", "Isomap path method", "choice", 0, 0, TIER_RERUN, ("auto", "FW", "D")), Field("spectral_affinity", "Spectral affinity", "choice", 0, 0, TIER_RERUN, ("nearest_neighbors", "rbf")), Field("spectral_n_neighbors", "Spectral neighbours", "int", 2, 1000000, TIER_RERUN), Field("metric", "Metric", "choice", 0, 0, TIER_RERUN, tuple(UMAP_METRICS)), Field("clustering", "Clustering", "choice", 0, 0, TIER_RERUN, ("dbscan", "kmeans")), Field("eps", "DBSCAN eps", "float", 0.0, 1000.0, TIER_RERUN), Field("min_samples", "Minimum samples", "int", 1, 1000000, TIER_RERUN), Field("remove_cluster_noise", "Drop noise points", "bool", 0, 0, TIER_RERUN), Field("remove_highly_correlated", "Drop correlated features", "bool", 0, 0, TIER_RERUN), Field("log_data", "Log-transform features", "bool", 0, 0, TIER_RERUN), Field("filter_by", "Feature filter", "text", 0, 0, TIER_RERUN), Field("row_limit", "Row limit", "int_or_none", 0, 100000000, TIER_RERUN), Field("color_by", "Colour by column", "text", 0, 0, TIER_RERUN), Field("plot_cluster_grids", "Cluster grid figure", "bool", 0, 0, TIER_RERUN), Field("save_figure", "Save figure as PDF", "bool", 0, 0, TIER_RERUN), Field("umap_canvas_width", "Live canvas width", "int", 200, 4000, TIER_RERUN), Field("umap_sidebar_width", "Live sidebar width", "int", 120, 2000, TIER_RERUN), ) #: ``key -> tier``, for callers that only need the classification. FIELD_TIERS: Dict[str, str] = {f.key: f.tier for f in IMAGE_UMAP_FIELDS}
[docs] def keys_for_tier(tier: str) -> Tuple[str, ...]: """Every setting key in ``tier``. :param tier: :data:`TIER_STYLE`, :data:`TIER_REDRAW` or :data:`TIER_RERUN`; any other value gives an empty tuple. """ return tuple(f.key for f in IMAGE_UMAP_FIELDS if f.tier == tier)
[docs] def live_keys() -> Tuple[str, ...]: """The settings that reach the figure already on screen.""" return tuple(f.key for f in IMAGE_UMAP_FIELDS if f.tier in (TIER_STYLE, TIER_REDRAW))
def _is_fixed_colour(point_color) -> bool: """Whether ``point_color`` names one colour rather than "per cluster".""" text = str(point_color or "").strip().lower() if text in {"", "cluster", "viridis"}: return False from matplotlib.colors import is_color_like return bool(is_color_like(str(point_color).strip()))
[docs] def restyle_umap_figure(fig, values: Dict[str, Any]) -> bool: """Push the cheap settings onto the artists ``fig`` already carries. Nothing is re-read and nothing is recomputed, so the points cannot move. The original per-cluster face colours are stashed on the collection the first time a fixed colour is applied. Without that, switching the dot colour to ``red`` and back to ``cluster`` would leave every point red: the per-cluster colours were overwritten and there is nowhere left to read them from short of replotting. :param fig: the matplotlib figure; ``None`` returns False. :param values: setting key to value; only ``dot_size``, ``point_alpha``, ``outline_width`` and ``point_color`` are read, and a missing key is left alone. :returns: True when any artist was changed. """ if fig is None: return False touched = False size = values.get("dot_size") alpha = values.get("point_alpha") width = values.get("outline_width") colour = values.get("point_color") for axes in fig.get_axes(): for collection in axes.collections: if size is not None: try: collection.set_sizes([float(size)]) touched = True except Exception: LOG.debug("could not set the dot size", exc_info=True) if alpha is not None: try: collection.set_alpha(max(0.0, min(1.0, float(alpha)))) touched = True except Exception: LOG.debug("could not set the dot opacity", exc_info=True) if colour is not None: try: if getattr(collection, "_spacr_base_facecolor", None) is None: collection._spacr_base_facecolor = ( collection.get_facecolor().copy()) if _is_fixed_colour(colour): collection.set_facecolor(str(colour).strip()) else: collection.set_facecolor( collection._spacr_base_facecolor) touched = True except Exception: LOG.debug("could not set the dot colour", exc_info=True) if width is not None: for line in axes.lines: try: line.set_linewidth(max(0.1, float(width))) touched = True except Exception: LOG.debug("could not set the outline width", exc_info=True) return touched
[docs] def redraw_umap_figure(fig, payload: Dict[str, Any], values: Dict[str, Any]) -> bool: """Replot ``fig`` FROM THE SAME EMBEDDING with ``values``. The embedding in ``payload`` is read, never recomputed: this is what makes "live apply" honest on a projection. Every point keeps its coordinates and its neighbours; only what is drawn on top of them changes. :param fig: the matplotlib figure; ``None`` returns False. :param payload: dict holding ``embedding`` (array of shape (N, 2)), ``plot_labels`` or ``labels`` (one per point), and optionally ``records`` with ``image`` paths and ``theme_colors``. A missing or mismatched embedding or labels returns False. :param values: setting key to value; an unset key uses the shipped default. :returns: True when the figure was replotted. """ import numpy as np if fig is None or not isinstance(payload, dict): return False embedding = np.asarray(payload.get("embedding"), dtype=float) if embedding.ndim != 2 or embedding.shape[0] == 0: return False labels = payload.get("plot_labels") if labels is None: labels = payload.get("labels") if labels is None: return False labels = np.asarray(labels) if len(labels) != len(embedding): return False from ...utils import (assign_colors, generate_colors, plot_clusters, plot_umap_images, _plot_theme_colors, _style_plot_axes) def _get(key, fallback): """One figure setting, or the fallback when it is unset.""" value = values.get(key) return fallback if value is None else value black_background = bool(_get("black_background", True)) figuresize = float(_get("figuresize", 10)) unique_labels = np.unique(labels) colors = generate_colors(len(unique_labels), black_background) colors, _index = assign_colors(unique_labels, colors) centers = [np.mean(embedding[labels == label], axis=0) for label in unique_labels] theme = _plot_theme_colors(black_background, payload.get("theme_colors")) fig.clear() axes = fig.add_subplot(111) _style_plot_axes(fig, axes, theme) try: fig.set_size_inches(figuresize, figuresize) except Exception: LOG.debug("could not resize the figure", exc_info=True) plot_clusters( axes, embedding, labels, colors, centers, bool(_get("plot_outlines", True)), bool(_get("plot_points", True)), bool(_get("smooth_lines", True)), figuresize, float(_get("dot_size", 50)), False, point_color=_get("point_color", "cluster"), point_alpha=float(_get("point_alpha", 0.65)), outline_width=float(_get("outline_width", 1.0)), ) records = payload.get("records") or [] image_paths = [record.get("image") for record in records] if (bool(_get("plot_images", False)) and len(image_paths) == len(embedding) and any(path is not None for path in image_paths)): try: plot_umap_images( axes, image_paths, embedding, labels, int(_get("image_nr", 16)), float(_get("img_zoom", 0.5)), colors, bool(_get("plot_by_cluster", True)), bool(_get("remove_image_canvas", False)), False) except Exception: LOG.debug("could not redraw the image overlay", exc_info=True) fig._spacr_umap_payload = payload return True
[docs] def apply_to_figure(fig, payload: Dict[str, Any], values: Dict[str, Any], previous: Optional[Dict[str, Any]] = None) -> str: """Apply ``values`` to a finished Image UMAP figure. :param fig: the matplotlib figure the Image UMAP was drawn into. :param payload: the figure's saved embedding payload, used for a redraw. :param values: setting key to value; keys that differ from ``previous`` decide the work: any redraw-tier key replots, otherwise any style-tier key restyles. :param previous: the values last applied; ``None`` treats every key as changed. :returns: ``"redraw"``, ``"style"`` or ``""`` -- what it actually had to do, so the caller can say whether the graph followed and can skip re-rasterising when nothing changed. """ previous = previous or {} changed = {key for key, value in (values or {}).items() if key not in previous or previous[key] != value} if not changed: return "" if any(FIELD_TIERS.get(key) == TIER_REDRAW for key in changed): return "redraw" if redraw_umap_figure(fig, payload, values) else "" if any(FIELD_TIERS.get(key) == TIER_STYLE for key in changed): return "style" if restyle_umap_figure(fig, values) else "" return ""
[docs] class UmapFigureSettings(QWidget): """Edit Image UMAP figure settings with debounced live application. Each change emits :attr:`settings_changed` with the complete settings dictionary after :data:`APPLY_DEBOUNCE_MS`. The containing figure-settings window applies style and redraw tiers without a separate Apply action. Rerun-tier values do not update the current figure; the containing dialog can propagate them to the module for a later run. :param values: the settings to open on. ``None`` opens on the defaults. :param parent: parent widget. """ #: Debounced, and carries the WHOLE value dict rather than the delta: the #: applier has to decide which tier changed, and it can only do that #: against a complete picture. settings_changed = Signal(dict) def __init__(self, values: Optional[Dict[str, Any]] = None, parent=None): """Build the Image UMAP settings panel, grouped by when a change applies. The three tiers are the point: settings that restyle the points already drawn, settings that redraw the graph from the same embedding so no point moves, and settings that only take effect on the next run. :param values: the settings to open with; ``None`` entries fall back to the module defaults. :param parent: parent widget, or ``None``. """ super().__init__(parent) self._editors: Dict[str, QWidget] = {} self._timer = QTimer(self) self._timer.setSingleShot(True) self._timer.setInterval(APPLY_DEBOUNCE_MS) self._timer.timeout.connect(self._emit_changed) seeded = dict(self._defaults()) seeded.update({k: v for k, v in (values or {}).items() if v is not None}) root = QVBoxLayout(self) root.setContentsMargins(0, 0, 0, 0) for tier, heading, note in ( (TIER_STYLE, "Applies now", "Set straight onto the points already drawn."), (TIER_REDRAW, "Applies now (redraws the graph)", "The graph is drawn again from the SAME embedding, so no point " "moves. Debounced, because the thumbnails are re-read."), (TIER_RERUN, "Applies on the next run", tr("These settings include embedding, clustering, view and output " "options. They do not update the current figure. Use Propagate " "settings, when available, to copy them to the module for the " "next run.")), ): title = QLabel(f"<b>{heading}</b>") root.addWidget(title) caption = QLabel(f"<span style='color:gray;'>{note}</span>") caption.setWordWrap(True) root.addWidget(caption) form = QFormLayout() for field in IMAGE_UMAP_FIELDS: if field.tier != tier: continue editor = self._editor(field, seeded.get(field.key)) editor.setProperty("settingKey", field.key) editor.setProperty("umapSettingTier", field.tier) self._editors[field.key] = editor form.addRow(field.label, editor) editor._spacr_setting_label = form.labelForField(editor) root.addLayout(form) reducer = self._editors.get("reduction_method") if isinstance(reducer, QComboBox): reducer.currentTextChanged.connect(self._refresh_reducer_fields) affinity = self._editors.get("spectral_affinity") if isinstance(affinity, QComboBox): affinity.currentTextChanged.connect(self._refresh_reducer_fields) self._refresh_reducer_fields() self._applied: Dict[str, Any] = dict(self.values()) self._initial: Dict[str, Any] = dict(self._applied) def _refresh_reducer_fields(self, *_args) -> None: """Grey reducer settings the selected static-figure recipe ignores.""" reducer = self._editors.get("reduction_method") method = reducer.currentText() if isinstance(reducer, QComboBox) \ else "umap" families = { "umap": {"n_neighbors", "min_dist"}, "tsne": { "tsne_perplexity", "tsne_learning_rate", "tsne_early_exaggeration", "tsne_max_iter", }, "pca": {"pca_whiten", "pca_svd_solver"}, "isomap": {"isomap_n_neighbors", "isomap_path_method"}, "spectral": {"spectral_affinity", "spectral_n_neighbors"}, } owned = set().union(*families.values()) active = families.get(method, set()) for key in owned: editor = self._editors.get(key) if editor is None: continue enabled = key in active if key == "spectral_n_neighbors" and method == "spectral": affinity = self._editors.get("spectral_affinity") enabled = not isinstance(affinity, QComboBox) or \ affinity.currentText() == "nearest_neighbors" editor.setEnabled(enabled) label = getattr(editor, "_spacr_setting_label", None) if label is not None: label.setEnabled(enabled) @staticmethod def _defaults() -> Dict[str, Any]: """Read the Image UMAP defaults. :returns: the defaults, or an empty mapping when they cannot be read -- the panel then seeds from the passed values alone rather than failing to build. """ from ...settings import set_default_umap_image_settings try: return set_default_umap_image_settings({}) except Exception: LOG.debug("could not read the Image UMAP defaults", exc_info=True) return {} def _editor(self, field: Field, value) -> QWidget: """Build the control for one setting. ``row_limit`` and the other nullable fields get a text box rather than a spin box: ``None`` means "every row", and a spin box has no way to say that, so it is typed rather than clamped. :param field: the setting's declaration. :param value: its current value; an unparseable one leaves the control at its own default rather than raising. :returns: the control. """ if field.kind == "bool": box = Toggle() box.setChecked(bool(value)) box.toggled.connect(self._schedule) return box if field.kind == "choice": combo = QComboBox() combo.setSizeAdjustPolicy( QComboBox.AdjustToMinimumContentsLengthWithIcon) combo.setMinimumContentsLength(12) combo.addItems(list(field.choices)) text = str(value or "").strip().lower() if text in field.choices: combo.setCurrentIndex(field.choices.index(text)) combo.currentIndexChanged.connect(self._schedule) return combo if field.kind == "int": spin = QSpinBox() spin.setRange(int(field.low), int(field.high)) if value is not None: try: spin.setValue(int(float(value))) except (TypeError, ValueError): pass spin.valueChanged.connect(self._schedule) return spin if field.kind == "float": spin = QDoubleSpinBox() spin.setDecimals(3) spin.setRange(float(field.low), float(field.high)) spin.setSingleStep(0.05) if value is not None: try: spin.setValue(float(value)) except (TypeError, ValueError): pass spin.valueChanged.connect(self._schedule) return spin edit = QLineEdit() if value is not None: edit.setText(str(value)) edit.textChanged.connect(self._schedule) return edit
[docs] def values(self) -> Dict[str, Any]: """Every setting the window holds, keyed as the settings dict keys.""" out: Dict[str, Any] = {} for field in IMAGE_UMAP_FIELDS: editor = self._editors.get(field.key) if editor is None: continue if isinstance(editor, Toggle): out[field.key] = bool(editor.isChecked()) elif isinstance(editor, QComboBox): out[field.key] = editor.currentText() elif isinstance(editor, QLineEdit): text = editor.text().strip() if field.kind == "int_or_none": out[field.key] = _int_or_none(text) else: out[field.key] = text or None else: out[field.key] = editor.value() return out
[docs] def live_values(self) -> Dict[str, Any]: """Only the half that reaches the figure already on screen.""" live = set(live_keys()) return {k: v for k, v in self.values().items() if k in live}
[docs] def initial_values(self) -> Dict[str, Any]: """What the window opened on -- what Cancel puts back.""" return dict(self._initial)
def _schedule(self, *_args) -> None: """Queue an emit after a control changed. Debounced, so dragging a spin box costs one redraw rather than one per step. :param _args: whatever the emitting control passes; ignored. """ self._timer.start()
[docs] def flush(self) -> None: """Emit any pending change now instead of on the timer.""" if self._timer.isActive(): self._timer.stop() self._emit_changed()
def _emit_changed(self) -> None: """Announce the settings, unless nothing actually changed. Comparing against what was last applied is what stops a debounce that fired on a value the user typed and then undid from redrawing the graph for no change. """ values = self.values() if values == self._applied: return self._applied = dict(values) self.settings_changed.emit(values)
def _int_or_none(text: str) -> Optional[int]: """``"1000"`` -> 1000; blank, ``none`` and junk -> ``None`` (every row).""" cleaned = str(text or "").strip() if not cleaned or cleaned.lower() in {"none", "null", "all"}: return None try: return int(float(cleaned)) except (TypeError, ValueError): return None __all__ = [ "APPLY_DEBOUNCE_MS", "Field", "FIELD_TIERS", "IMAGE_UMAP_FIELDS", "TIER_REDRAW", "TIER_RERUN", "TIER_STYLE", "UmapFigureSettings", "apply_to_figure", "keys_for_tier", "live_keys", "redraw_umap_figure", "restyle_umap_figure", ]