Source code for spacr.qt.comparison_grid

"""``B16`` — N panels of the same field, panned and zoomed together.

Four channels of one field. The same well at four timepoints. The same field
under four conditions. The comparison is only worth anything if the panels are
looking at the same place at the same magnification, and doing that by hand —
zoom each one, pan each one, hope — is how two panels end up half a cell out
and a difference in framing is read as a difference in biology.

:class:`spacr.layers.CanvasLink` is the model: N canvases sharing one world
window, each keeping its own pixel size because they are different widgets.
This module is the grid of widgets over it, plus the two things that only
exist once there is more than one panel:

* **Selection reaches across.** Clicking an object in one panel publishes its
  key through :mod:`spacr.qt.linked_selection`, so the same cell lights up in
  the other panels — and in the UMAP, the plate view and the annotation grid,
  which were already listening.
* **One panel can be let go.** "Look closely at this one without losing the
  others' place" is the ordinary next request, and it is a checkbox rather
  than a mode.
"""
from __future__ import annotations

import logging
import math
from dataclasses import replace
from typing import Any, Dict, List, Optional, Tuple

from PySide6.QtCore import Signal
from PySide6.QtWidgets import (QGridLayout, QHBoxLayout, QLabel,
                               QVBoxLayout, QWidget)

from ..layers import (Canvas, CanvasLink, LabelsLayer, LayerError, LayerStack)
from .layer_viewer import LayerCanvas
from .linked_selection import LinkedView
from .theme import font_px, register_widget_qss
from .widgets.preview_controls import FlatButton
from .widgets.toggle import Toggle

LOG = logging.getLogger(__name__)

__all__ = [
    "ComparisonPanel",
    "ComparisonGrid",
    "GRID_LINK_SOURCE",
]

#: What this view calls itself on the shared selection, so it can ignore the
#: echo of its own clicks.
GRID_LINK_SOURCE = "comparison_grid"


def _grid_qss(palette: Dict[str, Any], opacity) -> str:
    """This view's QSS block, appended to every generated stylesheet."""
    return f"""
QWidget#ComparisonGrid {{
    background: transparent;
}}
QLabel#ComparisonPanelName {{
    color: {palette["fg_dim"]};
    font-size: {font_px(10)}px;
    letter-spacing: 1px;
}}
QLabel#ComparisonStatus {{
    color: {palette["fg_muted"]};
}}
"""


register_widget_qss("ComparisonGrid", _grid_qss, replace=True)



class _LinkedCanvas(LayerCanvas):
    """A canvas whose widget resize keeps the MAGNIFICATION, not the view.

    :meth:`spacr.qt.layer_viewer.LayerCanvas._ensure_canvas` holds the world
    span when the widget changes size, which is right for a lone viewer: the
    user still sees the same field of view. In a grid it is exactly wrong — the
    cells are not all the same size, so holding the span puts two panels at two
    magnifications and a cell that is 4 px narrower shows the same sample 2%
    bigger. Nothing about the picture says so.

    Here the step is held and the shape follows the widget, which is
    :class:`spacr.layers.CanvasLink`'s own contract.
    """

    def _ensure_canvas(self) -> Optional[Canvas]:
        """The canvas at this widget's size, rebuilt only when the size changed."""
        height = max(1, self.height() - 2)
        width = max(1, self.width() - 2)
        if self._canvas is not None and self._canvas.shape != (height, width):
            self._canvas = replace(self._canvas, shape=(height, width))
            return self._canvas
        return super()._ensure_canvas()


