Source code for spacr.qt.widgets.class_editor

"""Edit named classes derived from annotation or metadata values.

Class definitions are stored as ``name -> {column, value}`` mappings. Values
from several columns can be appended to one definition set, and an optional
random-complement rule represents objects not claimed by another class. With
the metadata basis, the editor offers plate, row, column, field, and well
coordinates through the same interface.
"""
from __future__ import annotations

import ast
import logging
from typing import TYPE_CHECKING, Any, Dict, List, Mapping, Optional, Sequence

from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
    QCheckBox, QComboBox, QFrame, QHBoxLayout, QHeaderView, QLabel,
    QLineEdit, QPushButton, QScrollArea, QTreeWidget, QTreeWidgetItem,
    QVBoxLayout, QWidget,
)

if TYPE_CHECKING:                    # pragma: no cover - typing only
    import pandas as pd

from ...classify_classes import (
    METADATA_COLUMNS, ClassDefinitionError, ClassRule, candidate_columns,
    values_in,
)
from ..i18n import set_translatable_text
from ..theme import SPACING, apply_close_mark, register_widget_qss
from .sortable_table import install_sorting, tree_item

LOG = logging.getLogger("spacr.qt.class_editor")

QSS_NAME = "ClassEditor"


def _class_editor_qss(palette, opacity=None) -> str:
    """Build the class editor's stylesheet.

    :param palette: the active palette.
    :param opacity: the page opacity, blended into the panel's surface.
    :returns: the QSS.
    """
    return f"""
    QTreeWidget#ClassTable {{
        background: transparent;
        color: {palette['fg']};
        border: 1px solid {palette['border']};
        border-radius: 4px;
    }}
    QTreeWidget#ClassTable::item {{
        color: {palette['fg']};
        padding: 2px 4px;
    }}
    QLabel#ClassEditorHint {{
        color: {palette['fg_muted']};
        background: transparent;
    }}
    QWidget#ClassEditor {{
        background: transparent;
    }}
    """


register_widget_qss(QSS_NAME, _class_editor_qss, replace=True)


