Source code for spacr.qt.screens.classify

"""Model-evaluation and interpretation views integrated with Classify.

The Classify masthead provides direct access to three related modules:

* Classifier Evaluation reports held-out predictions, nested
  cross-validation, calibration, leakage checks and per-plate metrics from
  a saved evaluation bundle.
* Explain CV Model reports feature gain, held-out permutation importance and
  SHAP values for classifiers trained on measured features.
* Activation maps image regions associated with predictions from an image
  classifier.

Each module opens as a complete page beside the Classify settings. The shared
page and signal integration is implemented by
:mod:`spacr.qt.screens.map_barcodes`.
"""

from __future__ import annotations

import logging
import time
import weakref
from typing import Callable, Dict, Optional, Tuple

from PySide6.QtCore import Qt
from PySide6.QtWidgets import QLabel, QVBoxLayout, QWidget

from ..i18n import tr
from ..theme import (block_surface, ensure_widget_qss_applied,
                     register_widget_qss)
from ..widgets.collapsible_section import CollapsibleSection
from ..widgets.fold_strip import FoldStrip
from . import activation
from .map_barcodes import build_registered_screen, install_fold_strip

LOG = logging.getLogger(__name__)

#: Registry key of the screen this module hangs its strip on. Classify is
#: the merged module -- Torch on crops and gradient boosting on measured
#: features behind one form -- so its key is not ``classify``.
HOST_KEY = "classify_merged"

#: The settings-column surface that advertises FlowView without importing its
#: renderer.  The real panel is created only after the user opens the fold.
FLOWVIEW_SECTION_NAME = "ClassifyFlowViewSection"
FLOWVIEW_BODY_NAME = "ClassifyFlowViewBody"

#: A visualisation failure must not cost Classify its settings or Run button.
#: The exception itself is retained in the debug log; this is the useful next
#: action for the person looking at the panel.
FLOWVIEW_OPEN_ERROR = (
    "FlowView could not open. Collapse this section and open it again to retry."
)
FLOWVIEW_TOOLTIP = "Fold FlowView away, or open it again"


def _flowview_section_qss(palette: dict, _opacity=None) -> str:
    """The rounded box around the lazily constructed FlowView renderer.

    FILLED, AT THE PAGE OPACITY -- the same box the console wears, which is
    what was asked for: "transparent boxes with round corners like the rest
    of the boxes in the module".

    IT WAS OPAQUE, THEN IT WAS NOTHING, AND BOTH ARE WRONG. It first painted
    `surface` straight from the palette, which is raw hex and therefore fully
    opaque whatever the page-opacity preference says -- a black slab behind
    the flow cells and the inspector. Removing the fill did not fix it, it
    just moved the problem: with every layer transparent (the scene brush,
    the view, its viewport, the inspector, and this) NOTHING paints, so the
    panel shows straight through to the black application ground and reads as
    the same black box.

    `ConsoleBox` in theme.py carries this exact lesson from the same mistake
    made there: "Making it transparent (tried, reverted) left a rounded
    outline floating on the opaque container behind it -- the fill is what
    makes it read as a console." So the fill is the box; the accessor is what
    carries the opacity into it, and reading `palette` directly is what loses
    it.

    IT USES `block_surface`, NOT `pane_surface`, and the difference is the
    whole of a fault this file used to have. Both take an `opacity`, and both
    treat `None` differently: `pane_surface`'s `None` means "nobody told me,
    go and look", so it reads the live page-opacity preference. Inside a
    REGISTERED BLOCK that is wrong, because `None` is already an answer --
    `stylesheet(theme)` with no `surface_opacity` is asking for the theme's
    DESIGNED scrim. Calling the looking-up one made this sheet a function of
    a QSettings value rather than of its arguments, so it emitted
    `rgba(22, 23, 25, 0.600)` for a caller that asked for neither.
    """

    return f"""
QWidget#{FLOWVIEW_SECTION_NAME} {{
    background-color: {block_surface("surface_alt", opacity=_opacity)};
    border: 1px solid {palette["border_soft"]};
    border-radius: 8px;
}}
QWidget#{FLOWVIEW_BODY_NAME} {{
    background: transparent;
    border: none;
}}
QWidget#{FLOWVIEW_SECTION_NAME} QWidget#FlowViewPanel {{
    background: transparent;
    border: none;
}}
"""


