Source code for spacr.qt.widgets.external_mask_inputs
"""Editable image/mask detection table for the External Masks module."""
from __future__ import annotations
from typing import Any, Iterable, List
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QAbstractItemView,
QComboBox,
QFileDialog,
QHBoxLayout,
QHeaderView,
QPushButton,
QTableWidget,
QTableWidgetItem,
QVBoxLayout,
QWidget,
)
from ...external_masks import (
InputGroup,
OBJECT_TYPES,
ROLES,
detect_inputs,
)
from ...object_roles import ORGANELLE_ROLES, organelle_index, organelle_label
from .sortable_table import install_sorting, table_item
_ORGANELLE_ROLE_SET = frozenset(ORGANELLE_ROLES)
def _object_caption(object_type: Any) -> str:
"""What a mask type is called on screen.
An organelle slot reads as a number -- ``Organelle 2`` -- never as the
lettered role it is stored under (``organelleb``), the same caption the
Import Project screen and the settings forms give it. The fixed types
read as before: ``Cell``, ``Nucleus``, ``Pathogen``.
:param object_type: a role from :data:`spacr.external_masks.OBJECT_TYPES`.
:returns: the caption for that role.
"""
name = str(object_type)
if name in _ORGANELLE_ROLE_SET:
return organelle_label(name)
return name.title()
def _offered_object_types(groups: Iterable[InputGroup]) -> List[str]:
"""The mask types the Mask type box lists, for these groups.
The fixed types, then organelle slots 1 through N, where N is the larger
of the highest slot any group already uses and the number of groups --
one organelle slot per group is the most a set of groups can fill, so
every assignment stays reachable without listing all
:data:`spacr.object_roles.ORGANELLE_ROLES`.
:param groups: the input groups the table shows.
:returns: role names in the order the box lists them.
"""
groups = list(groups)
highest = max((organelle_index(group.object_type) for group in groups
if group.object_type in _ORGANELLE_ROLE_SET), default=0)
count = min(max(highest, len(groups), 1), len(ORGANELLE_ROLES))
fixed = [role for role in OBJECT_TYPES if role not in _ORGANELLE_ROLE_SET]
return fixed + list(ORGANELLE_ROLES[:count])
[docs]
class ExternalMaskInputWidget(QWidget):
"""Group dropped files and let the user correct every inferred role.
:param value: the groups already saved. ``None`` opens with none, which
is the ordinary case: the groups are built by dropping files in.
:param parent: parent widget.
"""
value_changed = Signal()
def __init__(self, value: Any = None, parent=None):
"""Build the external-mask input table.
:param value: the input groups to start with.
:param parent: parent widget, or ``None``.
"""
super().__init__(parent)
self._groups: List[InputGroup] = []
outer = QVBoxLayout(self)
outer.setContentsMargins(0, 0, 0, 0)
outer.setSpacing(6)
self._table = QTableWidget(0, 6, self)
install_sorting(self._table)
self._table.setHorizontalHeaderLabels([
"Source", "Detected as", "Use as", "Mask type", "Files",
"Confidence",
])
self._table.setSelectionBehavior(QAbstractItemView.SelectRows)
self._table.setSelectionMode(QAbstractItemView.ExtendedSelection)
self._table.setEditTriggers(QAbstractItemView.NoEditTriggers)
self._table.verticalHeader().setVisible(False)
header = self._table.horizontalHeader()
header.setSectionResizeMode(0, QHeaderView.Stretch)
for column in range(1, 6):
header.setSectionResizeMode(column, QHeaderView.ResizeToContents)
self._table.setMinimumHeight(190)
outer.addWidget(self._table)
buttons = QHBoxLayout()
self._add_files = QPushButton("Add files…", self)
self._add_folder = QPushButton("Add folder…", self)
self._remove = QPushButton("Remove selected", self)
self._add_files.clicked.connect(self._pick_files)
self._add_folder.clicked.connect(self._pick_folder)
self._remove.clicked.connect(self.remove_selected)
buttons.addWidget(self._add_files)
buttons.addWidget(self._add_folder)
buttons.addWidget(self._remove)
from ..import_demo import _import_test_data_button
example = _import_test_data_button(self, "auto", self._use_test_data)
example.setObjectName("ExternalMasksTestDataButton")
buttons.addWidget(example)
buttons.addStretch(1)
outer.addLayout(buttons)
self.set_value(value or [])
def _use_test_data(self, inputs) -> int:
"""Add an Import test variant's images and its masks folder.
The destination is set beside the images when the screen holding
this table has none yet.
:param inputs: :func:`spacr.import_examples.variant_inputs` for it.
:returns: the number of files added.
"""
import os
paths = [str(inputs["images"])]
masks = list(dict(inputs["masks"]).values())
if masks:
paths.append(os.path.dirname(str(masks[0])))
added = self.add_paths(paths)
holder = self.parent()
while holder is not None and not hasattr(holder, "_settings_model"):
holder = holder.parent()
model = getattr(holder, "_settings_model", None)
widget = getattr(model, "_widgets", {}).get("dst")
if widget is not None and not model._read_widget(widget):
model.set_value_for_key("dst", str(inputs["images"]) + "_spacr")
return added
[docs]
def add_paths(self, paths: Iterable[Any]) -> int:
"""Detect and append one drag/drop or picker batch.
:param paths: dropped or picked files and folders, grouped by
:func:`spacr.external_masks.detect_inputs`. A group already held
gains the new paths and keeps the lower confidence.
"""
detected = detect_inputs(list(paths))
existing = {group.key: group for group in self._groups}
for group in detected:
held = existing.get(group.key)
if held is None:
self._groups.append(group)
existing[group.key] = group
else:
held.paths = sorted(set([*held.paths, *group.paths]))
held.confidence = min(held.confidence, group.confidence)
self._rebuild()
if detected:
self.value_changed.emit()
return sum(len(group.paths) for group in detected)
[docs]
def set_value(self, value: Any) -> None:
"""Replace every group from a settings value.
:param value: the groups, as the settings dict carries them.
"""
self._groups = []
if isinstance(value, (str, bytes)):
value = [value]
values = list(value or [])
if values and all(isinstance(item, (str, bytes)) for item in values):
self._groups = detect_inputs(values)
else:
self._groups = [InputGroup.from_value(item) for item in values]
self._rebuild()
[docs]
def get_value(self) -> List[dict]:
"""The groups worth saving, in the shape the settings dict wants.
Ignored groups are dropped: "Ignore" marks files that are not part
of the run, so they are not written into the settings.
:returns: one dict per kept group.
"""
return [group.to_dict() for group in self._groups
if group.role != "ignore"]
[docs]
def groups(self) -> List[InputGroup]:
"""Every group, ignored ones included.
:returns: the groups, in display order.
"""
return [InputGroup.from_value(group.to_dict())
for group in self._groups]
[docs]
def group_count(self) -> int:
"""How many groups the files fell into.
:returns: the group count.
"""
return len(self._groups)
[docs]
def file_count(self) -> int:
"""How many files were grouped, across all groups.
:returns: the file count.
"""
return sum(len(group.paths) for group in self._groups)
[docs]
def set_group_role(self, row: int, role: str) -> bool:
"""Correct one group's inferred role.
An unknown role is refused rather than stored: the role decides how
the files are read, and an unrecognised one would fail later, in the
run, rather than here where the user can see what they typed.
:param row: which group.
:param role: the role's name.
:returns: True when the row and role were both valid.
"""
if role not in ROLES or not 0 <= row < len(self._groups):
return False
self._groups[row].role = role
self._rebuild()
self.value_changed.emit()
return True
[docs]
def set_group_object_type(self, row: int, object_type: Any) -> bool:
"""Correct one group's object type.
An empty or unassigned value clears the type rather than storing a
placeholder string, so the settings say "not chosen" rather than
naming an object that does not exist.
:param row: which group.
:param object_type: the object type, or None to clear it.
:returns: True when the row was valid.
"""
if not 0 <= row < len(self._groups):
return False
value = None if object_type in (None, "", "unassigned") else str(object_type)
if value is not None and value not in OBJECT_TYPES:
return False
self._groups[row].object_type = value
self._rebuild()
self.value_changed.emit()
return True
[docs]
def remove_selected(self) -> None:
"""Drop the selected groups."""
rows = {index.row() for index in self._table.selectedIndexes()}
groups = sorted({self._group_of_row(row) for row in rows} - {-1},
reverse=True)
for group in groups:
del self._groups[group]
if groups:
self._rebuild()
self.value_changed.emit()
def _group_of_row(self, row: int) -> int:
"""The group a drawn row stands for, or -1."""
item = self._table.item(row, 0)
index = None if item is None else item.data(Qt.UserRole)
if index is None or not 0 <= int(index) < len(self._groups):
return -1
return int(index)
def _rebuild(self) -> None:
"""Redraw the table, one row per input group.
Each row's first cell carries the index of the group it was built from:
the table sorts, so the third row is not the third group after a header
click, and the per-row combo boxes have to write back to the right one.
The object-type box is enabled only for a group being used as a mask --
an intensity image has no object type to name. It lists
:func:`_offered_object_types`, captioned by :func:`_object_caption`.
"""
self._table.setRowCount(len(self._groups))
offered = _offered_object_types(self._groups)
for row, group in enumerate(self._groups):
source = group.root
if len(group.paths) == 1:
source = group.paths[0]
source_item = table_item(source)
source_item.setData(Qt.UserRole, row)
source_item.setToolTip("\n".join(group.paths[:20]))
self._table.setItem(row, 0, source_item)
named = (_object_caption(group.object_type)
if group.object_type else "unassigned")
detected = (
f"mask · {named}" if group.role == "mask" else group.role
)
detected_item = table_item(detected)
detected_item.setToolTip(group.reason)
self._table.setItem(row, 1, detected_item)
role_box = QComboBox(self._table)
for role in ROLES:
role_box.addItem(role.title(), role)
role_box.setCurrentIndex(max(role_box.findData(group.role), 0))
role_box.currentIndexChanged.connect(
lambda _index, r=row, box=role_box:
self._role_changed(r, str(box.currentData())))
self._table.setCellWidget(row, 2, role_box)
object_box = QComboBox(self._table)
object_box.addItem("Choose…", None)
for object_type in offered:
object_box.addItem(_object_caption(object_type), object_type)
index = object_box.findData(group.object_type)
object_box.setCurrentIndex(max(index, 0))
object_box.setEnabled(group.role == "mask")
object_box.currentIndexChanged.connect(
lambda _index, r=row, box=object_box:
self._object_changed(r, box.currentData()))
self._table.setCellWidget(row, 3, object_box)
count = table_item(str(len(group.paths)))
count.setTextAlignment(Qt.AlignCenter)
self._table.setItem(row, 4, count)
confidence = table_item(f"{group.confidence:.0%}")
confidence.setTextAlignment(Qt.AlignCenter)
self._table.setItem(row, 5, confidence)
def _role_changed(self, row: int, role: str) -> None:
"""Change what one group is used as, and re-gate its object type.
:param row: the group's index.
:param role: the new role.
"""
if 0 <= row < len(self._groups):
self._groups[row].role = role
object_box = self._table.cellWidget(row, 3)
if object_box is not None:
object_box.setEnabled(role == "mask")
self.value_changed.emit()
def _object_changed(self, row: int, value: Any) -> None:
"""Change which object a mask group labels.
:param row: the group's index.
:param value: the new object type; anything not in the known set clears
it rather than being stored as a name nothing will match.
"""
if 0 <= row < len(self._groups):
self._groups[row].object_type = (
str(value) if value in OBJECT_TYPES else None)
self.value_changed.emit()
def _pick_files(self) -> None:
"""Ask for image and mask files and add them."""
paths, _selected = QFileDialog.getOpenFileNames(
self, "Choose intensity images and label masks", "",
"Images (*.tif *.tiff *.png *.jpg *.jpeg *.bmp);;All files (*)")
if paths:
self.add_paths(paths)
def _pick_folder(self) -> None:
"""Ask for a folder of images or masks and add it."""
path = QFileDialog.getExistingDirectory(
self, "Choose a folder containing images or masks")
if path:
self.add_paths([path])
__all__ = ["ExternalMaskInputWidget"]