"""Settings for the Gate Editor, in tabs.
Split by what the setting is ABOUT, not by which widget reads it:
``General``
How much data is loaded and how the cloud is drawn. The sampling setting
lives here because it is the answer to the module being laggy on a real
dataset -- a screen of a million objects is slow to draw, slow to
hit-test, and no more informative than a fifth of them.
``2D``
Gating on a scatter: the tools, the shapes, and the clustering that
proposes shapes for you.
``3D``
Gating in a volume. The settings are here; the workspace they drive is
the next piece of work.
Every setting is a field on :class:`GateEditorSettings`, which is a plain
frozen dataclass -- no Qt -- so what the editor does with a setting can be
tested without building a dialog, and so the whole set can be written to disk
as one thing later.
**Sampling is a display concern only.** A gate drawn on 20% of the objects is
still a statement about all of them; the export re-reads the full table and
applies the gate there (:func:`spacr.filters.gate_mask_over_table`). Nothing
downstream of the plot ever sees the sample.
"""
from __future__ import annotations
from dataclasses import dataclass, replace
from typing import Dict, Mapping, Sequence, Tuple
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QComboBox, QDialog, QDialogButtonBox, QDoubleSpinBox,
QFormLayout, QLabel, QLineEdit, QPushButton, QScrollArea, QSpinBox,
QTabWidget, QVBoxLayout, QWidget,
)
from ..theme import SPACING
from .toggle import Toggle
#: Colour maps offered for the density/colour axis. Perceptually uniform ones
#: first: on a scatter of a million objects the colour IS the reading, and
#: `jet` puts a bright band in the middle of a flat distribution.
COLOUR_MAPS: Tuple[str, ...] = (
"viridis", "plasma", "magma", "inferno", "cividis", "turbo",
"Greys", "Blues", "Reds", "coolwarm", "RdBu_r",
)
#: Ways to colour the points. A column name may be used instead.
COLOUR_BY: Tuple[str, ...] = ("density", "flat")
#: Axis scales offered. All are matplotlib SCALES, which change how values are
#: laid out and never what they are -- so a gate drawn before the change still
#: selects the same objects after it.
AXIS_SCALES: Tuple[str, ...] = ("linear", "log", "symlog", "logit")
#: How the cloud is drawn once there are more points than pixels.
RESOLUTION_MODES: Tuple[str, ...] = ("points", "hexbin", "histogram", "density")
#: What a gate is drawn in.
GATE_MODES: Tuple[str, ...] = ("2D", "3D", "xD")
#: Keys a filter can be merged back on. Defaults are what spaCR joins object
#: tables and png_list on; the object label alone is not unique across fields.
MERGE_KEYS: Tuple[str, ...] = (
"plateID", "rowID", "columnID", "fieldID", "object_label",
)
#: Clustering algorithms the picker offers, RE-EXPORTED from the module that
#: implements them so this dialog cannot list one the code does not have.
#:
#: That is not hypothetical tidiness. This tuple used to be written here and
#: read "dbscan", "hdbscan", "kmeans", while `cluster_gates` called DBSCAN
#: whatever it said -- so choosing k-means returned DBSCAN's answer under
#: another name. k-means is not in the list any more rather than newly
#: written, because it has to be told the number of clusters and the gate
#: editor has no such setting; adding one to justify a list entry is the
#: wrong way round, and the tooltip beside this control already argues
#: against k-means on a cytometry scatter.
from .gate_spec import CLUSTER_METHODS # noqa: E402 (re-export)
@dataclass(frozen=True)
[docs]
class GateEditorSettings:
"""Everything the Gate Editor is configured by.
Frozen: settings are replaced wholesale rather than mutated, so a screen
can compare what it has against what it is given and re-read the table
only when something that actually costs a read has changed.
"""
#: Fraction of the table loaded, in (0, 1]. The lag fix: gates are drawn
#: on this, and applied to everything on export.
sample_fraction: float = 1.0
#: Hard row cap after sampling. 0 means none. Ten thousand by default:
#: past that a scatter is drawing more markers than the screen has pixels,
#: and the large-data raster kicks in and takes the per-point settings
#: with it.
max_points: int = 10_000
colour_map: str = "viridis"
#: What the colour map is applied TO. "density" is the default because a
#: cytometry scatter has no colour axis and the overlap is the reading.
#: Any column name is also valid.
colour_by: str = "density"
#: Axis scales. These are DISPLAY transforms -- matplotlib scales -- so a
#: gate's coordinates keep meaning the measurement they were drawn on. A
#: transform that rewrote the values would silently invalidate every gate
#: already drawn, which is why z-score and min-max are not offered here.
x_scale: str = "linear"
y_scale: str = "linear"
point_size: float = 6.0
point_opacity: float = 0.6
#: How the cloud is rendered. "points" is one marker per object; the rest
#: bin first, which is what makes a million objects draw at all.
resolution_mode: str = "points"
#: Bins per axis when binning. Not a "resolution" in pixels: the bin is
#: the unit the data is summarised into, and saying so in data terms is
#: what makes the same setting mean the same thing at any zoom.
bins: int = 200
show_grid: bool = False
#: Kept so a saved settings set from before x_scale/y_scale still asks
#: for a log axis. `x_scale` wins when both are set: someone who chose a
#: scale has said what they mean.
log_x: bool = False
log_y: bool = False
[docs]
def scale_for(self, axis: str) -> str:
"""The scale for "x" or "y", honouring the retired log flags.
:param axis: ``"x"`` or ``"y"``; reads ``<axis>_scale``, and when that
is ``"linear"`` the retired ``log_<axis>`` flag turns it into
``"log"``.
"""
chosen = getattr(self, f"{axis}_scale", "linear")
if chosen != "linear":
return chosen
return "log" if getattr(self, f"log_{axis}", False) else "linear"
default_tool: str = "rectangle"
gate_line_width: float = 0.5
#: Ring the gated objects, rather than only outlining the gate.
highlight_gated: bool = True
#: Keys a filter is merged back onto the object tables with.
merge_keys: Tuple[str, ...] = MERGE_KEYS
#: Magic wand: how far apart two objects can be and still be
#: neighbours, in scaled units. Small enough to stop at a gap.
wand_tolerance: float = 0.05
#: How far from the click the wand may reach at all. Without a ceiling a
#: single chain of objects bridging two populations merges them.
wand_max_radius: float = 0.35
cluster_method: str = "dbscan"
cluster_eps: float = 0.5
cluster_min_samples: int = 20
cluster_scale: bool = True
#: Search the clustering hyperparameters instead of taking the values
#: above. What "Walk" means everywhere else in spaCR: try the space, show
#: each result as it arrives, let the user pick.
cluster_walk: bool = False
cluster_walk_steps: int = 12
#: How many axes are DRAWN: "2D" or "3D".
#:
#: "xD" used to be a third value here, and that was the mistake. xD is
#: not a dimensionality -- it is a statement about WHAT THE AXES ARE
#: (components rather than raw measurements), and it is orthogonal to how
#: many of them are drawn. Gating PC1 vs PC2 in 2D and PC1/PC2/PC3 in 3D
#: are both legitimate, and one exclusive button group could express
#: neither: choosing xD silently chose a dimensionality too.
gate_mode: str = "2D"
#: Project the measurements onto components first, and gate on those.
#: Independent of :attr:`gate_mode`.
xd_projection: bool = False
#: How xD projects. PCA is always available; the others need a package.
reduction: str = "pca"
#: How many components xD produces. Three, so the 3D view has a Z.
components: int = 3
#: Which measurements xD reduces, as ``{kind: [group names]}`` over
#: :data:`spacr.column_groups.GROUP_KINDS`.
#:
#: EMPTY MEANS EVERY NUMERIC COLUMN, which is what xD did before there
#: was a picker, so an existing session behaves exactly as it did. It is
#: also the setting most worth changing: reducing over all 400 columns
#: buries the phenotype, and that set includes identifiers -- feeding a
#: plate id to UMAP embeds the plate, which is the batch effect rather
#: than the biology.
reduction_groups: Mapping[str, Sequence[str]] = None
#: Individual columns ticked by hand, ADDED to whatever the groups
#: select. The request asks for both, not either.
reduction_columns: Sequence[str] = ()
#: UMAP only. How much of the data each point is placed against.
xd_n_neighbors: int = 15
#: UMAP only. How tightly points may pack.
xd_min_dist: float = 0.1
#: t-SNE only. Clamped to the sample size by the reducer.
xd_perplexity: float = 30.0
#: What a merge does with a primary object that has no children.
merge_na: str = "keep"
#: Which object everything else is rolled up onto. Decides what a row of
#: the merged table MEANS, so it is a choice rather than an assumption.
merge_primary: str = "cell"
#: column -> aggregation, beating the rules worked out from the column's
#: name. A default that is right most of the time is a wrong answer nobody
#: can find the rest of the time, so every one of them is overridable.
merge_overrides: Mapping[str, str] = None
[docs]
def __post_init__(self) -> None:
"""Normalise the mode and freeze the mutable defaults.
A settings file written while ``xD`` was a third gate mode is migrated:
it meant "project, and give me a Z" -- xD produced three components
precisely so the 3D view had one -- so it becomes 3D with the projection
turned on.
"""
if str(self.gate_mode).strip().lower() == "xd":
object.__setattr__(self, "gate_mode", "3D")
object.__setattr__(self, "xd_projection", True)
object.__setattr__(self, "merge_overrides",
dict(self.merge_overrides or {}))
object.__setattr__(self, "reduction_groups",
{k: tuple(v) for k, v in
dict(self.reduction_groups or {}).items()})
object.__setattr__(self, "reduction_columns",
tuple(self.reduction_columns or ()))
z_axis: str = ""
#: Voxels per axis in the 3D workspace.
voxel_bins: int = 64
#: Snap the camera to the nearest axis when a spin ends. Off by default:
#: on, every spin ended square-on with one measurement lying flat along
#: the bottom, which read as the volume refusing to stay where it was
#: turned to.
snap_to_axis: bool = False
spin_speed: float = 1.0
[docs]
def replaced(self, **changes) -> "GateEditorSettings":
"""A copy with ``changes`` applied."""
return replace(self, **changes)
[docs]
def costs_a_reload(self, other: "GateEditorSettings") -> bool:
"""Whether moving to ``other`` means re-reading the table.
Only two settings do. Everything else is drawing, and re-reading a
large table because the user changed a colour map is exactly the kind
of lag this dialog exists to remove.
:param other: the settings being moved to; only their
``sample_fraction`` and ``max_points`` are compared with these.
"""
return (self.sample_fraction != other.sample_fraction
or self.max_points != other.max_points)
[docs]
class GateSettingsDialog(QDialog):
"""The tabbed settings window.
Emits :attr:`settings_changed` as the user edits, so the settings that
CAN be applied live are, without waiting for OK. The ones that cannot --
sampling, which means re-reading the table -- are applied by the screen
when it sees them change, which is why the signal carries the whole
settings object rather than one field.
:param settings: the settings to edit. Held, not copied -- the dialog is
live and emits :attr:`settings_changed` as controls move, so a caller
wanting cancel-to-revert keeps its own copy.
:param parent: parent widget.
:param columns: the columns currently loaded, for the aggregation table.
Empty is legitimate and means the screen has no data yet.
"""
#: The settings changed. Carries a whole :class:`GateEditorSettings`.
settings_changed = Signal(object)
#: The per-column aggregation table was asked for. The dialog does not own
#: it: only the screen knows which columns are loaded.
aggregation_rules_requested = Signal()
def __init__(self, settings: GateEditorSettings, parent=None, *,
columns: Tuple[str, ...] = ()):
"""Build the settings dialog over a gate-editor settings record.
:param settings: the settings to edit; every change is emitted rather
than applied on close, so the scatter follows the dialog live.
:param parent: parent widget, or ``None``.
:param columns: the loaded table's measurements, offered wherever a
setting names one.
"""
super().__init__(parent)
self.setWindowTitle("Gate editor settings")
self.setObjectName("GateSettingsDialog")
self._settings = settings
self._live = True
outer = QVBoxLayout(self)
outer.setSpacing(SPACING["sm"])
self.tabs = QTabWidget(self)
self.tabs.setObjectName("GateSettingsTabs")
outer.addWidget(self.tabs, 1)
self.tabs.addTab(self._general_tab(), "General")
self.tabs.addTab(self._two_d_tab(), "2D")
self.tabs.addTab(self._three_d_tab(columns), "3D")
self.tabs.addTab(self._xd_tab(columns), "xD")
buttons = QDialogButtonBox(QDialogButtonBox.Close, self)
buttons.rejected.connect(self.accept)
buttons.accepted.connect(self.accept)
outer.addWidget(buttons)
def _general_tab(self) -> QWidget:
"""Build the General page: sampling, colours, scales and grid.
:returns: the page widget.
"""
page = QWidget(self)
form = QFormLayout(page)
self._sample = QSpinBox(page)
self._sample.setRange(1, 100)
self._sample.setSuffix(" %")
self._sample.setValue(int(round(self._settings.sample_fraction * 100)))
self._sample.setToolTip(
"How much of the table to load. Drawing and hit-testing a million "
"objects is what makes this module slow; a fifth of them is the "
"same cloud. Gates are applied to EVERY object on export, whatever "
"fraction was loaded.")
self._sample.valueChanged.connect(
lambda v: self._change(sample_fraction=max(0.01, v / 100.0)))
form.addRow("Load", self._sample)
self._max_points = QSpinBox(page)
self._max_points.setRange(0, 10_000_000)
self._max_points.setSingleStep(10_000)
self._max_points.setSpecialValueText("no cap")
self._max_points.setValue(self._settings.max_points)
self._max_points.setToolTip(
"A hard ceiling applied after the percentage, for the case where "
"even a small fraction of a very large table is too much.")
self._max_points.valueChanged.connect(
lambda v: self._change(max_points=int(v)))
form.addRow("At most", self._max_points)
self._cmap = QComboBox(page)
self._cmap.addItems(COLOUR_MAPS)
self._cmap.setCurrentText(self._settings.colour_map)
self._cmap.setToolTip(
"Colour map used for dense scatter plots. Perceptually uniform "
"maps are listed first because nonuniform brightness can create "
"apparent structure in a uniform distribution.")
self._cmap.currentTextChanged.connect(
lambda v: self._change(colour_map=v))
form.addRow("Colour map", self._cmap)
self._resolution = QComboBox(page)
self._resolution.addItems(RESOLUTION_MODES)
self._resolution.setCurrentText(self._settings.resolution_mode)
self._resolution.setToolTip(
"Rendering method for datasets with more objects than available "
"pixels. Hexagonal, histogram and density binning preserve local "
"object density that would be obscured by overplotted points.")
self._resolution.currentTextChanged.connect(
lambda v: self._change(resolution_mode=v))
form.addRow("Data resolution", self._resolution)
self._bins = QSpinBox(page)
self._bins.setRange(10, 2000)
self._bins.setValue(self._settings.bins)
self._bins.setToolTip(
"Number of bins per data axis. This value is independent of "
"display resolution and zoom.")
self._bins.valueChanged.connect(lambda v: self._change(bins=int(v)))
form.addRow("Bins", self._bins)
self._point_size = QDoubleSpinBox(page)
self._point_size.setRange(0.5, 60.0)
self._point_size.setSingleStep(0.5)
self._point_size.setValue(self._settings.point_size)
self._point_size.valueChanged.connect(
lambda v: self._change(point_size=float(v)))
form.addRow("Point size", self._point_size)
self._opacity = QDoubleSpinBox(page)
self._opacity.setRange(0.02, 1.0)
self._opacity.setSingleStep(0.05)
self._opacity.setValue(self._settings.point_opacity)
self._opacity.setToolTip(
"Point opacity. Values below 1 reveal local density through "
"overlapping marks.")
self._opacity.valueChanged.connect(
lambda v: self._change(point_opacity=float(v)))
form.addRow("Point opacity", self._opacity)
self._colour_by = QComboBox(page)
self._colour_by.setEditable(True)
self._colour_by.addItems(COLOUR_BY)
self._colour_by.setCurrentText(self._settings.colour_by)
self._colour_by.setToolTip(
"Variable mapped to colour. 'density' represents local point "
"density, 'flat' uses one colour, and a column name maps values "
"from that column.")
self._colour_by.currentTextChanged.connect(
lambda v: self._change(colour_by=v))
form.addRow("Colour by", self._colour_by)
for label, field, current in (("X scale", "x_scale",
self._settings.x_scale),
("Y scale", "y_scale",
self._settings.y_scale)):
box = QComboBox(page)
box.addItems(AXIS_SCALES)
box.setCurrentText(current)
box.setToolTip(
"How the axis is laid out. These change the spacing, never "
"the values, so a gate drawn before the change still selects "
"the same objects. 'log' is skipped on a measurement that "
"reaches zero, where it would draw nothing at all.")
box.currentTextChanged.connect(
lambda v, f=field: self._change(**{f: v}))
setattr(self, f"_{field}", box)
form.addRow(label, box)
self._grid = Toggle("Show grid", page)
self._grid.setChecked(self._settings.show_grid)
self._grid.toggled.connect(lambda v: self._change(show_grid=bool(v)))
form.addRow("", self._grid)
return page
def _two_d_tab(self) -> QWidget:
"""Build the 2D page: the drawing tools, merge keys, wand and clustering.
:returns: the page widget.
"""
page = QWidget(self)
form = QFormLayout(page)
self._tool = QComboBox(page)
self._tool.addItems(("rectangle", "ellipse", "polygon", "threshold",
"wand"))
self._tool.setCurrentText(self._settings.default_tool)
self._tool.setToolTip(
"The tool a drag uses when nothing else is armed. Rectangle, "
"because dragging a box is what everyone tries first.")
self._tool.currentTextChanged.connect(
lambda v: self._change(default_tool=v))
form.addRow("Default tool", self._tool)
self._line_width = QDoubleSpinBox(page)
self._line_width.setRange(0.1, 6.0)
self._line_width.setSingleStep(0.2)
self._line_width.setValue(self._settings.gate_line_width)
self._line_width.valueChanged.connect(
lambda v: self._change(gate_line_width=float(v)))
form.addRow("Gate line width", self._line_width)
self._highlight = Toggle("Ring the gated objects", page)
self._highlight.setChecked(self._settings.highlight_gated)
self._highlight.setToolTip(
"Marks the objects inside each shown gate. The rest of the cloud "
"stays on screen either way — a gate highlights, it never hides.")
self._highlight.toggled.connect(
lambda v: self._change(highlight_gated=bool(v)))
form.addRow("", self._highlight)
self._merge_boxes: Dict[str, Toggle] = {}
merge_note = QLabel(
"Keys a filter is merged back onto the object tables with. The "
"object label alone repeats in every field, so dropping a key "
"merges objects that are not the same object.", page)
merge_note.setWordWrap(True)
form.addRow("Merge on", merge_note)
for key in MERGE_KEYS:
box = Toggle(key, page)
box.setChecked(key in self._settings.merge_keys)
box.toggled.connect(self._on_merge_key_toggled)
self._merge_boxes[key] = box
form.addRow("", box)
self._wand_tolerance = QDoubleSpinBox(page)
self._wand_tolerance.setRange(0.001, 1.0)
self._wand_tolerance.setDecimals(3)
self._wand_tolerance.setSingleStep(0.01)
self._wand_tolerance.setValue(self._settings.wand_tolerance)
self._wand_tolerance.setToolTip(
"Magic wand: how far apart two objects can be and still count as "
"neighbours. This is what makes it a watershed rather than a "
"circle — the selection flows along a dense ridge and stops at a "
"gap, so a bent population comes out whole.")
self._wand_tolerance.valueChanged.connect(
lambda v: self._change(wand_tolerance=float(v)))
form.addRow("Wand tolerance", self._wand_tolerance)
self._wand_radius = QDoubleSpinBox(page)
self._wand_radius.setRange(0.01, 5.0)
self._wand_radius.setSingleStep(0.05)
self._wand_radius.setValue(self._settings.wand_max_radius)
self._wand_radius.setToolTip(
"How far from the click the wand may reach at all. Without a "
"ceiling, one chain of objects bridging two populations merges "
"them — which on a real scatter happens more often than not.")
self._wand_radius.valueChanged.connect(
lambda v: self._change(wand_max_radius=float(v)))
form.addRow("Wand max distance", self._wand_radius)
self._cluster_method = QComboBox(page)
self._cluster_method.addItems(CLUSTER_METHODS)
self._cluster_method.setCurrentText(self._settings.cluster_method)
self._cluster_method.setToolTip(
"DBSCAN finds clusters of any shape and is not told how many "
"there are — which is the whole problem with k-means on a "
"cytometry scatter.")
self._cluster_method.currentTextChanged.connect(
lambda v: self._change(cluster_method=v))
form.addRow("Clustering", self._cluster_method)
self._eps = QDoubleSpinBox(page)
self._eps.setRange(0.01, 100.0)
self._eps.setSingleStep(0.05)
self._eps.setValue(self._settings.cluster_eps)
self._eps.valueChanged.connect(
lambda v: self._change(cluster_eps=float(v)))
form.addRow("eps", self._eps)
self._min_samples = QSpinBox(page)
self._min_samples.setRange(2, 10_000)
self._min_samples.setValue(self._settings.cluster_min_samples)
self._min_samples.valueChanged.connect(
lambda v: self._change(cluster_min_samples=int(v)))
form.addRow("min samples", self._min_samples)
self._scale = Toggle("Standardise the axes first", page)
self._scale.setChecked(self._settings.cluster_scale)
self._scale.setToolTip(
"Without it, eps means a distance in whichever measurement has "
"the larger numbers and the other axis is ignored.")
self._scale.toggled.connect(
lambda v: self._change(cluster_scale=bool(v)))
form.addRow("", self._scale)
self._walk = Toggle("Walk", page)
self._walk.setChecked(self._settings.cluster_walk)
self._walk.setToolTip(
"Try the space instead of taking the values above, showing each "
"result as it arrives so you can pick the one that matches what "
"you can see.")
self._walk.toggled.connect(lambda v: self._change(cluster_walk=bool(v)))
form.addRow("", self._walk)
self._walk_steps = QSpinBox(page)
self._walk_steps.setRange(2, 200)
self._walk_steps.setValue(self._settings.cluster_walk_steps)
self._walk_steps.valueChanged.connect(
lambda v: self._change(cluster_walk_steps=int(v)))
form.addRow("Walk steps", self._walk_steps)
return page
def _xd_tab(self, columns: Tuple[str, ...]) -> QWidget:
"""Which measurements xD reduces.
THE SETTING THAT DECIDES WHETHER THE PROJECTION MEANS ANYTHING. The
reduction used to run over every numeric column, with no way to
choose. A screen's phenotype usually lives in a subset, and reducing
over all four hundred buries it; worse, that set includes
identifiers, and feeding a plate id to UMAP embeds the plate -- the
batch effect rather than the biology.
THREE KINDS OF GROUP, because a table names a column three ways at
once: ``cell_channel_1_mean_intensity`` is a CELL measurement, a
CHANNEL 1 measurement and an INTENSITY measurement, and which one is
meant depends on the question. The families come from
:mod:`spacr.feature_dict`, not from a second taxonomy invented here.
Nothing ticked means EVERY numeric column, which is what xD did
before this existed, so an existing session is unchanged.
"""
from ...column_groups import GROUP_KINDS, group_names, summarise
page = QWidget(self)
outer = QVBoxLayout(page)
self._group_boxes: Dict[Tuple[str, str], Toggle] = {}
names = group_names(columns)
tabs = QTabWidget(page)
for kind in GROUP_KINDS:
inner = QWidget(tabs)
column = QVBoxLayout(inner)
offered = names.get(kind, [])
if not offered:
column.addWidget(QLabel(
f"No {kind} groups in this table.", inner))
for name in offered:
box = Toggle(name, inner)
box.setChecked(name in
(self._settings.reduction_groups or {}).get(kind, ()))
box.toggled.connect(self._on_group_toggled)
self._group_boxes[(kind, name)] = box
column.addWidget(box)
column.addStretch(1)
scroll = QScrollArea(tabs)
scroll.setWidget(inner)
scroll.setWidgetResizable(True)
scroll.viewport().setAutoFillBackground(False)
tabs.addTab(scroll, kind.capitalize())
outer.addWidget(tabs, 1)
form = QFormLayout()
self._reduction = QComboBox(page)
self._reduction.addItems(("pca", "umap", "tsne"))
self._reduction.setCurrentText(self._settings.reduction)
self._reduction.setToolTip(
"How xD projects many measurements onto few. PCA is always "
"available and is the only one whose axes have a stated meaning "
"\u2014 the share of variance each component explains.")
self._reduction.currentTextChanged.connect(self._on_reduction_changed)
form.addRow("Projection", self._reduction)
self._components = QSpinBox(page)
self._components.setRange(2, 10)
self._components.setValue(self._settings.components)
self._components.valueChanged.connect(
lambda v: self._change(components=int(v)))
form.addRow("Components", self._components)
self._n_neighbors = QSpinBox(page)
self._n_neighbors.setRange(2, 500)
self._n_neighbors.setValue(int(self._settings.xd_n_neighbors))
self._n_neighbors.setToolTip(
"UMAP only. How much of the data each point is placed against: "
"small values keep local structure and fragment the map, large "
"ones preserve the global shape and merge populations.")
self._n_neighbors.valueChanged.connect(
lambda v: self._change(xd_n_neighbors=int(v)))
form.addRow("UMAP neighbours", self._n_neighbors)
self._min_dist = QDoubleSpinBox(page)
self._min_dist.setRange(0.0, 1.0)
self._min_dist.setSingleStep(0.05)
self._min_dist.setValue(float(self._settings.xd_min_dist))
self._min_dist.setToolTip(
"UMAP only. How tightly points may pack. Lower packs clusters "
"harder, which looks cleaner and tells you less about spread.")
self._min_dist.valueChanged.connect(
lambda v: self._change(xd_min_dist=float(v)))
form.addRow("UMAP min distance", self._min_dist)
self._perplexity = QDoubleSpinBox(page)
self._perplexity.setRange(5.0, 200.0)
self._perplexity.setValue(float(self._settings.xd_perplexity))
self._perplexity.setToolTip(
"t-SNE only. Roughly how many neighbours each point is fitted "
"against. The reducer CLAMPS it to the sample size, because "
"sklearn raises outright when it exceeds it \u2014 so a small "
"selection cannot turn this into a failed projection.")
self._perplexity.valueChanged.connect(
lambda v: self._change(xd_perplexity=float(v)))
form.addRow("t-SNE perplexity", self._perplexity)
outer.addLayout(form)
self._grey_irrelevant_methods()
self._explicit = QLineEdit(page)
self._explicit.setText(", ".join(self._settings.reduction_columns))
self._explicit.setPlaceholderText("extra columns, comma separated")
self._explicit.setToolTip(
"Individual measurements, ADDED to whatever the groups above "
"select. Groups and columns add up rather than replacing each "
"other.\n\nAn identifier is never offered as part of a group, "
"but it can be typed here -- not offered is not the same as "
"forbidden.")
self._explicit.editingFinished.connect(self._on_explicit_changed)
outer.addWidget(self._explicit)
self._selection_note = QLabel(page)
self._selection_note.setWordWrap(True)
self._selection_note.setObjectName("MutedNote")
outer.addWidget(self._selection_note)
self._columns_for_picker = tuple(columns)
self._refresh_selection_note()
return page
#: Which controls belong to which projection method. A method with no
#: entry has no parameters of its own -- PCA is entirely determined by
#: the data -- and that is worth showing rather than hiding, because
#: "PCA has nothing to tune" is a fact about PCA.
_METHOD_CONTROLS = {
"umap": ("_n_neighbors", "_min_dist"),
"tsne": ("_perplexity",),
}
def _grey_irrelevant_methods(self) -> None:
"""Enable only the controls the chosen projection reads."""
active = set(self._METHOD_CONTROLS.get(
self._reduction.currentText(), ()))
for controls in self._METHOD_CONTROLS.values():
for name in controls:
widget = getattr(self, name, None)
if widget is not None:
widget.setEnabled(name in active)
def _on_reduction_changed(self, value: str) -> None:
"""Record a new reduction and grey out the methods it makes irrelevant.
:param value: the newly chosen reduction.
"""
self._change(reduction=value)
self._grey_irrelevant_methods()
def _picked_groups(self) -> Dict[str, Tuple[str, ...]]:
"""Collect the ticked measurement groups.
:returns: the chosen names by group kind, each tuple sorted so the same
tick set always produces the same record.
"""
out: Dict[str, list] = {}
for (kind, name), box in self._group_boxes.items():
if box.isChecked():
out.setdefault(kind, []).append(name)
return {k: tuple(sorted(v)) for k, v in out.items()}
def _on_group_toggled(self, _checked: bool) -> None:
"""Record the ticked measurement groups and restate what they select.
:param _checked: the toggle's new state; unused, since every box is
re-read either way.
"""
self._change(reduction_groups=self._picked_groups())
self._refresh_selection_note()
def _on_explicit_changed(self) -> None:
"""Record the explicitly typed columns and restate what they select.
Blank entries between commas are dropped, so a trailing comma while
typing does not become a column named ``""``.
"""
typed = tuple(part.strip() for part in self._explicit.text().split(",")
if part.strip())
self._change(reduction_columns=typed)
self._refresh_selection_note()
def _refresh_selection_note(self) -> None:
"""Say how many measurements the tick boxes actually come to.
A picker that does not say what it selected is one the user has to
run to find out about, and running a UMAP is the expensive part.
"""
from ...column_groups import summarise
note = getattr(self, "_selection_note", None)
if note is None:
return
note.setText(summarise(
self._columns_for_picker,
self._picked_groups(),
explicit=tuple(part.strip() for part in
self._explicit.text().split(",") if part.strip())))
def _three_d_tab(self, columns: Tuple[str, ...]) -> QWidget:
"""Build the 3D page: the merge policy, the gate mode and the Z axis.
:param columns: the loaded table's measurements, offered as Z.
:returns: the page widget.
"""
page = QWidget(self)
form = QFormLayout(page)
self._merge_primary = QComboBox(page)
self._merge_primary.addItems(
("cell", "nucleus", "pathogen", "cytoplasm", "organelle"))
self._merge_primary.setCurrentText(self._settings.merge_primary)
self._merge_primary.setToolTip(
"The object everything else is rolled up onto. It decides what a "
"row of the merged table means — rolling cells onto pathogens is "
"a legitimate thing to want and gives a different table.")
self._merge_primary.currentTextChanged.connect(
lambda v: self._change(merge_primary=v))
form.addRow("Merge: primary object", self._merge_primary)
self._merge_na = QComboBox(page)
self._merge_na.addItems(("keep", "zero", "drop"))
self._merge_na.setCurrentText(self._settings.merge_na)
self._merge_na.setToolTip(
"What happens to an object with no children when tables are "
"merged. A cell with no pathogens genuinely has a pathogen COUNT "
"of zero, and genuinely has no pathogen mean intensity at all — "
"so 'keep' leaves that blank rather than inventing a zero.")
self._merge_na.currentTextChanged.connect(
lambda v: self._change(merge_na=v))
form.addRow("Merge: missing children", self._merge_na)
self._mode = QComboBox(page)
self._mode.addItems(GATE_MODES)
self._mode.setCurrentText(self._settings.gate_mode)
self._mode.setToolTip(
"2D gates on a scatter, 3D in a volume, xD on more measurements "
"than can be drawn at once.")
self._mode.currentTextChanged.connect(lambda v: self._change(gate_mode=v))
form.addRow("Gate in", self._mode)
self._z = QComboBox(page)
self._z.setEditable(True)
self._z.addItem("")
self._z.addItems(columns)
self._z.setCurrentText(self._settings.z_axis)
self._z.currentTextChanged.connect(lambda v: self._change(z_axis=v))
form.addRow("Z", self._z)
self._voxels = QSpinBox(page)
self._voxels.setRange(8, 512)
self._voxels.setValue(self._settings.voxel_bins)
self._voxels.setToolTip(
"Voxels per axis. A volume is bins cubed, so this costs far more "
"than the same number does in 2D.")
self._voxels.valueChanged.connect(
lambda v: self._change(voxel_bins=int(v)))
form.addRow("Voxels", self._voxels)
self._snap = Toggle("Snap to the nearest axis when a spin ends", page)
self._snap.setChecked(self._settings.snap_to_axis)
self._snap.setToolTip(
"Off by default, so the volume stays wherever a spin leaves it. "
"On, every spin ends square-on to the nearest face.")
self._snap.toggled.connect(lambda v: self._change(snap_to_axis=bool(v)))
form.addRow("", self._snap)
self._spin = QDoubleSpinBox(page)
self._spin.setRange(0.1, 10.0)
self._spin.setSingleStep(0.1)
self._spin.setValue(self._settings.spin_speed)
self._spin.valueChanged.connect(
lambda v: self._change(spin_speed=float(v)))
form.addRow("Spin speed", self._spin)
self._rules_button = QPushButton("Aggregation rules…", page)
self._rules_button.setToolTip(
"Review or change the aggregation rule for each measurement. "
"Defaults follow the measurement type: areas and counts sum, "
"while minima retain the minimum value.")
self._rules_button.clicked.connect(self.aggregation_rules_requested.emit)
form.addRow("", self._rules_button)
note = QLabel(
"The 3D workspace itself is the next piece of work. These "
"settings are read by it when it lands; nothing here changes the "
"2D view.", page)
note.setWordWrap(True)
form.addRow("", note)
return page
def _on_merge_key_toggled(self, _checked: bool) -> None:
"""Merge keys are one setting, so they are collected, not appended.
Kept in MERGE_KEYS order rather than click order: the tuple is used as
a join key list, and a join on the same keys in a different order is
the same join written two ways.
"""
chosen = tuple(k for k in MERGE_KEYS if self._merge_boxes[k].isChecked())
self._change(merge_keys=chosen)
def _change(self, **fields) -> None:
"""Replace the held settings and announce them.
:param fields: the fields to change; everything else is carried over.
Nothing is emitted while the dialog is filling its own widgets, so
building the form does not look like a hundred edits.
"""
self._settings = self._settings.replaced(**fields)
if self._live:
self.settings_changed.emit(self._settings)
[docs]
def set_mode(self, mode: str) -> None:
"""Show a mode chosen elsewhere, without re-emitting it.
The 2D/3D/xD buttons and this dropdown are two views of one setting.
Echoing the change back would be a loop; showing it is what keeps the
window honest about the state the editor is actually in.
:param mode: one of :data:`GATE_MODES`; any other value is ignored.
"""
if mode not in GATE_MODES:
return
self._live = False
try:
self._mode.setCurrentText(mode)
self._settings = self._settings.replaced(gate_mode=mode)
finally:
self._live = True
[docs]
def settings(self) -> GateEditorSettings:
"""The gate settings the user chose.
:returns: the settings dict.
"""
return self._settings