register_widget_qss(FLOWVIEW_SECTION_NAME, _flowview_section_qss)

#: Registry keys of the modules folded into it, in the order the strip
#: draws them.  The first three are one reading of one model, in the
#: order that reading is done: judge the model first, then ask which
#: measured features it is keying on, then where in the image it looked.
#:
#: Training Runs and Feature Explorer were appended to that sequence
#: rather than inserted into it, because neither is a step in it.
#: Training Runs diffs two runs against each other, which is a question
#: about a pair rather than about this model, and Feature Explorer ranks
#: features before anything has been trained at all -- so putting either
#: between the three would break the sentence above.
FOLDED_APPS: Tuple[str, ...] = ("classifier_evaluation", "explain_cv",
                                activation.APP_KEY, "train_compare",
                                "feature_explorer")


#: What the tiles these folds replaced said, kept so the buttons on this
#: masthead survive the loss of their registry rows.
#:
#: The registry answers a key it no longer holds exactly as it answers a
#: typo -- no name, no sentence, and "stable" for the maturity -- so
#: without this an Activation button would carry no tooltip at all and
#: light up in the colour of finished code for a module assessed as beta.
FOLD_FALLBACK = {
    activation.APP_KEY: (
        "Activation",
        "Generate activation maps",
        "beta"),
}


def _navigable(host_window: Optional[QWidget]) -> Optional[QWidget]:
    """``host_window`` if it can be navigated, else None.

    The folded screens send the user on -- Explain CV offers to open
    Activation Maps, and both it and the evaluation screen seed a
    training screen -- through the main window's ``_on_train_requested``.
    Handing them anything else would turn one of their buttons into an
    ``AttributeError`` at the moment it was pressed.
    """
    if host_window is None:
        return None
    return host_window if callable(
        getattr(host_window, "_on_train_requested", None)) else None


def _build_classifier_evaluation(host_window: Optional[QWidget]) -> QWidget:
    """Classifier Evaluation's own screen, bundle browser included."""
    from .classifier_evaluation import ClassifierEvaluationScreen
    return ClassifierEvaluationScreen()


def _build_explain_cv(host_window: Optional[QWidget]) -> QWidget:
    """Explain CV Model's own screen, with its navigation answered.

    The host it is given is the activation module's ``ExplainNavigator``
    rather than the window: "Open Activation Maps" now has a page on this
    very screen to land on, and asking the window for it reached a key
    nothing knows. Everything else the screen asks its host for is
    forwarded to the window unchanged, and only when that window can
    answer.
    """
    from .model_explanation import make_model_explanation_screen

    navigator = activation.ExplainNavigator(_navigable(host_window))
    screen = make_model_explanation_screen(app_key="explain_cv",
                                           host=navigator)
    navigator.attach(screen)
    return screen


def _build_activation(host_window: Optional[QWidget]) -> QWidget:
    """Activation's own screen: the attribution form and its Run button."""
    return activation.build(host_window)


#: One builder per folded module — see
#: :func:`spacr.qt.screens.map_barcodes.install_fold_strip`.
def _build_train_compare(host_window: Optional[QWidget] = None) -> QWidget:
    """Training Runs, as the window builds it.

    CLASSIFIERS, NOT CELLPOSE. The module reads the per-epoch `train.csv`
    an image-classifier run writes and diffs the two runs' settings; it
    has no notion of a segmentation model. That is what makes Classify
    its host rather than Make Masks -- "why is run B better than run A"
    is a question you only have after training something here.
    """
    return build_registered_screen("train_compare", host_window)


