Source code for spacr.qt.widgets.metadata_table

"""
Editable metadata table for image ingestion.

When a dataset's plate / well / field / channel assignment is *inferred*
— from a container file's internal structure or from a folder layout —
the guess is rarely perfect. This widget shows those inferred rows in an
editable :class:`QTableWidget` so the user can correct wells, split
conditions, relabel channels, etc. before committing, then writes a
``filename_map.csv`` the pipeline consumes.

Rows are the plain dicts produced by :mod:`spacr.qt.ingest_preview`
(``original / plate / well / field / channel / time / canonical``). The
``canonical`` column is recomputed live from the editable columns so the
generated filenames always reflect the user's edits.

:class:`MetadataTablePanel` is the reusable widget (embeddable / testable
without an event loop); :class:`MetadataTableDialog` wraps it with
Apply / Cancel buttons for the drop flow.
"""
from __future__ import annotations

from pathlib import Path
from typing import Any, Callable, Dict, List, Optional

from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
    QDialog,
    QDialogButtonBox,
    QHBoxLayout,
    QHeaderView,
    QLabel,
    QPushButton,
    QTableWidget,
    QTableWidgetItem,
    QVBoxLayout,
    QWidget,
)

from ..ingest_preview import ROW_COLUMNS, _yokogawa_name, rows_to_mappings
from .sortable_table import install_sorting, table_item

_EDITABLE = {"plate", "well", "field", "channel", "time"}
_INT_COLS = {"field", "channel", "time"}
_HEADERS = ["Source", "Plate", "Well", "Field", "Channel", "Time", "Filename"]


