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``
changes the embedding itself -- ``n_neighbors``, ``min_dist``, the metric,
the feature filter. There is no honest way to apply one of these to a
finished figure: recomputing moves every point, and the arrangement the
user was reading is the whole value of the projection. They are editable
and propagated, and the panel says they land on the next run.
The tier is a fact about the artists and the data, not a policy, which is why
it lives beside the field rather than in the caller.
"""
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 ...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"
#: Changes the embedding; saved for the next run.
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"`` or ``"choice"``.
: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 immediately; rerun-tier values are
retained for the next embedding run. No separate Apply action is required.
: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",
"These change the embedding itself. Applying one here would "
"move every point and lose the arrangement you are reading, so "
"they are saved and propagated instead."),
):
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",
]