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