[docs] class MetadataTablePanel(QWidget): """An editable grid of ingestion metadata rows. :param rows: initial preview rows (see :mod:`spacr.qt.ingest_preview`). :param parent: optional Qt parent. """ def __init__(self, rows: Optional[List[Dict[str, Any]]] = None, parent: Optional[QWidget] = None): """Build the metadata review table. :param rows: the extracted metadata to show; ``None`` starts empty. :param parent: parent widget, or ``None``. """ super().__init__(parent) self._table = QTableWidget(0, len(ROW_COLUMNS), self) install_sorting(self._table) self._table.setHorizontalHeaderLabels(_HEADERS) self._table.verticalHeader().setVisible(False) self._table.setAlternatingRowColors(True) hdr = self._table.horizontalHeader() hdr.setSectionResizeMode(0, QHeaderView.Stretch) for i in range(1, len(ROW_COLUMNS)): hdr.setSectionResizeMode(i, QHeaderView.ResizeToContents) self._summary = QLabel("", self) try: from ..theme import active_palette self._summary.setStyleSheet( f"color: {active_palette()['fg_muted']};") except Exception: pass lay = QVBoxLayout(self) lay.setContentsMargins(0, 0, 0, 0) lay.addWidget(self._summary) lay.addWidget(self._table) self._guard = False self._table.itemChanged.connect(self._on_item_changed) self.set_rows(rows or [])
[docs] def set_rows(self, rows: List[Dict[str, Any]]) -> None: """Replace the table contents with ``rows``. :param rows: one dict per row, keyed by column; missing keys show blank. """ self._guard = True try: self._table.setRowCount(0) for r in rows: self._append_row(r) finally: self._guard = False self._refresh_summary()
def _append_row(self, r: Dict[str, Any]) -> None: """Add one metadata row. Only the columns a user may correct are editable; the rest are shown greyed, because they are derived and typing over one would produce a filename that does not follow from the values beside it. :param r: the row's values by column key; missing keys render blank. """ row = self._table.rowCount() self._table.insertRow(row) for col, key in enumerate(ROW_COLUMNS): val = r.get(key, "") item = table_item("" if val is None else str(val)) if key in _EDITABLE: item.setFlags(item.flags() | Qt.ItemIsEditable) else: item.setFlags(item.flags() & ~Qt.ItemIsEditable) item.setForeground(Qt.gray) if key in _INT_COLS: item.setTextAlignment(Qt.AlignCenter) self._table.setItem(row, col, item) def _on_item_changed(self, item: QTableWidgetItem) -> None: """Coerce an edited cell and rebuild the filename it feeds. Integer columns are clamped to at least 1, and unparseable input reverts to 1 rather than being left as text a later stage would choke on. The re-entrancy guard is what stops the rewrite from re-triggering this. :param item: the edited cell; edits to a read-only column are ignored. """ if self._guard: return col = item.column() key = ROW_COLUMNS[col] if key not in _EDITABLE: return self._guard = True try: if key in _INT_COLS: try: n = max(1, int(float(item.text()))) except (ValueError, TypeError): n = 1 item.setText(str(n)) self._recompute_canonical(item.row()) finally: self._guard = False self._refresh_summary() def _recompute_canonical(self, row: int) -> None: """Rebuild the read-only Filename cell from the editable columns.""" get = lambda k: (self._table.item(row, ROW_COLUMNS.index(k)) or table_item("")).text() plate = get("plate") or "plate1" well = get("well") or f"{plate}_A01" if not well.startswith(plate + "_") and "_" not in well: well = f"{plate}_{well}" try: field = max(1, int(float(get("field") or 1))) channel = max(1, int(float(get("channel") or 1))) time = max(1, int(float(get("time") or 1))) except (ValueError, TypeError): field = channel = time = 1 name = _yokogawa_name(well, time, field, channel) cell = self._table.item(row, ROW_COLUMNS.index("canonical")) if cell is not None: cell.setText(name)
[docs] def rows(self) -> List[Dict[str, Any]]: """Return the current (possibly edited) rows as dicts.""" out: List[Dict[str, Any]] = [] for row in range(self._table.rowCount()): rec: Dict[str, Any] = {} for col, key in enumerate(ROW_COLUMNS): item = self._table.item(row, col) text = item.text() if item is not None else "" if key in _INT_COLS: try: rec[key] = max(1, int(float(text))) except (ValueError, TypeError): rec[key] = 1 else: rec[key] = text out.append(rec) return out
[docs] def write_filename_map(self, dst: Any) -> Path: """Write the current rows to ``dst`` as a ``filename_map.csv``. :param dst: path of the CSV to write, converted to :class:`~pathlib.Path`; its parent folders are created. :returns: the path written. """ from ..folder_metadata import save_filename_map mappings = rows_to_mappings(self.rows()) return save_filename_map(Path(dst), mappings)
def _refresh_summary(self) -> None: """Restate what the table now holds, above it.""" from ..ingest_preview import summarize_rows self._summary.setText("Review & edit the extracted metadata — " + summarize_rows(self.rows()))
[docs] class MetadataTableDialog(QDialog): """Modal wrapper around :class:`MetadataTablePanel` for the drop flow. :param rows: preview rows to edit. :param dst: where the ``filename_map.csv`` is written on Apply. :param on_apply: optional callback invoked with the written CSV path. :param parent: parent widget; ownership only. """ def __init__(self, rows: List[Dict[str, Any]], dst: Any, on_apply: Optional[Callable[[Path], None]] = None, parent: Optional[QWidget] = None): """Wrap the metadata table in a modal review dialog. :param rows: the extracted metadata to review. :param dst: where ``filename_map.csv`` is written on Apply. :param on_apply: called with the written path once it lands. :param parent: parent widget, or ``None``. """ super().__init__(parent) self.setWindowTitle("Review extracted image metadata") self.setModal(True) self.resize(760, 460) self._dst = Path(dst) self._on_apply = on_apply self._written: Optional[Path] = None self.panel = MetadataTablePanel(rows, self) info = QLabel( "Edit the plate / well / field / channel / time columns as needed. " "The Filename column updates live. Apply writes a filename_map.csv " "next to your data — the pipeline uses it to name every extracted " "image.", self) info.setWordWrap(True) buttons = QDialogButtonBox( QDialogButtonBox.Cancel, Qt.Horizontal, self) self._apply_btn = QPushButton("Apply && write filename_map.csv", self) buttons.addButton(self._apply_btn, QDialogButtonBox.AcceptRole) buttons.rejected.connect(self.reject) self._apply_btn.clicked.connect(self._apply) lay = QVBoxLayout(self) lay.addWidget(info) lay.addWidget(self.panel) row = QHBoxLayout() row.addStretch(1) row.addWidget(buttons) lay.addLayout(row) @property
[docs] def written_path(self) -> Optional[Path]: """Path of the CSV written on Apply, or None if cancelled.""" return self._written
def _apply(self) -> None: """Write ``filename_map.csv`` and close. A failure leaves the written path unset rather than raising, so the caller sees "nothing was written" instead of an exception out of a dialog's button. """ try: self._written = self.panel.write_filename_map(self._dst) if self._on_apply is not None: self._on_apply(self._written) except Exception: self._written = None self.accept()