[docs] class ClassChip(QWidget): """Display one class name and its selected value as a removable pair. Random-complement classes omit the value pill because they do not select a specific value. Name and value colours come from the active theme's ``chip_class`` and ``chip_value`` roles. :param index: which rule this chip stands for. It is what ``removed`` carries, so it must be the rule's position in the editor's list rather than a running count of chips built. :param rule: the rule to show. Read once, at construction: a chip does not follow a rule that changes underneath it. :param palette: the active theme's colour roles, as a mapping. Needs at least ``chip_class``, ``chip_value`` and ``bg``. :param parent: parent widget. """ removed = Signal(int) def __init__(self, index: int, rule: "ClassRule", palette, parent=None): """Build one class bubble: its name, its value and a remove mark. :param index: the rule's position, carried so removal can name it. :param rule: the class rule this chip stands for. :param palette: the active theme colours. :param parent: parent widget, or ``None``. """ super().__init__(parent) self.setObjectName("ClassChip") self._index = int(index) row = QHBoxLayout(self) row.setContentsMargins(0, 0, 0, 0) row.setSpacing(SPACING["xs"]) self.name_pill = QLabel(str(rule.name), self) self.name_pill.setObjectName("ClassChipName") self.name_pill.setStyleSheet( f"background:{palette['chip_class']}; color:{palette['bg']};" f"border-radius:8px; padding:2px 8px;") row.addWidget(self.name_pill) if rule.random_complement: text = "the rest, at random" elif rule.value is None: text = "\u2014" else: text = _key(rule.value) self.value_pill = QLabel(text, self) self.value_pill.setObjectName("ClassChipValue") self.value_pill.setStyleSheet( f"background:{palette['chip_value']}; color:{palette['bg']};" f"border-radius:8px; padding:2px 8px;") row.addWidget(self.value_pill) if rule.column: source = QLabel(f"({rule.column})", self) source.setObjectName("ClassChipSource") source.setStyleSheet(f"color:{palette['fg_muted']};") row.addWidget(source) self._close = QPushButton(self) self._close.setObjectName("ClassChipRemove") apply_close_mark(self._close, tooltip=f"Remove the class {rule.name!r}") self._close.clicked.connect(self._on_removed) row.addWidget(self._close) row.addStretch(1) def _on_removed(self) -> None: """Announce that this chip's rule should go.""" self.removed.emit(self._index)
[docs] class ClassEditorWidget(QWidget): """Edits the ``classes`` setting. :param value: the current setting -- a dict, or the old list of names. :param frame: the table whose columns and values are offered. Without one the widget still edits an existing dictionary but cannot populate new rows, and says so rather than showing an empty column picker as though the table had no columns. :param parent: parent widget; ownership only. :param basis: ``annotation`` to derive classes from an annotation column, or ``metadata`` to offer plate/row/column/field/well instead. It picks WHICH COLUMNS ARE ON OFFER, not how a rule is stored -- both bases produce the same ``name -> {column, value}`` mapping -- and it can be changed after construction with :meth:`set_basis`. """ value_changed = Signal(object) def __init__(self, value: Any = None, parent=None, *, frame: Optional[pd.DataFrame] = None, basis: str = "annotation"): """Build the class editor: a column picker, a two-field entry row and chips. The column combo is editable because it is filled from a loaded table and there is not always one: with no frame the list came back empty, Add values was disabled, and a non-editable empty combo left no way at all to name a column -- so no class could be added and the module could not be configured. :param value: the classes to start with. :param parent: parent widget, or ``None``. :param frame: the loaded table, used to offer columns and their values. :param basis: which columns the picker offers -- ``"annotation"`` or the metadata set. """ super().__init__(parent) self.setObjectName("ClassEditor") self._frame = frame self._basis = basis self._rules: List[ClassRule] = [] outer = QVBoxLayout(self) outer.setContentsMargins(0, 0, 0, 0) outer.setSpacing(SPACING["xs"]) picker = QHBoxLayout() picker.setContentsMargins(0, 0, 0, 0) picker.addWidget(QLabel("Column", self)) self.column = QComboBox(self) self.column.setToolTip( "Choosing a column fills the table below with its values, one row " "per class. Choosing another column adds its values alongside — " "classes can be defined across more than one column.") self.column.setEditable(True) self.column.setInsertPolicy(QComboBox.NoInsert) picker.addWidget(self.column, 1) self._add = QPushButton("Add values", self) self._add.clicked.connect(self.populate_from_column) picker.addWidget(self._add) self._picker_row = picker outer.addLayout(picker) entry = QHBoxLayout() entry.setContentsMargins(0, 0, 0, 0) entry.setSpacing(SPACING["xs"]) self.class_field = QLineEdit(self) self.class_field.setPlaceholderText("Class") self.class_field.setToolTip( "The name of the class. It becomes the teal bubble, and it is the " "name that appears in every figure and results table afterwards.") self.class_field.returnPressed.connect(self.add_typed_class) entry.addWidget(self.class_field, 1) self.value_field = QLineEdit(self) self.value_field.setPlaceholderText("Value") self.value_field.setToolTip( "The value in the chosen column that makes an object a member of " "this class. It becomes the green bubble.") self.value_field.returnPressed.connect(self.add_typed_class) entry.addWidget(self.value_field, 1) self._add_typed = QPushButton("Add", self) self._add_typed.clicked.connect(self.add_typed_class) entry.addWidget(self._add_typed) outer.addLayout(entry) self.chips_host = QWidget(self) self.chips_host.setObjectName("ClassChips") self._chips_layout = QVBoxLayout(self.chips_host) self._chips_layout.setContentsMargins(0, 0, 0, 0) self._chips_layout.setSpacing(SPACING["xs"]) outer.addWidget(self.chips_host) self.table = QTreeWidget(self) install_sorting(self.table) self.table.setVisible(False) self.table.setObjectName("ClassTable") self.table.setColumnCount(3) self.table.setHeaderLabels(["Class name", "Value", "From column"]) header = self.table.header() header.setSectionResizeMode(0, QHeaderView.Stretch) header.setSectionResizeMode(1, QHeaderView.ResizeToContents) header.setSectionResizeMode(2, QHeaderView.ResizeToContents) self.table.itemChanged.connect(self._on_item_changed) outer.addWidget(self.table, 1) row = QHBoxLayout() row.setContentsMargins(0, 0, 0, 0) self._remove = QPushButton("Remove", self) self._remove.clicked.connect(self.remove_selected) row.addWidget(self._remove) self._complement = QPushButton("Add random rest", self) self._complement.setToolTip( "One class made of the objects no other class claimed, chosen at " "random and sized to match the largest class. This is what to use " "when only one class is annotated — a comparison group ten times " "larger teaches the model the prior, not the difference.") self._complement.clicked.connect(self.add_random_complement) row.addWidget(self._complement) row.addStretch(1) outer.addLayout(row) self._hint = QLabel("", self) self._hint.setObjectName("ClassEditorHint") self._hint.setWordWrap(True) outer.addWidget(self._hint) self.set_frame(frame) self.set_value(value) from ..screens.settings_model import retarget_field_tooltips retarget_field_tooltips(self)
[docs] def set_frame(self, frame: Optional[pd.DataFrame]) -> None: """Offer this table's columns. :param frame: the table whose column names are offered (filtered by the basis), or ``None`` when no table is loaded, which disables adding a class and shows a hint. """ self._frame = frame columns = candidate_columns( {"dataset_mode": self._basis}, available=list(frame.columns) if frame is not None else ()) current = self.column.currentText() self.column.blockSignals(True) self.column.clear() self.column.addItems([str(c) for c in columns]) if current: self.column.setCurrentText(current) self.column.blockSignals(False) self._add.setEnabled(bool(self.column.currentText().strip()) and frame is not None) if frame is None: set_translatable_text( self._hint, "Load a table, or press SQL to read the column " "names out of the database, to fill classes in " "from a column.")
[docs] def attach_sql_picker(self, db_path_getter, table: str = "png_list"): """Add a database-backed column picker beside the column field. The picker reads available columns from the current run database when no table has been loaded into the editor. :param db_path_getter: callable giving the run folder or database path, called on each press so a path edited later is picked up. :param table: database table whose columns should be offered. :returns: the button, or ``None`` if it could not be built. """ from .column_picker import attach_column_picker try: return attach_column_picker( self.column, db_path_getter, table, layout=self._picker_row, on_pick=lambda _name: self._add.setEnabled(True), tooltip=("Read the column names out of this run's database, " "rather than typing one and finding out at run " "time whether it exists.")) except Exception: # noqa: BLE001 LOG.debug("could not attach the column picker", exc_info=True) return None
[docs] def set_basis(self, basis: str) -> None: """Metadata or annotation: it decides which columns are offered. Under metadata these become plate / row / column / field / well, which is what replaces location_column plus the two control settings. :param basis: the dataset basis, passed on as the ``dataset_mode`` setting: ``"metadata"`` offers the plate coordinates, ``"annotation"`` the table's annotation columns. The column list is refilled. """ self._basis = basis self.set_frame(self._frame)
[docs] def set_value(self, value: Any) -> None: """Show ``value``, whether it is the dict, the old list, or a string. A settings CSV stores ``repr(value)``, so ``classes`` comes back as the TEXT ``"['nc', 'pc']"``. Without the string branch below, that matched neither the Mapping nor the list arm, fell through to an empty table, and reported SUCCESS: ``apply_settings_dict`` returned ``applied=1`` while ``collect()['classes']`` was ``{}``. The class names were dropped without a word -- and because ``{}`` is a Mapping, ``classify_classes.normalize_settings`` then skipped its own legacy-translation branch too, so nothing downstream recovered them. Every other list-shaped key survived that round trip; this was the one that decides what gets trained. :param value: a mapping of class name to ``{column, value, random_complement}``, a legacy list or tuple of class names, or the text of either; anything else leaves the table empty. Malformed entries are skipped. """ self._rules = [] if isinstance(value, str): text = value.strip() if text.startswith(("[", "(", "{")): try: value = ast.literal_eval(text) except (ValueError, SyntaxError): LOG.debug("classes is a string that does not parse: %r", text) if isinstance(value, Mapping): for name, spec in value.items(): if not isinstance(spec, Mapping): continue try: self._rules.append(ClassRule( name=str(name), column=str(spec.get("column", "") or ""), value=spec.get("value"), random_complement=bool( spec.get("random_complement", False)))) except ClassDefinitionError: LOG.debug("skipping malformed class %r", name) elif isinstance(value, (list, tuple)): for name in value: self._rules.append(ClassRule(name=str(name), column="?", value=None)) self._rebuild()
[docs] def value(self) -> Dict[str, Dict[str, Any]]: """The class rules, keyed by name. :returns: one dict per rule. """ return {r.name: r.to_dict() for r in self._rules}
#: The settings panel reads every custom widget through this name.
[docs] def get_value(self) -> Dict[str, Dict[str, Any]]: """The same as :meth:`value`, under the name the settings form calls. :returns: one dict per rule. """ return self.value()
[docs] def rules(self) -> List[ClassRule]: """The rules as objects rather than as dicts. :returns: the rules, in display order. """ return list(self._rules)
[docs] def populate_from_column(self) -> None: """Fill the table from the chosen column's distinct values. Values already present are left alone, so adding a second column adds to the table rather than replacing what is in it -- and re-adding the same column does not duplicate or reset the names already typed. """ column = self.column.currentText() if not column or self._frame is None: return try: values = values_in(self._frame, column) except ClassDefinitionError as exc: self._say(str(exc)) return known = {(r.column, _key(r.value)) for r in self._rules} added = 0 for value in values: if (column, _key(value)) in known: continue self._rules.append(ClassRule(name=f"{column}={_key(value)}", column=column, value=value)) added += 1 self._rebuild() self._say(f"added {added} value(s) from {column}" if added else f"{column} adds nothing new")
[docs] def add_typed_class(self) -> None: """Add one class from the two fields. The chip appears; the fields clear. A class with no name is refused rather than added blank -- `ClassRule` raises on it anyway, and the message a user needs is which field is empty, not a traceback. """ name = self.class_field.text().strip() value = self.value_field.text().strip() if not name: set_translatable_text(self._hint, "give the class a name first") return column = self.column.currentText().strip() if not column: set_translatable_text( self._hint, "choose the column the value comes from") return if not value: self._say(f"give {name!r} a value in {column!r}, or use " f"'Add random rest' for the objects nothing else claims") return if any((r.column, _key(r.value)) == (column, _key(value)) for r in self._rules): self._say(f"{column}={value} is already a class") return try: self._rules.append( ClassRule(name=name, column=column, value=value)) except ClassDefinitionError as exc: self._say(str(exc)) return self.class_field.clear() self.value_field.clear() self.class_field.setFocus() self._rebuild() self._say(f"added {name}")
[docs] def remove_at(self, index: int) -> None: """Remove the class a chip's close mark belongs to. :param index: zero-based position of the class; out of range does nothing. """ if 0 <= int(index) < len(self._rules): del self._rules[int(index)] self._rebuild()
[docs] def add_random_complement(self) -> None: """Add a rule taking a random sample of whatever the others leave. AT MOST ONE. Two complements would each be defined as "the rest", which is not a partition and cannot both be true. """ if any(r.random_complement for r in self._rules): set_translatable_text( self._hint, "there is already a random-rest class; two classes both " "meaning 'everything else' have no boundary between them") return self._rules.append(ClassRule(name="rest", random_complement=True)) self._rebuild()
[docs] def remove_selected(self) -> None: """Drop the selected rules.""" item = self.table.currentItem() if item is None: return index = self.table.indexOfTopLevelItem(item) if 0 <= index < len(self._rules): del self._rules[index] self._rebuild()
def _rebuild(self) -> None: """Redraw the chips and the hidden table from the current rules. Only the class name is editable in the table: the value and its column are facts about the loaded table, and letting them be typed over would produce a class that selects nothing with no sign of why. """ self._rebuild_chips() self.table.blockSignals(True) self.table.clear() for rule in self._rules: if rule.random_complement: labels = [rule.name, "the rest, at random", ""] else: labels = [rule.name, "" if rule.value is None else str(rule.value), rule.column] item = tree_item(labels) item.setFlags(item.flags() | Qt.ItemIsEditable) self.table.addTopLevelItem(item) self.table.blockSignals(False) self._emit() def _on_item_changed(self, item: QTreeWidgetItem, column: int) -> None: """Rename a class from an edited table cell. An empty name is refused and the old one put back -- a class with no name cannot be trained on or reported, so it fails later rather than here if accepted. :param item: the edited row. :param column: which cell changed; only the name column is acted on. """ if column != 0: return index = self.table.indexOfTopLevelItem(item) if not (0 <= index < len(self._rules)): return name = item.text(0).strip() if not name: self.table.blockSignals(True) item.setText(0, self._rules[index].name) self.table.blockSignals(False) return rule = self._rules[index] self._rules[index] = ClassRule( name=name, column=rule.column, value=rule.value, random_complement=rule.random_complement) self._emit() def _rebuild_chips(self) -> None: """Redraw the bubbles from `self._rules`. Cleared and rebuilt rather than diffed: the list is a handful of classes, and a diff here would be the second place the order lives. """ from ..theme import active_palette while self._chips_layout.count(): item = self._chips_layout.takeAt(0) widget = item.widget() if widget is not None: widget.setParent(None) widget.deleteLater() palette = active_palette() for index, rule in enumerate(self._rules): chip = ClassChip(index, rule, palette, self.chips_host) chip.removed.connect(self.remove_at) self._chips_layout.addWidget(chip) def _emit(self) -> None: """Announce the current class definitions.""" self.value_changed.emit(self.value()) def _say(self, message: str) -> None: """Show a hint built from data — a column name, a count, an error. The message is shown verbatim. Sending it through the translator would rewrite the user's own column names and values word by word, so a class on ``control`` would report itself as ``Kontroll``. """ self._hint.setProperty("_spacr_i18n_text_template", None) self._hint.setProperty("i18nSkipText", True) self._hint.setText(message)
def _key(value: Any) -> str: """A value's identity for de-duplication, insensitive to 1 vs 1.0.""" if isinstance(value, float) and value.is_integer(): return str(int(value)) return str(value)