Source code for spacr.qt.screens.agreement

"""
Annotator Agreement — agreement between the annotation columns of one database.

The Annotate app writes one ``INTEGER`` column per pass into ``png_list``. Two
people labelling the same crops therefore leave two columns side by side, and
this screen reports whether they agree, and on which rows they differ, before
either set is used as ground truth.

Layout::

    ┌───────────────────────────────────────────────────────────────────┐
    │ /data/plate1/measurements/measurements.db  [DB…] [Run folder…]    │
    │ Read-only (mode=ro) — nothing here can modify your annotations.   │
    ├──────────────┬────────────────────────────────────────────────────┤
    │ Annotators   │ a       b      n   abstain   raw    κ   band       │
    │ ☑ alice      │ alice   bob  410      92   88.0% +0.61 substantial │
    │ ☑ bob        ├────────────────────────────────────────────────────┤
    │ ☐ carol      │ Confusion  [alice vs bob ▾]     1     2            │
    │              │                              1  310    22          │
    │ [Compute]    │                              2   27    51          │
    ├──────────────┴────────────────────────────────────────────────────┤
    │ Overall Cohen's κ +0.61 (substantial) · raw 88.0% · 49 to review   │
    ├───────────────────────────────────┬───────────────────────────────┤
    │ Disagreements  png_path  alice bob│      [crop preview]           │
    │  …/A01_1_cell_3.png        1    2 │                               │
    └───────────────────────────────────┴───────────────────────────────┘

Design notes:

* **Read-only, structurally.** Every connection goes through
  :mod:`spacr.agreement`. It opens the file with ``file:…?mode=ro`` and
  ``PRAGMA query_only = ON``, the same approach the Database Browser takes.
  Disagreements are resolved in the Annotate app; this screen does not write.
* **Off the GUI thread.** The report reads the whole ``png_list``
  annotation block, so it runs through :func:`spacr.qt.bridge.make_thread`
  like every other spaCR job. Tests pass ``threaded=False``.
* **No modal dialogs on any error path.** "You only picked one column",
  "that file isn't a database", "this table has no annotation columns" —
  all of it lands in an inline status label. A QMessageBox would hang a
  headless run.
* **κ is never quoted alone.** Raw percent agreement, the number of
  compared rows, the number of abstentions and the reason κ is undefined
  (when it is) are all on screen next to it. See :mod:`spacr.agreement`
  for why that matters on a screen where 98 % of cells are negative.
"""
from __future__ import annotations

import math
import os
from typing import Any, Callable, Dict, List, Optional

from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QPixmap
from PySide6.QtWidgets import (
    QAbstractItemView,
    QComboBox,
    QFileDialog,
    QHBoxLayout,
    QHeaderView,
    QLabel,
    QLineEdit,
    QListWidget,
    QListWidgetItem,
    QPushButton,
    QSizePolicy,
    QSpinBox,
    QTableWidget,
    QTableWidgetItem,
    QVBoxLayout,
    QWidget,
)

from ... import agreement as agree
from ..bridge import make_thread
from ..hidpi import scaled_for
from ..theme import SPACING, active_palette
from ..widgets import Divider
from ..widgets.collapsible_splitter import CollapsibleSplitter
from .db_browser import resolve_db_path
from ..widgets.sortable_table import (
    _retire_table_cells, _reuse_list_items, _spare_table_item, install_sorting,
    table_item,
)

__all__ = ["AgreementScreen", "DEFAULT_REVIEW_LIMIT", "format_kappa"]


#: Rows of the disagreement review list fetched by default. The list is a
#: work queue for a human, not a data dump — 500 is already a long day.
DEFAULT_REVIEW_LIMIT = 500
REVIEW_LIMIT_RANGE = (10, 100000)

#: Thumbnail edge for the crop preview, in pixels.
PREVIEW_PX = 260

_KAPPA_HEADERS = ("annotator A", "annotator B", "n compared", "abstentions",
                  "raw agreement", "κ", "interpretation")


