"""The Curate screen, where a mask and its tracks are corrected by hand.
A layer viewer with two panels beside it: the brush, and the track surgery.
They share one screen because a track usually breaks where the mask lost the
cell for a frame, so joining the track and correcting the mask at that frame
are one correction.
Everything on this screen is recorded. A ledger is written beside each artefact
(see :mod:`spacr.curation`), so a hand-edited mask can be told apart from a
segmented one later.
"""
from __future__ import annotations
import logging
import os
from typing import Optional
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (QFileDialog, QHBoxLayout, QLabel, QLineEdit,
QPushButton, QTabWidget,
QVBoxLayout, QWidget)
from ...curation import is_curated
from ..curation_tool import BrushPanel, TrackCurationPanel
from ..layer_viewer import LayerViewer
from ..theme import SPACING, active_palette
from ..widgets.collapsible_splitter import CollapsibleSplitter
LOG = logging.getLogger(__name__)
__all__ = [
"CurateScreen",
"register",
"APP_KEY",
]
APP_KEY = "curate"
[docs]
class CurateScreen(QWidget):
"""Correct a mask by hand and curate its tracks, recording every change.
:param parent: parent widget.
"""
#: A mask was opened. Carries the path.
mask_opened = Signal(str)
def __init__(self, parent=None):
"""Build the curation screen and arm its drop zone.
:param parent: parent widget, or ``None``.
"""
super().__init__(parent)
self.setObjectName("CurateScreen")
self._mask_path = ""
self.brush: Optional[BrushPanel] = None
self._build()
from ..dnd import install_for
install_for(self, "curate")
from .settings_model import retarget_field_tooltips
retarget_field_tooltips(self)
def _build(self) -> None:
"""Lay out the mask and track source rows, the viewer and the tool tabs."""
outer = QVBoxLayout(self)
outer.setContentsMargins(SPACING["lg"], SPACING["lg"],
SPACING["lg"], SPACING["lg"])
outer.setSpacing(SPACING["sm"])
title = QLabel("Curate", self)
title.setObjectName("DisplayHeading")
outer.addWidget(title)
intro = QLabel(
"Paint over a mask to correct it, and join, split or delete "
"tracks by hand. Every correction is written to a ledger beside "
"the file, so a curated dataset can be told from a raw one.", self)
intro.setObjectName("Muted")
intro.setWordWrap(True)
outer.addWidget(intro)
source = QHBoxLayout()
source.addWidget(QLabel("Mask", self))
self._mask_edit = QLineEdit(self)
self._mask_edit.setPlaceholderText("…/masks/plate1_A01_1.tif")
self._mask_edit.returnPressed.connect(self.open_mask)
source.addWidget(self._mask_edit, 1)
self._browse_mask = QPushButton("Browse…", self)
self._browse_mask.clicked.connect(self._choose_mask)
source.addWidget(self._browse_mask)
self._open_mask = QPushButton("Open", self)
self._open_mask.setObjectName("PrimaryButton")
self._open_mask.clicked.connect(self.open_mask)
source.addWidget(self._open_mask)
outer.addLayout(source)
tracks_row = QHBoxLayout()
tracks_row.addWidget(QLabel("Tracks", self))
self._tracks_edit = QLineEdit(self)
self._tracks_edit.setPlaceholderText(
"…/tracks/btrack_tracks_cell_plate1_A01_1.csv")
self._tracks_edit.returnPressed.connect(self.open_tracks)
tracks_row.addWidget(self._tracks_edit, 1)
self._browse_tracks = QPushButton("Browse…", self)
self._browse_tracks.clicked.connect(self._choose_tracks)
tracks_row.addWidget(self._browse_tracks)
self._open_tracks = QPushButton("Open", self)
self._open_tracks.clicked.connect(self.open_tracks)
tracks_row.addWidget(self._open_tracks)
outer.addLayout(tracks_row)
split = CollapsibleSplitter(Qt.Horizontal, self,
persist_key="curate::body")
self.viewer = LayerViewer(parent=self)
split.add_section(self.viewer, "Image",
persist_key="curate/Image", stretch=1, extent=680)
self.tabs = QTabWidget(self)
self.brush_host = QWidget(self.tabs)
self._brush_layout = QVBoxLayout(self.brush_host)
self._brush_layout.setContentsMargins(0, 0, 0, 0)
self._brush_hint = QLabel(
"Open a mask to paint on it.", self.brush_host)
self._brush_hint.setWordWrap(True)
self._brush_layout.addWidget(self._brush_hint)
self._brush_layout.addStretch(1)
self.tabs.addTab(self.brush_host, "Brush")
self.tracks = TrackCurationPanel(self.tabs)
self.tabs.addTab(self.tracks, "Tracks")
split.add_section(self.tabs, "Curation tools",
persist_key="curate/Curation tools", stretch=0,
extent=320)
outer.addWidget(split, 1)
self._body_splitter = split
self.status = QLabel("", self)
self.status.setObjectName("Muted")
self.status.setWordWrap(True)
outer.addWidget(self.status)
def _choose_mask(self) -> None:
"""Ask for a label mask and open it."""
path, _ = QFileDialog.getOpenFileName(
self, "Open a label mask", self._mask_edit.text().strip(),
"Masks (*.tif *.tiff *.png *.npy);;All files (*)")
if path:
self._mask_edit.setText(path)
self.open_mask()
[docs]
def open_mask(self) -> Optional[BrushPanel]:
"""Load the mask and put a brush on it. Returns the panel."""
path = self._mask_edit.text().strip()
if not path or not os.path.isfile(path):
self.status.setText("Choose a mask file first.")
return None
layer = self.viewer.add_labels_file(path)
if layer is None:
self.status.setText(f"Could not load {path} as a label mask.")
return None
self._mask_path = path
panel = self.attach_brush(layer, artifact=path)
self.mask_opened.emit(path)
self._say_whether_curated(path)
return panel
[docs]
def attach_brush(self, layer, *, artifact: str = "") -> BrushPanel:
"""Put a brush panel over ``layer``. Tests call this directly.
:param layer: the labels layer the new :class:`BrushPanel` edits; any
previous brush panel is stopped and removed first.
"""
if self.brush is not None:
self.brush.close()
self.brush.setParent(None)
self.brush.deleteLater()
self._brush_hint.setVisible(False)
self.brush = BrushPanel(self.viewer.canvas, self.brush_host,
layer=layer, artifact=artifact)
self._brush_layout.insertWidget(0, self.brush)
return self.brush
def _choose_tracks(self) -> None:
"""Ask for a tracks table and open it."""
path, _ = QFileDialog.getOpenFileName(
self, "Open a tracks table", self._tracks_edit.text().strip(),
"CSV (*.csv);;All files (*)")
if path:
self._tracks_edit.setText(path)
self.open_tracks()
[docs]
def open_tracks(self) -> bool:
"""Load a tracks CSV into the track panel."""
path = self._tracks_edit.text().strip()
if not path or not os.path.isfile(path):
self.status.setText("Choose a tracks CSV first.")
return False
opened = self.tracks.load(path) is not None
if opened:
self.tabs.setCurrentWidget(self.tracks)
self._say_whether_curated(path)
return opened
def _say_whether_curated(self, path: str) -> None:
"""The one question the ledger exists to answer, on screen.
Said on *open*, not on save: the person who needs to know whether a
file has been edited by hand is the one about to analyse it, and they
find out by opening it.
"""
if is_curated(path):
self.status.setText(
f"{os.path.basename(path)} has been curated by hand — see "
f"{os.path.basename(path)}.curation.json for what changed.")
self.status.setStyleSheet(
f"color: {active_palette()['warning']};")
else:
self.status.setText(
f"{os.path.basename(path)} is as the pipeline produced it.")
self.status.setStyleSheet("")
[docs]
def closeEvent(self, event) -> None:
"""Stop background work and unlink before going away.
:param event: the Qt close event.
"""
if self.brush is not None:
self.brush.stop_painting()
super().closeEvent(event)
APP_NAME = "Curate"
APP_DESCRIPTION = (
"Correct segmentation masks and tracking assignments manually while "
"recording each edit in the curation log."
)
APP_INTRO = (
"Cellpose merges two touching cells; btrack breaks a track when a cell "
"leaves focus for a frame. Both are obvious to look at and, until now, "
"impossible to correct. Paint over the mask with a world-space brush, "
"join / split / delete tracks by hand, and every correction is written "
"to a ledger beside the file — so a curated dataset can be told from a "
"raw one, by someone who was not there when it was edited.")
APP_CLI_NOTE = (
"Curate is hand correction — the brush and the track surgery are the "
"whole feature; run it in the GUI (spacr-qt). Headless, "
"spacr.curation.TrackCuration does the same edits to a tracks table with "
"the same ledger.")
[docs]
def make_curate_screen(**_kwargs) -> CurateScreen:
"""Build the screen. The ``factory=`` for :func:`spacr.qt.app.register_app`."""
return CurateScreen()
[docs]
def register(*, section: Optional[str] = None, stage: Optional[str] = None,
key: str = APP_KEY):
"""Put Curate in the app registry. Idempotent.
:returns: the registry row, or ``None`` when the key was already there.
"""
from ..app import APPS, SECTION_TOOLS, STAGE_ALPHA, register_app
if any(row[0] == key for row in APPS):
return None
return register_app(
key, APP_NAME, APP_DESCRIPTION, section or SECTION_TOOLS,
factory=make_curate_screen,
stage=STAGE_ALPHA if stage is None else stage,
intro=APP_INTRO, cli_note=APP_CLI_NOTE,
api_module="qt/screens/curate",
translations=("Kurera", "Kuratieren", "Curación", "校正", "Curadoria",
"क्यूरेट", "큐레이트", "Grisja", "Curation"))