[docs] class ComparisonPanel(QWidget): """One cell: a caption, a canvas and the checkbox that frees it. :param key: the panel's name in the :class:`~spacr.layers.CanvasLink`. :param stack: what this panel shows. Each panel has its OWN stack — that is the whole point, since they hold different channels, timepoints or conditions. :param parent: parent widget; ownership only. :param title: the caption over the panel. Empty falls back to ``key``, which is a name the code chose, not one a reader picked -- fine while the keys are the conditions, worth overriding once they are not. """ #: This panel's view moved. Carries ``(key, canvas)``. view_changed = Signal(str, object) #: Something was picked here. Carries ``(key, layer, world, value)``. picked = Signal(str, object, object, object) #: The lock checkbox was toggled. Carries ``(key, locked)``. lock_changed = Signal(str, bool) def __init__(self, key: str, stack: LayerStack, parent=None, *, title: str = ""): """Build one panel of the comparison grid. :param key: identifies this panel in the grid and in every signal it emits. :param stack: the layers this panel draws. :param parent: parent widget, or ``None``. :param title: caption; an empty one falls back to ``key``. """ super().__init__(parent) self._key = str(key) layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(2) header = QHBoxLayout() header.setSpacing(4) self.caption = QLabel(title or self._key, self) self.caption.setObjectName("ComparisonPanelName") header.addWidget(self.caption, 1) self.lock_box = Toggle("linked", self) self.lock_box.setChecked(True) self.lock_box.setToolTip( "Uncheck to pan and zoom this panel on its own without losing " "where the others are looking.") self.lock_box.toggled.connect(self._on_lock_toggled) header.addWidget(self.lock_box) layout.addLayout(header) self.canvas = _LinkedCanvas(stack, self) self.canvas.view_changed.connect(self._on_view_changed) self.canvas.picked.connect(self._on_picked) layout.addWidget(self.canvas, 1) @property
[docs] def key(self) -> str: """This panel's name in the link.""" return self._key
@property
[docs] def stack(self) -> LayerStack: """What this panel is showing.""" return self.canvas.stack
def _on_view_changed(self) -> None: """Re-emit this panel's view change with its key attached.""" self.view_changed.emit(self._key, self.canvas.canvas) def _on_picked(self, layer, world, value) -> None: """Re-emit a pick with this panel's key attached. :param layer: the layer picked from. :param world: the picked point in world coordinates. :param value: the value at that point. """ self.picked.emit(self._key, layer, world, value) def _on_lock_toggled(self, checked: bool) -> None: """Announce that this panel joined or left the linked view. :param checked: the new state of the link toggle. """ self.lock_changed.emit(self._key, bool(checked))
[docs] def detach(self) -> None: """Let go of the model. Call from the grid's ``closeEvent``.""" self.canvas.detach()
[docs] class ComparisonGrid(LinkedView, QWidget): """N panels of the same field, locked together. :param panels: ``[(key, stack), …]`` or ``{key: stack}`` — what each panel shows. Order is the order they are laid out in. :param columns: how many panels per row; the default is the squarest grid that fits them. :param titles: per-panel captions, defaulting to the keys. :param parent: parent widget; ownership only. """ #: The shared world window moved. Carries the driving panel's key. view_changed = Signal(str) #: An object was picked in one of the panels. Carries its key. object_picked = Signal(str) def __init__(self, panels: Any = None, parent=None, *, columns: Optional[int] = None, titles: Optional[Dict[str, str]] = None): """Build the grid and add a panel per stack. The canvas link is held as ``_canvas_link`` rather than ``_link``: the latter name belongs to ``LinkedView`` and carries the process-wide selection bus, so shadowing it would leave the grid publishing selections into its own canvas link and hearing nothing from the app. :param panels: the panels to build, as a ``{key: stack}`` mapping or an iterable of ``(key, stack)`` pairs; ``None`` starts empty. :param parent: parent widget, or ``None``. :param columns: how many columns to lay out; ``None`` picks a roughly square grid for whatever is added. :param titles: captions by key, for panels that want more than their key. """ super().__init__(parent) self.setObjectName("ComparisonGrid") self._canvas_link = CanvasLink() self._panels: Dict[str, ComparisonPanel] = {} self._titles = dict(titles or {}) self._columns = columns self._syncing = False self._build() for key, stack in self._as_pairs(panels): self.add_panel(key, stack) self._refresh_status() self.link_selection(GRID_LINK_SOURCE) @staticmethod def _as_pairs(panels: Any) -> List[Tuple[str, LayerStack]]: """Normalise the panel argument to ``(key, stack)`` pairs. :param panels: a mapping, an iterable of pairs, or ``None``. :returns: the pairs, with every key coerced to :class:`str`. """ if panels is None: return [] items = (list(panels.items()) if isinstance(panels, dict) else [tuple(entry) for entry in panels]) return [(str(key), stack) for key, stack in items] def _build(self) -> None: """Lay out the panel grid, the view buttons and the status line.""" outer = QVBoxLayout(self) outer.setContentsMargins(12, 12, 12, 12) outer.setSpacing(8) self.grid = QGridLayout() self.grid.setSpacing(8) outer.addLayout(self.grid, 1) row = QHBoxLayout() row.setSpacing(4) self.fit_button = FlatButton("Fit", self, tooltip="Fit every linked panel again") self.fit_button.clicked.connect(self.reset_view) row.addWidget(self.fit_button) self.lock_all_button = FlatButton( "Link all", self, tooltip="Bring every panel back to this view") self.lock_all_button.clicked.connect(self.lock_all) row.addWidget(self.lock_all_button) row.addStretch(1) outer.addLayout(row) self.status = QLabel("", self) self.status.setObjectName("ComparisonStatus") outer.addWidget(self.status) @property @property
[docs] def panels(self) -> Dict[str, ComparisonPanel]: """``{key: panel}``, in layout order.""" return dict(self._panels)
[docs] def add_panel(self, key: str, stack: LayerStack, *, title: str = "") -> ComparisonPanel: """Add one panel; returns it. A panel added to a grid the user has already zoomed into starts where the others are, not fitted to its own extent — otherwise adding a fifth channel throws away the view. :param key: the panel's name in this grid, converted to a string; a key already in the grid raises :class:`LayerError`. :param stack: the layer stack the new panel displays. """ key = str(key) if key in self._panels: raise LayerError(f"panel {key!r} is already in this grid") panel = ComparisonPanel(key, stack, self, title=title or self._titles.get(key, key)) panel.view_changed.connect(self._on_panel_view_changed) panel.picked.connect(self._on_panel_picked) panel.lock_changed.connect(self._on_lock_changed) self._panels[key] = panel self._relayout() canvas = panel.canvas._ensure_canvas() if canvas is not None: self._canvas_link.add(key, canvas) panel.canvas._canvas = self._canvas_link[key] panel.canvas.update() self._refresh_status() return panel
[docs] def remove_panel(self, key: str) -> ComparisonPanel: """Take a panel out of the grid and return it. :param key: the panel's key; a key not in the grid raises :class:`LayerError`. """ key = str(key) if key not in self._panels: raise LayerError( f"no panel {key!r}; the panels are {list(self._panels)}") panel = self._panels.pop(key) if key in self._canvas_link: self._canvas_link.remove(key) panel.detach() panel.setParent(None) self._relayout() self._refresh_status() return panel
def _relayout(self) -> None: """Re-place every panel in the grid. The column count is the one given at construction, or the nearest square for however many panels there now are. """ while self.grid.count(): self.grid.takeAt(0) n = len(self._panels) columns = self._columns or max(1, int(math.ceil(math.sqrt(n)))) for index, panel in enumerate(self._panels.values()): self.grid.addWidget(panel, index // columns, index % columns) panel.show() def _on_panel_view_changed(self, key: str, canvas: Optional[Canvas] ) -> None: """One panel moved: put every locked panel on the same window.""" if self._syncing or canvas is None or key not in self._canvas_link: return self._syncing = True try: self._canvas_link.set(key, canvas) self._push_to_panels(skip=key) finally: self._syncing = False self.view_changed.emit(key) self._refresh_status() def _push_to_panels(self, *, skip: str = "") -> None: """Put every following panel's widget on its canvas from the link. Each panel's SHAPE comes from its widget and its WINDOW comes from the link, which is the split that lets a grid of unequal cells share one magnification. ``LayerCanvas`` would otherwise refit on its next paint and hold the world span instead, quietly leaving two cells at two magnifications. """ for other, panel in self._panels.items(): if other == skip or other not in self._canvas_link: continue widget = panel.canvas height = max(1, widget.height() - 2) width = max(1, widget.width() - 2) if self._canvas_link[other].shape != (height, width): self._canvas_link.resize(other, height, width) widget._canvas = self._canvas_link[other] widget.update()
[docs] def resizeEvent(self, event) -> None: """A layout change resizes the cells; re-share the window afterwards. :param event: the resize event, passed on to the base class before the view is re-shared. """ super().resizeEvent(event) self._push_to_panels()
def _on_lock_changed(self, key: str, locked: bool) -> None: """Join a panel to the linked view, or let it go free. A panel joining is brought to the shared view straight away, so linking is a visible act rather than one that takes effect on the next pan. :param key: which panel changed. :param locked: whether it is now linked. """ if key not in self._canvas_link: return if locked: self._canvas_link.lock(key) self._push_to_panels() else: self._canvas_link.unlock(key) self._refresh_status()
[docs] def lock_all(self) -> None: """Bring every panel back onto the shared window.""" for key, panel in self._panels.items(): panel.lock_box.setChecked(True) if key in self._canvas_link: self._canvas_link.lock(key) self._push_to_panels() self._refresh_status()
[docs] def reset_view(self) -> None: """Fit every linked panel to its own stack again.""" for key, panel in self._panels.items(): if key not in self._canvas_link or not self._canvas_link.is_locked(key): continue panel.canvas.reset_view() fitted = panel.canvas._ensure_canvas() if fitted is not None: self._syncing = True try: self._canvas_link.set(key, fitted) self._push_to_panels(skip=key) finally: self._syncing = False break self._refresh_status()
def _on_panel_picked(self, key: str, layer, world, value) -> None: """A click in one panel reaches every other view in the app.""" if not isinstance(layer, LabelsLayer) or not value: self._refresh_status() return object_key = layer.object_key_at_world(world) if object_key is None: return layer.selected_label = int(value) self.highlight(object_key) self.object_picked.emit(object_key) self.publish_selection([object_key])
[docs] def highlight(self, object_key: str) -> List[str]: """Select ``object_key`` in every panel that holds it; returns which. The other half of the comparison: a cell picked in the DAPI panel is the same cell in the phalloidin panel, and saying so is what makes the four pictures one observation rather than four. :param object_key: the object key to find, compared with each labels layer's field object keys. """ found: List[str] = [] for key, panel in self._panels.items(): for layer in panel.stack: if not isinstance(layer, LabelsLayer) or layer.field is None: continue for label in layer.labels(): if layer.field.object_key(int(label)) != object_key: continue layer.selected_label = int(label) found.append(key) break else: continue break self.status.setText( f"{object_key} · in {len(found)} of {len(self._panels)} panel(s)") return found
[docs] def on_linked_selection_changed(self, selection) -> None: """Another view selected something: show it in every panel. :param selection: the shared selection; only one with exactly one key is shown. """ if selection.keys is None or len(selection.keys) != 1: return self.highlight(str(selection.keys[0]))
def _refresh_status(self) -> None: """Say how many panels there are and which are not following the shared view. Free panels and unlinked ones are counted separately: a panel the user unlinked deliberately and a panel that never joined the link are different states, and only one of them is a mistake. """ if not self._panels: self.status.setText("No panels") return free = [key for key in self._panels if key in self._canvas_link and not self._canvas_link.is_locked(key)] unlinked = [key for key in self._panels if key not in self._canvas_link] notes = [] if free: notes.append(f"{len(free)} free ({', '.join(free)})") if unlinked: notes.append( f"{len(unlinked)} not linked ({', '.join(unlinked)})") self.status.setText( f"{len(self._panels)} panel(s) · " + (" · ".join(notes) if notes else "all linked"))
[docs] def closeEvent(self, event) -> None: """Leave the shared selection and let go of every panel's model. :param event: the close event, passed on to the base class after the grid unlinks and detaches its panels. """ self.unlink_selection() for panel in self._panels.values(): panel.detach() super().closeEvent(event)