[docs] def format_kappa(value: Any) -> str: """Render a κ for display, or ``"undefined"`` when it is NaN. :param value: a κ, possibly ``nan``. :returns: signed 3-decimal string, or ``"undefined"``. """ try: v = float(value) except (TypeError, ValueError): return "undefined" return "undefined" if math.isnan(v) else f"{v:+.3f}"
def _format_pct(value: Any) -> str: """Render a fraction as a percentage. :param value: the fraction. :returns: it to one decimal place, or ``"n/a"`` for anything unparseable or NaN -- an agreement that could not be computed is not 0%. """ try: v = float(value) except (TypeError, ValueError): return "n/a" return "n/a" if math.isnan(v) else f"{v:.1%}" def _cell(text: str, table: Optional[QTableWidget] = None) -> QTableWidgetItem: """A read-only table cell, rewritten from ``table``'s spare cells if given.""" item = table_item(text) if table is None else _spare_table_item(table, text) item.setFlags(item.flags() & ~Qt.ItemIsEditable) return item
[docs] class AgreementScreen(QWidget): """Inter-annotator agreement over the annotation columns of a database. :param threaded: compute the report on a worker thread (the default). Tests pass ``False`` for deterministic, synchronous behaviour. :param parent: parent widget; ownership only. :ivar last_error: text of the most recent failure, ``""`` when the last operation succeeded. Errors are *only* ever reported here and in the inline status label — never in a modal dialog. """ #: emitted with the resolved path whenever a database opens database_opened = Signal(str) #: emitted after every compute job settles (ok or not) job_finished = Signal(bool) #: internal relay that hops a worker completion back onto the GUI thread _job_settled = Signal(bool) def __init__(self, parent=None, threaded: bool = True): """Build the screen and arm its drop zone. :param parent: parent widget, or ``None``. :param threaded: run the agreement computation on a worker thread. Set ``False`` in tests so ``compute`` finishes before it returns. """ super().__init__(parent) self._threaded = bool(threaded) self._db_path: str = "" self._candidates: List[str] = [] self._report: Optional[agree.AgreementReport] = None self._disagreements = None self._busy = False self._jobs: List[tuple] = [] self._pending: List[tuple] = [] self._thread = None self._worker = None self.last_error: str = "" self._job_settled.connect(self._on_job_settled) self._build_ui() from ..dnd import install_dropzone from ..dnd_handlers import get_handler install_dropzone(self, get_handler("agreement"), self) self._set_status( "Choose a measurements.db (or a run folder), tick two or more " "annotation columns, then Compute agreement.") self._update_controls() from .settings_model import retarget_field_tooltips retarget_field_tooltips(self) def _build_ui(self) -> None: """Lay out the source row, the annotator list, the tables and the review pane.""" outer = QVBoxLayout(self) outer.setContentsMargins(SPACING["lg"], SPACING["lg"], SPACING["lg"], SPACING["lg"]) outer.setSpacing(SPACING["md"]) title = QLabel("Annotator Agreement") title.setObjectName("DisplayHeading") outer.addWidget(title) subtitle = QLabel( "Cohen's κ between annotation columns (Fleiss' κ for three or " "more), plus the rows they disagree on. Read-only: the database " "is opened with mode=ro, so nothing here can change your " "annotations.") subtitle.setObjectName("Muted") subtitle.setWordWrap(True) outer.addWidget(subtitle) outer.addWidget(Divider()) src_row = QHBoxLayout() src_row.setSpacing(SPACING["sm"]) self._path_edit = QLineEdit(self) self._path_edit.setPlaceholderText( "…/measurements/measurements.db — or a run folder") self._path_edit.setClearButtonEnabled(True) self._path_edit.returnPressed.connect(self._on_open_typed_path) self._btn_pick_db = QPushButton("Choose database…", self) self._btn_pick_db.clicked.connect(self._pick_database) self._btn_pick_src = QPushButton("Choose run folder…", self) self._btn_pick_src.clicked.connect(self._pick_run_folder) self._btn_open = QPushButton("Open", self) self._btn_open.clicked.connect(self._on_open_typed_path) src_row.addWidget(self._path_edit, 1) src_row.addWidget(self._btn_pick_db) src_row.addWidget(self._btn_pick_src) src_row.addWidget(self._btn_open) outer.addLayout(src_row) body = CollapsibleSplitter(Qt.Vertical, self, persist_key="agreement::body") top = QWidget(body) top_layout = QVBoxLayout(top) top_layout.setContentsMargins(0, 0, 0, 0) top_layout.setSpacing(SPACING["xs"]) split = CollapsibleSplitter(Qt.Horizontal, top, persist_key="agreement::tables") left = QWidget() left_layout = QVBoxLayout(left) left_layout.setContentsMargins(0, 0, 0, 0) left_layout.setSpacing(SPACING["xs"]) hint = QLabel("Tick two or more columns — agreement needs at least " "two annotators.") hint.setObjectName("Caption") hint.setWordWrap(True) left_layout.addWidget(hint) self._columns_list = QListWidget(left) self._columns_list.setSelectionMode(QAbstractItemView.NoSelection) self._columns_list.itemChanged.connect(lambda *_: self._update_controls()) left_layout.addWidget(self._columns_list, 1) self._btn_compute = QPushButton("Compute agreement", left) self._btn_compute.clicked.connect(self.compute) left_layout.addWidget(self._btn_compute) split.add_section(left, "Annotation columns", persist_key="agreement/Annotation columns", stretch=0, extent=260) right = CollapsibleSplitter(Qt.Vertical, split, persist_key="agreement::right") self._kappa_table = QTableWidget(0, len(_KAPPA_HEADERS)) install_sorting(self._kappa_table) self._kappa_table.setHorizontalHeaderLabels(list(_KAPPA_HEADERS)) self._prepare_table(self._kappa_table) self._kappa_table.currentCellChanged.connect( lambda row, *_: self._on_pair_row_changed(row)) right.add_section(self._kappa_table, "Pairwise agreement", persist_key="agreement/Pairwise agreement") self._pair_combo = QComboBox() self._pair_combo.setMinimumWidth(220) self._pair_combo.currentIndexChanged.connect( lambda *_: self._show_confusion(self._pair_combo.currentIndex())) self._confusion_table = QTableWidget(0, 0) install_sorting(self._confusion_table) self._prepare_table(self._confusion_table) right.add_section(self._confusion_table, "Confusion matrix", persist_key="agreement/Confusion matrix", actions=[self._pair_combo]) split.add_pane(right, "Tables", stretch=1) top_layout.addWidget(split, 1) self._summary = QLabel("", top) self._summary.setWordWrap(True) self._summary.setTextInteractionFlags(Qt.TextSelectableByMouse) top_layout.addWidget(self._summary) body.add_pane(top, "Agreement", stretch=1) review = QWidget(body) review_layout = QVBoxLayout(review) review_layout.setContentsMargins(0, 0, 0, 0) review_layout.setSpacing(SPACING["xs"]) review_layout.addWidget(Divider()) review_row = QHBoxLayout() review_row.setSpacing(SPACING["sm"]) self._review_label = QLabel("Disagreement review", review) review_row.addWidget(self._review_label) review_row.addStretch(1) review_row.addWidget(QLabel("Max rows", review)) self._limit_box = QSpinBox(review) self._limit_box.setRange(*REVIEW_LIMIT_RANGE) self._limit_box.setSingleStep(50) self._limit_box.setValue(DEFAULT_REVIEW_LIMIT) self._limit_box.setToolTip( "(int) How many disagreeing rows to pull into the review list.") review_row.addWidget(self._limit_box) review_layout.addLayout(review_row) review_split = CollapsibleSplitter(Qt.Horizontal, review, persist_key="agreement::review") self._review_table = QTableWidget(0, 0) install_sorting(self._review_table) self._prepare_table(self._review_table) self._review_table.setSelectionBehavior(QAbstractItemView.SelectRows) self._review_table.setSelectionMode(QAbstractItemView.SingleSelection) self._review_table.currentCellChanged.connect( lambda row, *_: self.select_disagreement(row)) review_split.add_section(self._review_table, "Disagreeing rows", persist_key="agreement/Disagreeing rows") preview = QWidget() preview_layout = QVBoxLayout(preview) preview_layout.setContentsMargins(0, 0, 0, 0) preview_layout.setSpacing(SPACING["xs"]) self._crop_label = QLabel("", preview) self._crop_label.setAlignment(Qt.AlignCenter) self._crop_label.setMinimumSize(PREVIEW_PX, PREVIEW_PX) self._crop_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) preview_layout.addWidget(self._crop_label, 1) self._crop_caption = QLabel("", preview) self._crop_caption.setObjectName("Caption") self._crop_caption.setWordWrap(True) self._crop_caption.setTextInteractionFlags(Qt.TextSelectableByMouse) preview_layout.addWidget(self._crop_caption) review_split.add_section(preview, "Crop preview", persist_key="agreement/Crop preview", stretch=0, extent=320) review_layout.addWidget(review_split, 1) body.add_pane(review, "Review", stretch=1) outer.addWidget(body, 1) self._body_splitter = body self._status = QLabel("", self) self._status.setObjectName("Muted") self._status.setWordWrap(True) self._status.setTextInteractionFlags(Qt.TextSelectableByMouse) outer.addWidget(self._status) @staticmethod def _prepare_table(table: QTableWidget) -> None: """Common read-only look for every result table.""" table.setEditTriggers(QAbstractItemView.NoEditTriggers) table.setAlternatingRowColors(True) table.verticalHeader().setVisible(False) table.horizontalHeader().setSectionResizeMode(QHeaderView.Interactive) table.horizontalHeader().setStretchLastSection(True) def _set_status(self, text: str, error: bool = False) -> None: """Report inline. Deliberately never a QMessageBox — a modal dialog would hang a headless run (and did, in MakeMasksScreen).""" self.last_error = text if error else "" palette = active_palette() colour = palette["error"] if error else palette["fg_muted"] self._status.setStyleSheet(f"color: {colour};") self._status.setText(text)
[docs] def status_text(self) -> str: """Current inline status message (test/introspection helper).""" return self._status.text()
[docs] def summary_text(self) -> str: """The overall-κ summary line, or ``''`` before a report exists.""" return self._summary.text()
def _pick_database(self) -> None: """Ask for a measurements database and open whatever is chosen.""" path, _ = QFileDialog.getOpenFileName( self, "Open measurements database", "", "SQLite databases (*.db *.sqlite *.sqlite3);;All files (*)") if path: self.set_database(path) def _pick_run_folder(self) -> None: """Ask for a run folder and open the database found inside it.""" path = QFileDialog.getExistingDirectory(self, "Choose a run folder", "") if path: self.set_database(path) def _on_open_typed_path(self) -> None: """Open whatever path is currently typed in the source box. Wired to both Return in the box and the Open button, so a typed path behaves the same either way. """ self.set_database(self._path_edit.text())
[docs] def set_database(self, path: str) -> bool: """Open ``path`` read-only and list its annotation columns. Accepts the database file or a run ``src`` folder. Any problem (missing file, not a database, no ``png_list``) is reported in the status label and returns ``False`` — this never raises. :param path: ``measurements.db`` file, run ``src`` folder or ``measurements`` folder; resolved with :func:`spacr.qt.screens.db_browser.resolve_db_path`. :returns: True when a database with at least one annotation column was opened. """ self._clear_results() self._columns_list.blockSignals(True) _reuse_list_items(self._columns_list, 0) self._columns_list.blockSignals(False) self._candidates = [] self._db_path = "" try: resolved = resolve_db_path(path) candidates = agree.annotation_columns(resolved) except Exception as e: self._set_status(str(e) or e.__class__.__name__, error=True) self._update_controls() return False self._db_path = resolved self._candidates = list(candidates) self._path_edit.setText(resolved) self._columns_list.blockSignals(True) for name, item in zip(candidates, _reuse_list_items( self._columns_list, len(candidates))): item.setText(name) item.setFlags(item.flags() | Qt.ItemIsUserCheckable) item.setCheckState(Qt.Unchecked) self._columns_list.blockSignals(False) self.database_opened.emit(resolved) if not candidates: self._set_status( f"Opened {resolved} — but png_list has no annotation " f"columns yet. Label some crops in the Annotate app first; " f"each pass adds one column.", error=True) self._update_controls() return False if len(candidates) == 1: self._set_status( f"Opened {resolved} — only one annotation column " f"({candidates[0]}). Agreement needs at least two: have a " f"second annotator label the same crops into a new column " f"in the Annotate app.", error=True) self._columns_list.item(0).setCheckState(Qt.Checked) self._update_controls() return True for i in range(min(2, self._columns_list.count())): self._columns_list.item(i).setCheckState(Qt.Checked) self._set_status( f"Opened {resolved} (read-only) — {len(candidates)} annotation " f"column{'s' if len(candidates) != 1 else ''}: " f"{', '.join(candidates)}.") self._update_controls() return True
[docs] def database_path(self) -> str: """Path of the open database, or ``''``.""" return self._db_path
[docs] def available_columns(self) -> List[str]: """Annotation columns offered for comparison.""" return list(self._candidates)
[docs] def selected_columns(self) -> List[str]: """Ticked annotation columns, in list order.""" return [self._columns_list.item(i).text() for i in range(self._columns_list.count()) if self._columns_list.item(i).checkState() == Qt.Checked]
[docs] def select_columns(self, names) -> bool: """Tick exactly ``names``; report inline for anything unknown. :param names: annotation column names to check; every other listed column is unchecked. Names not in the list are reported in the status line. :returns: True when every requested column was found. """ wanted = {str(n) for n in names} unknown = wanted - set(self._candidates) self._columns_list.blockSignals(True) for i in range(self._columns_list.count()): item = self._columns_list.item(i) item.setCheckState(Qt.Checked if item.text() in wanted else Qt.Unchecked) self._columns_list.blockSignals(False) self._update_controls() if unknown: self._set_status( f"Unknown annotation column(s): " f"{', '.join(sorted(unknown))}. Available: " f"{', '.join(self._candidates) or 'none'}.", error=True) return False return True
[docs] def compute(self) -> bool: """Build the agreement report for the ticked columns. Everything that can go wrong here is a normal state — no database, one column ticked, a column that turns out to be empty — so every failure is inline text and a ``False`` return. :returns: for the synchronous path, whether the report was built; for the threaded path, ``True`` once the job has started. """ if not self._db_path: self._set_status("Open a measurements database first.", error=True) return False if self._busy: self._set_status("A computation is already running…", error=True) return False columns = self.selected_columns() if len(columns) < 2: self._clear_results() self._set_status( f"Tick at least two annotation columns — agreement needs two " f"annotators (ticked: {', '.join(columns) or 'none'}). " f"Available: {', '.join(self._candidates) or 'none'}.", error=True) return False db_path = self._db_path limit = int(self._limit_box.value()) def _job() -> Dict[str, Any]: """Build the agreement report and its disagreements. Off the GUI thread.""" report = agree.agreement_report(db_path, columns) rows = agree.disagreements(db_path, columns, limit=limit) return {"report": report, "disagreements": rows} return self._run_job(_job, self._apply_result)
def _apply_result(self, result: Dict[str, Any]) -> None: """Fill every output pane from a finished agreement job. :param result: the worker's payload -- ``report`` (an ``AgreementReport``) and ``disagreements`` (the rows the annotators differed on). """ self._report = result["report"] self._disagreements = result["disagreements"] self._fill_kappa_table(self._report) self._fill_pair_combo(self._report) self._fill_summary(self._report) self._fill_review_table(self._disagreements, self._report) r = self._report self._set_status( f"{r.overall_method} {format_kappa(r.overall_kappa)} " f"({r.interpretation}) over {r.n_complete} row(s) labelled by " f"all {r.n_annotators} annotators · raw agreement " f"{_format_pct(r.percent_agreement)} · {r.n_disagreements} " f"disagreement(s) · {r.n_partial} abstention row(s).")
[docs] def report(self) -> Optional[agree.AgreementReport]: """The most recent :class:`~spacr.agreement.AgreementReport`, or None.""" return self._report
def _clear_results(self) -> None: """Empty every result pane and forget the last report. Called before a new run and after a failure, so a stale κ is never left on screen next to a new database. """ self._report = None self._disagreements = None _retire_table_cells(self._kappa_table) _retire_table_cells(self._confusion_table, headers=True) self._confusion_table.setRowCount(0) self._confusion_table.setColumnCount(0) self._pair_combo.blockSignals(True) self._pair_combo.clear() self._pair_combo.blockSignals(False) _retire_table_cells(self._review_table, headers=True) self._review_table.setRowCount(0) self._review_table.setColumnCount(0) self._review_label.setText("Disagreement review") self._summary.setText("") self._crop_label.setPixmap(QPixmap()) self._crop_label.setText("") self._crop_caption.setText("") def _fill_kappa_table(self, report: agree.AgreementReport) -> None: """Fill the pairwise table, one row per annotator pair. A pair's ``note`` -- why its κ is unreliable, when it is -- becomes the tooltip on every cell of that row, so it is reachable from wherever the eye lands. :param report: the finished report to read pairs from. """ table = self._kappa_table table.blockSignals(True) _retire_table_cells(table) table.setRowCount(len(report.pairs)) for r, pair in enumerate(report.pairs): cells = ( pair.column_a, pair.column_b, f"{pair.n_compared:,}", f"{pair.n_abstained:,}", _format_pct(pair.percent_agreement), format_kappa(pair.kappa), pair.interpretation, ) for c, text in enumerate(cells): item = _cell(text, table) if pair.note: item.setToolTip(pair.note) table.setItem(r, c, item) table.blockSignals(False) table.resizeColumnsToContents()
[docs] def kappa_rows(self) -> List[List[str]]: """The κ table as plain strings — one list per pair.""" return [[self._kappa_table.item(r, c).text() for c in range(self._kappa_table.columnCount())] for r in range(self._kappa_table.rowCount())]
def _fill_pair_combo(self, report: agree.AgreementReport) -> None: """Repopulate the confusion-matrix picker and show the first pair. :param report: the finished report to read pairs from. """ self._pair_combo.blockSignals(True) self._pair_combo.clear() for pair in report.pairs: self._pair_combo.addItem(f"{pair.column_a} vs {pair.column_b}") self._pair_combo.blockSignals(False) if report.pairs: self._pair_combo.setCurrentIndex(0) self._show_confusion(0) def _on_pair_row_changed(self, row: int) -> None: """Clicking a κ row shows that pair's confusion matrix.""" if self._report is None or not (0 <= row < len(self._report.pairs)): return if self._pair_combo.currentIndex() != row: self._pair_combo.setCurrentIndex(row) else: self._show_confusion(row) def _show_confusion(self, index: int) -> None: """Render pair ``index``'s confusion matrix into the grid.""" table = self._confusion_table if self._report is None or not (0 <= index < len(self._report.pairs)): _retire_table_cells(table, headers=True) table.setRowCount(0) table.setColumnCount(0) return pair = self._report.pairs[index] conf = pair.confusion labels = [str(l) for l in conf.index] _retire_table_cells(table, headers=True) table.setRowCount(len(labels)) table.setColumnCount(len(labels) + 1) table.setHorizontalHeaderLabels( [f"{pair.column_a} ↓ / {pair.column_b} →"] + [str(l) for l in conf.columns]) for r, label in enumerate(labels): table.setItem(r, 0, _cell(label, table)) for c in range(len(conf.columns)): value = int(conf.iloc[r, c]) item = _cell(f"{value:,}", table) if r == c: item.setToolTip("agreement") table.setItem(r, c + 1, item) table.resizeColumnsToContents()
[docs] def confusion_rows(self) -> List[List[str]]: """The confusion grid as plain strings, row label first.""" return [[self._confusion_table.item(r, c).text() for c in range(self._confusion_table.columnCount())] for r in range(self._confusion_table.rowCount())]
def _fill_summary(self, report: agree.AgreementReport) -> None: """Write the one-paragraph summary line under the tables. :param report: the finished report; its overall κ, note, warnings and convention are stacked into a single rich-text label. """ bits = [ f"<b>Overall {report.overall_method}: " f"{format_kappa(report.overall_kappa)}</b> " f"({report.interpretation}) &nbsp;·&nbsp; raw agreement " f"{_format_pct(report.percent_agreement)} on " f"{report.n_complete:,} row(s) labelled by all " f"{report.n_annotators} annotators &nbsp;·&nbsp; " f"{report.n_partial:,} abstention row(s) &nbsp;·&nbsp; " f"{report.n_disagreements:,} disagreement(s)." ] if report.overall_note: bits.append(report.overall_note) bits.extend(report.warnings) bits.append(f"<i>{report.convention}</i>") self._summary.setText("<br>".join(bits)) def _fill_review_table(self, rows, report: agree.AgreementReport) -> None: """Fill the disagreement review table and select its first row. :param rows: the disagreeing rows, as a ``DataFrame`` holding the report key plus every annotation column. :param report: the finished report, for the key column, the column order and the total disagreement count -- the table may hold fewer rows than that total, and the label says so when it does. """ table = self._review_table columns = [report.key] + list(report.columns) table.blockSignals(True) _retire_table_cells(table, headers=True) table.setColumnCount(len(columns)) table.setHorizontalHeaderLabels(columns) table.setRowCount(len(rows)) for r in range(len(rows)): for c, col in enumerate(columns): value = rows[col].iloc[r] text = "—" if value is None else str(value) item = _cell(text, table) if c == 0: item.setToolTip(str(value)) table.setItem(r, c, item) table.blockSignals(False) table.resizeColumnsToContents() shown, total = len(rows), report.n_disagreements suffix = (f" — showing the first {shown:,} of {total:,}" if shown < total else "") self._review_label.setText( f"Disagreement review — {total:,} row(s){suffix}") if shown: table.setCurrentCell(0, 0) else: self._crop_label.setText("No disagreements to review.") self._crop_caption.setText("")
[docs] def disagreement_rows(self) -> List[List[str]]: """The review list as plain strings — key first, then each label.""" return [[self._review_table.item(r, c).text() for c in range(self._review_table.columnCount())] for r in range(self._review_table.rowCount())]
[docs] def disagreement_paths(self) -> List[str]: """The ``png_path`` of every row in the review list.""" return [row[0] for row in self.disagreement_rows()]
[docs] def select_disagreement(self, row: int) -> bool: """Show the crop for review row ``row``. A missing PNG is a fact about the dataset (crops get moved, or the database was copied without them), not an error worth a dialog — the preview says so and the row stays selected. :param row: zero-based row of the disagreement table; out of range clears the preview. :returns: True when an image was actually rendered. """ self._crop_label.setPixmap(QPixmap()) if self._disagreements is None or not (0 <= row < len(self._disagreements)): self._crop_label.setText("") self._crop_caption.setText("") return False key = self._report.key if self._report is not None else agree.PNG_KEY raw = self._disagreements[key].iloc[row] path = self._resolve_crop(raw) labels = ", ".join( f"{col}={'—' if self._disagreements[col].iloc[row] is None else self._disagreements[col].iloc[row]}" for col in (self._report.columns if self._report else [])) self._crop_caption.setText(f"{raw}\n{labels}") if path is None: self._crop_label.setText( f"Crop not found on disk:\n{raw}\n\n" "The labels above are unaffected — only the image file is " "missing.") return False pixmap = QPixmap(path) if pixmap.isNull(): self._crop_label.setText(f"Could not read image:\n{path}") return False self._crop_label.setText("") self._crop_label.setPixmap( scaled_for(pixmap, self._crop_label, PREVIEW_PX, PREVIEW_PX)) return True
def _resolve_crop(self, png_path: Any) -> Optional[str]: """Find the crop on disk, or None. ``png_list`` stores absolute paths from the machine that ran Measure. When a dataset has been copied elsewhere those break, so a relative path is also tried against the run folder — the database's grandparent, ``<src>/measurements/measurements.db``. """ if png_path is None: return None p = str(png_path) if os.path.isfile(p): return p if self._db_path: src = os.path.dirname(os.path.dirname(os.path.abspath(self._db_path))) candidate = os.path.join(src, p.lstrip("/\\")) if os.path.isfile(candidate): return candidate return None
[docs] def current_crop_path(self) -> str: """Text of the crop-preview caption's first line (test helper).""" return self._crop_caption.text().splitlines()[0] if \ self._crop_caption.text() else ""
[docs] def crop_message(self) -> str: """Text shown instead of a crop when there is no image.""" return self._crop_label.text()
def _run_job(self, fn: Callable[[], Any], on_done: Callable[[Any], None]) -> bool: """Run ``fn`` off the GUI thread and hand its result to ``on_done``. Mirrors ``DbBrowserScreen._run_job``: one threading idiom for the whole Qt layer, and ``threaded=False`` runs inline while firing the same signals so both paths behave identically from outside. One detail this cannot copy from the Database Browser. ``PipelineWorker.finished`` is emitted *in the worker thread*, and PySide6 hands a plain closure connected to it a direct call — so the completion handler, and every widget it touches, would run off the GUI thread. It is chained through :attr:`_job_settled` into a *bound method* of this widget instead. The widget lives on the GUI thread, so Qt queues the call and the handler runs where every other widget call runs. """ if not self._threaded: ok = True try: on_done(fn()) except Exception as e: self._on_job_error(e) ok = False self._update_controls() self.job_finished.emit(ok) return ok box: Dict[str, Any] = {} def _job(payload: Dict[str, Any]) -> None: """Call the wrapped function, stashing its result in the payload. The payload is how a value crosses back from the worker: a return would be swallowed by the runner. """ payload["result"] = fn() thread, worker = make_thread(_job, box) self._jobs.append((thread, worker)) self._thread, self._worker = thread, worker self._pending.append((box, on_done)) worker.error.connect(self._on_worker_error_text) worker.finished.connect(self._job_settled) thread.finished.connect(self._retire_finished_jobs) self._busy = True self._update_controls() thread.start() return True def _on_job_settled(self, ok: bool) -> None: """Finish the oldest in-flight job. Always on the GUI thread.""" self._busy = False box, on_done = self._pending.pop(0) if self._pending else ({}, None) ok = bool(ok) if ok and on_done is not None: try: on_done(box.get("result")) except Exception as e: self._on_job_error(e) ok = False self._update_controls() self.job_finished.emit(ok) def _retire_finished_jobs(self) -> None: """Retire every job whose QThread has stopped. GUI thread only. A BOUND METHOD, not a closure — the rule ``make_thread`` states and then relies on for its own ``handle.retire``. With a closure PySide6 makes the QThread itself the receiver, and ``make_thread`` connects ``thread.finished -> thread.deleteLater`` FIRST; slots run in connection order, so the DeferredDelete is posted ahead of the closure's metacall and Qt discards queued events for a destroyed receiver. The job was then never retired, ``active_jobs()`` never returned to zero, and every ``waitUntil(active_jobs() == 0)`` sat there until it timed out with the QThread's C++ half already gone. It sweeps rather than naming a sender for the same reason: by the time this runs, the emitter may be exactly what is gone, and ``QObject.sender()`` is null for a queued call whose emitter was destroyed. """ from ..bridge import thread_has_stopped for thread, _worker in list(self._jobs): if thread_has_stopped(thread): self._retire_job(thread) def _retire_job(self, thread) -> None: """Release *this* job's refs once its own event loop has exited.""" self._jobs = [(t, w) for (t, w) in self._jobs if t is not thread] if self._thread is thread: self._thread = None self._worker = None
[docs] def active_jobs(self) -> int: """How many compute threads are still winding down.""" return len(self._jobs)
[docs] def is_busy(self) -> bool: """Whether anything is still running. What the window asks before closing. :returns: True while work is outstanding. """ return self._busy
def _on_worker_error_text(self, tb: str) -> None: """Turn a worker traceback into one inline line (never a dialog).""" line = "" for candidate in reversed(str(tb).strip().splitlines()): if candidate.strip(): line = candidate.strip() break self._clear_results() self._set_status(f"Agreement failed: {line}", error=True) def _on_job_error(self, exc: Exception) -> None: """Clear the results and report a failed agreement run. :param exc: the exception raised by the worker. """ self._clear_results() self._set_status(f"Agreement failed: {exc}", error=True) def _update_controls(self) -> None: """Enable the compute button, column list and row limit when they can be used. Computing needs a database and at least two ticked columns -- one annotator cannot disagree with anybody -- and nothing is enabled while a run is in flight. """ has_db = bool(self._db_path) enough = len(self.selected_columns()) >= 2 self._btn_compute.setEnabled(has_db and enough and not self._busy) self._columns_list.setEnabled(has_db and not self._busy) self._limit_box.setEnabled(has_db and not self._busy)
[docs] def closeEvent(self, event): # noqa: N802 """Let every in-flight compute thread finish before the widget dies. :param event: the close event, passed on to the base class once each running job thread has been asked to quit and waited on (up to five seconds each). """ for thread, _worker in list(self._jobs): try: if thread.isRunning(): thread.quit() thread.wait(5000) except RuntimeError: pass super().closeEvent(event)