def _build_feature_explorer(host_window: Optional[QWidget] = None) -> QWidget:
    """Feature Explorer, as the window builds it.

    Ranks measured features by how well they separate classes, so it is
    the step BEFORE choosing what to train on -- and useless without the
    class column Classify is already pointed at.
    """
    return build_registered_screen("feature_explorer", host_window)


BUILDERS: Dict[str, Callable[[Optional[QWidget]], QWidget]] = {
    "classifier_evaluation": _build_classifier_evaluation,
    "explain_cv": _build_explain_cv,
    activation.APP_KEY: _build_activation,
    "train_compare": _build_train_compare,
    "feature_explorer": _build_feature_explorer,
}


[docs] class LazyFlowViewSection(CollapsibleSection): """A collapsed Classify footer that pays for FlowView only when opened. The empty ``content`` passed to :class:`CollapsibleSection` is deliberate: importing the graphics renderer constructs a sizeable Qt/scientific dependency tree. Classify gets only this small header on first paint; the panel, its scene and the approved preview graph arrive on the first expansion. :param screen: the Classify screen this footer belongs to. Read on the first EXPANSION rather than here, which is the whole point of the class: nothing about FlowView is imported until someone opens it. :param parent: parent widget. """ OPEN_MINIMUM = 420 def __init__(self, screen: QWidget, parent: QWidget | None = None) -> None: """Build the FlowView section's body without building FlowView itself. The screen is held weakly: this section outlives nothing, but a strong reference from a lazily-built child back to its screen is how a closed module stays alive. :param screen: the screen the section belongs to. :param parent: parent widget for the body, or ``None``. """ self._screen_ref = weakref.ref(screen) self._panel = None self._error_label: QLabel | None = None self._shut_down = False body = QWidget(parent) body.setObjectName(FLOWVIEW_BODY_NAME) body_layout = QVBoxLayout(body) body_layout.setContentsMargins(6, 0, 6, 6) body_layout.setSpacing(6) self._body_layout = body_layout super().__init__("FlowView", body, expanded=False, parent=parent) self.setObjectName(FLOWVIEW_SECTION_NAME) self.setAttribute(Qt.WA_StyledBackground, True) self._header.setProperty("_spacr_i18n_text", "FlowView") self._header.setText(tr("FlowView")) self._header.setProperty("_spacr_i18n_tooltip", FLOWVIEW_TOOLTIP) self._header.setToolTip(tr(FLOWVIEW_TOOLTIP)) self.set_open_minimum(self.OPEN_MINIMUM) self.toggled.connect(self._flowview_toggled)
[docs] def panel(self): """Return the constructed renderer, or ``None`` before first open.""" return self._panel
def _settings(self) -> dict: """Take one detached snapshot of the Classify form for the preview.""" screen = self._screen_ref() model = getattr(screen, "_settings_model", None) if screen else None collect = getattr(model, "collect", None) return dict(collect() or {}) if callable(collect) else {} def _collector_for_open_panel(self): """Enable tracing and return the live collector the panel must follow. A fresh process owns an empty generic collector. Replacing only that empty graph with Classify's approved eight-node preview gives the opened panel something informative immediately. A populated global collector belongs to a run already under observation and wins. """ from spacr.flowview.classify_blueprint import classify_graph from spacr.flowview.collector import Collector from spacr.flowview.trace import enable, get_collector collector = get_collector() try: has_live_graph = bool(collector.snapshot().nodes) except Exception: has_live_graph = False if not has_live_graph: graph = classify_graph( self._settings(), run_id=f"classify-preview-{time.time_ns()}", ) collector = Collector(graph) return enable(collector) def _clear_error(self) -> None: """Remove the error label, if one is showing. Clears the attribute BEFORE touching the widget, so a failure while deleting it cannot leave the section pointing at a half-destroyed label. """ label = self._error_label self._error_label = None if label is None: return self._body_layout.removeWidget(label) label.deleteLater() def _show_open_error(self, error: Exception) -> None: """Surface one recoverable error without letting it escape the fold.""" self._clear_error() try: from spacr.flowview.panel import QT_MISSING_MESSAGE except Exception: message = FLOWVIEW_OPEN_ERROR else: message = QT_MISSING_MESSAGE if isinstance(error, ImportError) else ( FLOWVIEW_OPEN_ERROR ) label = QLabel(self.content()) label.setObjectName("ClassifyFlowViewError") label.setProperty("_spacr_i18n_text", message) label.setText(tr(message)) label.setWordWrap(True) self._body_layout.addWidget(label) self._error_label = label def _ensure_panel(self): """Build the real FlowView panel once, on the first expansion.""" if self._panel is not None or self._shut_down: return self._panel self._clear_error() try: from spacr.flowview.panel import FlowViewPanel collector = self._collector_for_open_panel() panel = FlowViewPanel( collector, self.content(), auto_start=False, embedded=True, ) self._body_layout.addWidget(panel, 1) self._panel = panel from ..theme import clear_container_surfaces clear_container_surfaces(panel) screen = self._screen_ref() if screen is not None: ensure_widget_qss_applied(FLOWVIEW_SECTION_NAME, root=screen) except Exception as error: # noqa: BLE001 - optional UI isolation LOG.debug("could not open Classify FlowView", exc_info=True) self._show_open_error(error) return self._panel def _flowview_toggled(self, expanded: bool) -> None: """Start the panel when the section opens and stop it when it closes. The panel is built on first open, which is what makes the section lazy -- FlowView is not paid for by a user who never expands it. """ if expanded: panel = self._ensure_panel() if panel is not None and self.isVisible(): panel.start() return panel = self._panel if panel is not None: panel.stop()
[docs] def showEvent(self, event) -> None: # noqa: N802 - Qt virtual name """Resume rendering only for an actually open, visible section. :param event: the show event; it is passed on to the base class unchanged. """ super().showEvent(event) if self.is_expanded(): panel = self._ensure_panel() if panel is not None: panel.start()
[docs] def hideEvent(self, event) -> None: # noqa: N802 - Qt virtual name """A cached Classify page must spend no cycles while hidden. :param event: the hide event; it is passed on to the base class unchanged. """ panel = self._panel if panel is not None: panel.stop() super().hideEvent(event)
[docs] def shutdown(self) -> None: """Stop and release renderer-owned Qt objects during screen teardown.""" if self._shut_down: return self._shut_down = True panel = self._panel self._panel = None if panel is not None: panel.stop() panel.close() panel.deleteLater() self._clear_error()
[docs] def install_flowview(screen: QWidget) -> Optional[LazyFlowViewSection]: """Mount Classify's lazy FlowView box directly below its settings. :param screen: the host screen; nothing is mounted unless its ``app_key`` is ``'classify_merged'`` and it has a ``_settings_content`` widget with a layout. """ if getattr(screen, "app_key", None) != HOST_KEY: return None existing = getattr(screen, "_flowview_section", None) if isinstance(existing, LazyFlowViewSection): return existing try: content = getattr(screen, "_settings_content", None) layout = content.layout() if content is not None else None if layout is None: return None section = LazyFlowViewSection(screen, content) last = layout.itemAt(layout.count() - 1) if layout.count() else None insert_at = layout.count() - 1 if ( last is not None and last.spacerItem() is not None ) else layout.count() layout.insertWidget(max(0, insert_at), section) screen._flowview_section = section ensure_widget_qss_applied(FLOWVIEW_SECTION_NAME, root=screen) return section except Exception: # noqa: BLE001 - optional UI must not cost Classify LOG.debug("could not install Classify FlowView", exc_info=True) return None
[docs] def install_folds(screen: QWidget) -> Optional[FoldStrip]: """Put Classify's FlowView footer and fold strip on ``screen``. :param screen: the Classify screen that receives the FlowView section and the fold strip. """ try: install_flowview(screen) except Exception: LOG.debug("could not install Classify FlowView", exc_info=True) return install_fold_strip(screen, HOST_KEY, FOLDED_APPS, BUILDERS)