"""Interactive editing and curation of segmentation masks.
Make Masks is filed under **Tools** on the home screen and is the manual
half of segmentation: it corrects masks a model got wrong, one field at a
time, and its masthead opens the Cellpose workflows that produced them.
:class:`MakeMasksScreen` loads images and labelled masks from
``<folder>/masks`` and saves edited labels as ``uint16`` TIFF files.
THE TEN TOOLS, in :data:`TOOL_MODES` order, because this vocabulary is what
a reader needs before opening the screen:
**Brush** and **Erase** paint and unpaint the active label a pixel at a time.
**Erase object** removes a whole label in one click. **Wand** grows a region
from the pixel clicked and adds it to the mask by default. Hold Ctrl while
clicking to remove that region; the tolerance is relative to the image's intensity range
by default, see :func:`spacr.qt.mask_engine.relative_tolerance`. **Draw**
traces a free-form outline that closes and fills as ONE object -- the tool a
brush is not, because a brush stamps disks along the path, so tracing a rim
with it labels the rim and leaves the middle background. **Divide / Merge**
uses a left-button drag to divide and a right-button drag to merge crossed
objects under the first touched identifier without changing their pixels.
The merged identity is retained when saving and reopening. Dividing drags a
line across a merged object and makes it two, keeping the original identifier
on the larger component and giving the smaller a new one, with every other
object untouched; :data:`spacr.qt.mask_engine.DIVIDE_CUT_WIDTH` is the cut
width. **Zoom** rectangle-drags the view and changes no labels at all.
**Recrop** is the ninth and is the only one that changes WHICH field is on
screen rather than what is painted on it. A staged crop holding several cells
is not one training example, and curating it as one teaches a network that
two objects are one picture -- so a box round an object writes that region of
both the image and the mask as a field of its own
(:func:`spacr.qt.mask_engine.write_recrop`), queued straight after the current
field, and the multi-object original is retired into
``recropped_originals/`` rather than curated
(:func:`spacr.qt.mask_engine.retire_recropped_original`). A box shorter than
:data:`spacr.qt.mask_engine.RECROP_MIN_SIDE` on a side, or repeating a cut
already made past :data:`spacr.qt.mask_engine.RECROP_MAX_OVERLAP`, is refused;
objects the box cuts through are dropped, because an object whose boundary is
where the mouse was released is not that object; and the labels that survive
are renumbered from one.
**Ruler** is the tenth. Drag between image pixels to read a length without
editing labels. Zoom and pan preserve it; right-click with Ruler selected
clears it. The same ruler is available on the paired live preview canvases.
Each field has a :class:`spacr.curation.CurationLog`, initialized from any
existing sidecar. :func:`spacr.qt.mask_engine.save_mask` writes the labels and
ledger together, allowing :func:`spacr.curation.is_curated` to distinguish
manually corrected masks from pipeline output. A single gesture produces one
ledger entry and one undo step.
:meth:`MakeMasksScreen.run_cellpose` applies the pipeline's resolved
Cellpose-SAM model to the open field and displays the mask, cell-probability
map, and flow field. The intermediate outputs support evaluation of
:data:`CELLPROB_THRESHOLD` when detections are missing or incomplete.
Editor modes are assembled from :func:`tool_row_entries` and
:data:`TOOL_MODES`. :meth:`MakeMasksScreen.add_toolbar_action` inserts
non-mode actions into the same toolbar. The settings panel carries the
operations that are not gestures -- object filling and relabeling, swapping
object and background, size filtering and Otsu detection, with undo and redo
over all of them -- alongside the brush, wand and display controls, and can
be hidden to return its width to the canvas.
Image inversion and swapping object and background are separate operations.
The first changes the image seen by the detector; the second changes labels.
**Invert image**, in the Display category, is THE inversion. It draws the
field as its own negative and hands the detectors that same negative: both
detect buttons and the live magnifier segment
:meth:`MakeMasksScreen._detector_image`, so a threshold written for bright
objects takes dark ones, and a banner above the image says so for as long as
it is on. The corner readout's pixel intensity follows the inverted image.
Object mean intensities and the object filter still read the loaded pixels.
Saving writes label masks without changing the source image.
IT USED TO BE TWO SWITCHES AND THEY COULD DISAGREE. A display-only "Invert
image" drew a negative and left detection untouched; "Invert for detection"
inverted what the detectors read and drew nothing. A curator could hold
either without the other, and the first one's status line had to contradict
the second one's banner. They are one switch because the reason for the
switch is one intention: inverting a field of dark objects so that Otsu and
the magnifier can detect them. ``_cp_invert`` is now the same widget object as
``_invert_display`` under its old name, so the two cannot come apart.
**Swap object and background**, in Object operations, is the old "Invert
mask" under the name that describes it -- it flips the LABEL image, which on
an ordinary field leaves one object covering the frame. It is not
a picture invert and is deliberately not called one.
THE ARITHMETIC. :func:`~spacr.qt.mask_engine.invert_normalized` normalises
the field to 0..1 on its own range and takes ``1 - v``, and the
normalisation is not incidental: the Otsu correction is a
MULTIPLIER on an absolute level, so normalising first is what makes one
correction value mean the same thing on the next image.
:func:`~spacr.qt.mask_engine.invert_intensity` (dtype complement) and
:func:`~spacr.qt.mask_engine.invert_for_detection` (reflection about the
field's range) are what this screen used before and are kept for other
callers. :func:`~spacr.qt.mask_engine.invert_mask` flips labels.
Additional segmentation tools are opened from the masthead in
:data:`FOLD_ORDER` through
:class:`~spacr.qt.widgets.fold_strip.FoldStrip`. **Mask the whole folder**
applies Cellpose to every image in the selected folder, while **Save mask** on
the Curate page calls :meth:`spacr.curation.MaskCuration.save_mask`. Folded
modules retain their existing widgets inside :class:`FoldedModulePanel`.
Time-series tracking is integrated with Mask Generation as the Timelapse
settings category; see :mod:`spacr.qt.screens.mask`. Motility Assay instead
belongs to Measure because it consumes existing masks and writes measurement
tables rather than generating masks.
"""
from __future__ import annotations
import logging
import math
import os
import re
import threading
import time
import weakref
from collections import deque
from functools import partial
from importlib.util import find_spec
from typing import Any, List, NamedTuple, Optional
import numpy as np
from PySide6.QtCore import (
QEvent,
QObject,
QPoint,
QPointF,
QRect,
QRectF,
QThread,
QTimer,
Qt,
Signal,
)
from PySide6.QtGui import (
QBrush,
QColor,
QCursor,
QImage,
QPainter,
QPen,
QPixmap,
)
from PySide6.QtWidgets import (
QApplication,
QComboBox,
QDialog,
QDialogButtonBox,
QDoubleSpinBox,
QFileDialog,
QFormLayout,
QGroupBox,
QHBoxLayout,
QInputDialog,
QLabel,
QLineEdit,
QMenu,
QMessageBox,
QPlainTextEdit,
QProgressBar,
QPushButton,
QScrollArea,
QSizePolicy,
QSlider,
QSpinBox,
QSplitter,
QStackedWidget,
QTabBar,
QTabWidget,
QVBoxLayout,
QWidget,
)
from ...curation import CurationLog
from .. import cpu_modes
from .. import detect_chain
from .. import iconset
from .. import mask_engine as engine
from .. import organelle_modes
from .. import prefs
from .. import wand_rescue
from ..hidpi import follow_device_ratio, logical_size, scaled_for
from ..theme import (SPACING, active_palette, block_surface,
ensure_widget_qss_applied, mark_surface,
register_widget_qss, set_a_sheeted_widgets_own_rule)
from ..widgets import Card, Divider, EmptyState
from ..widgets.fold_strip import FoldStrip
from ..widgets.section import Section
from ..widgets.toggle import Toggle
from .app_screen import ModuleHeader
LOG = logging.getLogger("spacr.qt.make_masks")
#: The registry key this screen answers to.
APP_KEY = "make_masks"
#: The masthead's name, matching the registry row so the page and the tile
#: that opens it say the same thing. The masthead carries no one-line
#: description beside it, so the name, the instruction under it and the
#: fold strip are the whole row.
HEADER_TITLE = "Make Masks"
HEADER_INSTRUCTION = (
"Open a folder of images, correct each mask, and save it back.")
#: The applying half of the Cellpose loop, as a key. It has never had a tile
#: of its own — :class:`~spacr.qt.screens.train_cellpose.CellposeWorkbenchScreen`
#: carries it as a tab — but it has artwork and a settings form under this
#: name, and it is what "mask the whole folder" runs.
MASK_FOLDER_KEY = "cellpose_all"
#: The modules that fold into this screen, in the order their buttons appear
#: on the masthead. The button IS the module, so this is also the list of
#: keys :meth:`MakeMasksScreen.folded_screen` knows how to build.
#:
#: TIMELAPSE AND MOTILITY ARE NOT HERE. They were, and they were in the
#: wrong home: this screen is hand-curation of masks that already exist,
#: and both of those are things mask GENERATION does over a series --
#: their settings overlap that module's, not this one's tools. They fold
#: into Mask Generation instead, as switches that reveal their own
#: settings categories on its form; see :mod:`spacr.qt.screens.mask`.
FOLD_ORDER = (
"train_cellpose",
MASK_FOLDER_KEY,
"model_compare",
"model_zoo",
"curate",
"napari_bridge",
)
#: ``key -> (name, description, stage)`` for a folded module whose registry
#: row has gone.
#:
#: :class:`~spacr.qt.widgets.fold_strip.FoldStrip` reads a button's name, its
#: tooltip and its hover colour out of the app registry, which is right while
#: the module still has a row and answers nothing once it is folded and the
#: row is dropped: the tooltip empties and the stage falls back to stable, so
#: an alpha module's button would light blue where its tile lit green-cyan.
#: This is what the tile said, kept so the button can go on saying it.
#:
#: The registry still wins whenever it has the row, and
#: ``test_the_fold_fallback_agrees_with_the_registry`` asserts the two agree
#: for every key that has one — so the pair cannot drift apart while both
#: exist, and what is left after the row goes is what was last true.
FOLD_FALLBACK = {
"train_cellpose": (
"Cellpose Workbench",
"Fine-tune a Cellpose model on labelled fields, then apply the "
"trained model or a stock model to an image folder.",
"beta"),
MASK_FOLDER_KEY: (
"Mask the whole folder",
"Apply the selected segmentation model to every image in the open "
"folder.",
"beta"),
"model_compare": (
"Model Compare",
"Compare two Cellpose models on the same fields using side-by-side "
"masks, object-count differences and adjusted Rand index (ARI).",
"stable"),
"model_zoo": (
"Model Zoo",
"Browse, verify, download and benchmark Cellpose and classifier "
"models on selected fields.",
"stable"),
"curate": (
"Curate",
"Correct segmentation masks and tracking assignments manually while "
"recording each edit in the curation log.",
"alpha"),
"napari_bridge": (
"Napari Bridge",
"Correct a segmentation mask in napari and import the revised labels "
"into spaCR.",
"alpha"),
}
#: A folded key that shares another key's screen. The two halves of the
#: Cellpose loop are two tabs of one workbench, so pressing either button has
#: to reach the same widget: a checkpoint trained on one tab is what the
#: other tab segments with, and a second copy of the screen would not have it.
FOLD_HOSTS = {MASK_FOLDER_KEY: "train_cellpose"}
_HEADLESS_PLATFORMS = ("offscreen", "minimal", "minimalegl", "vnc")
[docs]
def is_headless() -> bool:
"""Return True when no interactive display is attached to this process.
A modal ``QMessageBox`` runs its own event loop and only returns once
somebody clicks a button. Under the ``offscreen`` / ``minimal`` Qt
platform plugins — CI, a headless server, an SSH session with no X —
nobody can, so the call never returns and the whole app hangs. Any
message triggered by *data* rather than by a user gesture therefore
has to degrade to the status line instead.
Sibling screens (align / batch / convert / report / plate_view / …)
solve this by never opening a modal at all — see their ``_set_status``
docstrings, which cite this screen as the case that actually hung.
That is not sufficient here because "Clear mask" genuinely needs a
yes/no answer, so this screen keeps the modal when — and only when —
there is somebody able to answer it.
"""
app = QApplication.instance()
if app is None:
return True
try:
name = str(app.platformName()).strip().lower()
except Exception:
return True
return (not name) or name in _HEADLESS_PLATFORMS
MODE_NONE = "none"
MODE_BRUSH = "brush"
MODE_ERASE = "erase"
MODE_ERASE_OBJECT = "erase_object"
MODE_WAND_ADD = "wand_add"
MODE_WAND_ERASE = "wand_erase"
#: Trace a free-form outline; it closes and fills as ONE object. The tool a
#: brush is not: a brush stamps disks along the path, so tracing a rim with
#: it labels the rim and leaves the middle background.
MODE_DRAW = "draw"
MODE_BOX = "box"
#: Drag a line across a merged object and it becomes two, with every other
#: object's id untouched. The commonest correction a segmentation needs.
MODE_DIVIDE = "divide"
MODE_ZOOM = "zoom"
#: Drag a box round one object and that region of BOTH the image and the
#: mask becomes a field of its own, queued straight after this one. The only
#: tool here that changes WHICH field is on screen rather than what is
#: painted on it — see :func:`spacr.qt.mask_engine.write_recrop` for what it
#: writes and :func:`spacr.qt.mask_engine.retire_recropped_original` for
#: what happens to the field it was cut out of.
MODE_RECROP = "recrop"
MODE_RULER = "ruler"
#: The tools that fill the toolbar row, in the order they appear there:
#: ``(mode, label, icon key)``. THE ROW IS BUILT FROM THIS TABLE and not
#: from a list of literals at the layout site, so adding a tool to the row
#: is adding a line here. A ``MODE_*`` constant that nobody gave a line to
#: still reaches the row — :func:`tool_row_entries` names it after itself
#: — so a tool cannot be invisible because its author did not know this
#: table existed.
TOOL_MODES: List[tuple] = [
(MODE_BRUSH, "Brush", "brush"),
(MODE_ERASE, "Erase", "erase"),
(MODE_ERASE_OBJECT, "Erase object", "erase_object"),
(MODE_WAND_ADD, "Wand", "wand_add"),
(MODE_DRAW, "Draw", "draw"),
(MODE_BOX, "Box", "embeddings"),
(MODE_DIVIDE, "Divide / Merge", "divide"),
(MODE_ZOOM, "Zoom", "zoom"),
(MODE_RECROP, "Recrop", "recrop"),
(MODE_RULER, "Ruler", "measure"),
]
PAN_MODIFIERS = Qt.ShiftModifier | Qt.AltModifier
#: Smallest zoom viewport, in image pixels. Below a handful of pixels the
#: view is all interpolation and the wheel has nothing left to magnify.
MIN_VIEWPORT = 8
#: Decimals on the display-percentile boxes. Six, because the interesting
#: end of a 16-bit histogram is the last few pixels: on a 2048x2048 field,
#: 99.9999 clips four pixels and 99.9 clips four thousand, and the hot ones
#: are usually the entire reason the field looks black.
PERCENTILE_DECIMALS = 6
#: Starting width of the settings pane, in pixels, and the width it is
#: put back at when the settings button turns it on again after a session
#: that never dragged the splitter.
SETTINGS_WIDTH = 380
#: Pixels between the settings, on the left, and the image: the splitter's
#: handle, so the gap is also where the settings are dragged wider.
SETTINGS_GAP = 12
#: Width of the shortcut list beside the views, in pixels. FIXED, so every
#: pixel a wider window gives the right-hand pane goes to the image; the list
#: is a dozen short lines and does not want the room. Wide enough that the
#: longest keys -- ``Magnifier, whole image: right`` -- wrap onto two lines
#: and no line of prose breaks after one word, which is what a narrower
#: column was doing when this was measured on a rendered screen.
SHORTCUTS_WIDTH = 230
RESTORATION_SETTLE_MS = 400
#: Where the settings panel's folded categories are remembered, as the titles
#: folded away -- :func:`spacr.qt.preferences.get_section_layout` keyed by
#: this name.
_SETTINGS_LAYOUT_KEY = "make_masks/settings"
#: Settings categories this panel has renamed, old title to new. The stored
#: layout is a list of TITLES, so a user who folded the old one away would
#: find it open again after a rename and would have to fold it a second time;
#: reading the stored list through this keeps their arrangement.
#: Cellpose-SAM became Object detection and Auto-filter objects became the
#: Filter category; item 473 then folded Otsu, Object detection and its own
#: Object detections into ONE "Object detection" category, because they
#: were three categories answering one question -- what finds the objects
#: -- and only one of them was ever being read.
_RENAMED_CATEGORIES = {"Cellpose-SAM": "Object detection",
"Detection method": "Object detection",
"Otsu": "Object detection",
"Detection methods": "Object detection",
"Live magnifier": "Magnification settings",
"Auto-filter objects": "Filter"}
#: The two inks this screen cannot take from the shipped stylesheet: the
#: Filter category's removal ledger, which is RED because it lists what was
#: deleted, and the Invert warning, which is a warning and not prose.
#: Registered rather than written inline, so both follow the user's theme --
#: a colour set on the widget at build time is the colour it keeps when the
#: theme changes under it.
MAKE_MASKS_QSS_NAME = "MakeMasksInk"
#: The Filter category's removal ledger, by object name.
FILTER_LOG_NAME = "MakeMasksFilterLog"
#: The banner that says the detectors are reading an inverted image.
INVERT_WARNING_NAME = "MakeMasksInvertWarning"
#: What that banner says. It reminds the curator that masks are generated
#: from the inverted image, and it is NOT INSIDE THE SETTINGS PANEL: the Settings toggle hides that
#: panel to give the image the width, and the magnifier goes on inverting
#: while it is hidden. It sits between the tool row and the image, where
#: nothing can fold it away.
INVERT_WARNING_TEXT = (
"Invert image is on: the picture and detection use the INVERTED image. "
"Hover pixel intensity follows the inversion; object mean intensity "
"and Filter thresholds use the original loaded values. "
"The loaded image data and existing mask are unchanged.")
#: How many removal rows the Filter category's ledger shows before it
#: scrolls. The ledger has one row per removed object, and a
#: filter tightened too far removes hundreds; a box that grew with them
#: would push every other category off the panel, so it is a fixed six rows
#: with the rest a scroll away.
FILTER_LOG_ROWS = 6
#: Gaussian sigma the Otsu mode smooths with before it cuts, and what the
#: Otsu category's Smoothing box starts at. It is
#: :data:`spacr.qt.mask_engine._CLASSICAL_SMOOTHING` -- the value the
#: magnifier's threshold mode has always used -- written here so the panel
#: can start on it without importing a private name.
OTSU_SMOOTHING = 1.0
#: Where the local-threshold window starts, in pixels. Comfortably
#: larger than a cell at the magnifications this screen is used at and far
#: smaller than the scale illumination falls off over, which is the band a
#: local threshold has to sit in to be worth switching on. It is odd because
#: the window is centred on the pixel it judges.
OTSU_LOCAL_WINDOW = 51
#: How many bars the Otsu histogram preview draws. 256 is what a reader can
#: tell apart at the width the dialog opens at, and enough that a 16-bit
#: field's two populations are two humps rather than one.
OTSU_HISTOGRAM_BINS = 256
#: The shortcut list beside the Mask / Cell probability / Flows
#: views, as ``(keys, what it does)``. ONE TERSE LINE EACH: the panel is read at a glance between strokes, and a paragraph
#: there would be read once and then never again. Every line is a gesture
#: this module actually implements -- :data:`PAN_MODIFIERS`,
#: :meth:`_MaskCanvas.wheelEvent`, :meth:`_MaskCanvas.mousePressEvent` and
#: :meth:`MakeMasksScreen._install_shortcuts` are where they live -- so the
#: panel is checked against the code by
#: ``tests/qt/test_make_masks_shortcuts_otsu_and_object_edits.py`` rather
#: than believed.
SHORTCUT_HINTS = (
("Shift or Alt + drag", "Pan, from any tool"),
("Wheel", "Zoom about the cursor"),
("Right button", "Sweep away the objects it passes"),
("Ctrl + left click", "Split the object at its waist"),
("Ctrl + right click", "Remove the object under the cursor"),
("Left / Right arrows", "Previous / next field"),
("Ctrl+Z / Ctrl+Y", "Undo / redo"),
("Ctrl+S", "Save the mask"),
("Esc", "Reset the zoom"),
("B E W D V Z R", "Brush, erase, wand, draw, divide, zoom, recrop"),
("X", "Draw YOLO bounding boxes"),
("M", "Live magnifier"),
("Magnifier: wheel", "Box zoom"),
("Magnifier: Shift + wheel", "Box size"),
("Ctrl+L+right click", "Lock / unlock box"),
("Magnifier: drag", "Add the objects it passes over"),
("Magnifier, whole image: right", "Remove the object under it"),
)
def _make_masks_qss(palette, opacity) -> str:
"""This screen's two coloured inks, for one palette.
Registered through :func:`spacr.qt.theme.register_widget_qss`, so the
colours follow the user's theme without a line in ``theme.py`` and
without either widget holding a colour of its own.
The removal ledger is a text box and takes ``surface_alt`` under its red
rows, the fill every other boxed control on this panel stands on, put
through the page opacity the same way. The warning is a label over the
page and stays transparent: a filled strip across the top of the image
would be a second surface stacked on the one behind it.
:param palette: the theme palette, surfaces already rendered through the
page opacity.
:param opacity: the user's page-opacity preference, passed through.
"""
return f"""
QPlainTextEdit#{FILTER_LOG_NAME} {{
color: {palette['error']};
background: {block_surface('surface_alt', palette.get('theme'), opacity)};
border: 1px solid {palette['border_soft']};
}}
QLabel#{INVERT_WARNING_NAME} {{
color: {palette['warning']};
background: transparent;
}}
"""
register_widget_qss(MAKE_MASKS_QSS_NAME, _make_masks_qss, replace=True)
class _MaskLoadWorker(QThread):
"""Decode one image/mask pair without blocking Qt's main thread."""
def __init__(self, folder: str, filename: str, token: int, parent=None,
layout: Optional[dict] = None):
"""Load one image and its mask off the GUI thread.
:param folder: the folder holding the pair.
:param filename: the image to load; the mask is found beside it.
:param token: identifies the request this worker answers. The screen
compares it on completion, so a load the user has already
navigated away from is discarded rather than drawn over the
image now on screen.
:param parent: parent object.
:param layout: extra keywords for
:func:`spacr.qt.mask_engine.load_image_and_mask` -- the sibling
layout's ``masks_dir`` -- or ``None`` for the nested layout.
The result and the original exception are both kept as attributes
rather than raised, because a thread that raises loses the traceback
the screen needs to say what failed.
"""
super().__init__(parent)
self.folder = folder
self.filename = filename
self.token = token
self.layout = dict(layout or {})
self.result = None
self.error: Optional[Exception] = None
def run(self) -> None:
"""Load the pair, retaining either the result or original exception."""
try:
self.result = engine.load_image_and_mask(
self.folder, self.filename, **self.layout
)
except Exception as exc:
self.error = exc
LOG.exception(
"Failed to load mask source %s from %s",
self.filename,
self.folder,
)
#: Subfolders a dropped folder may hold that are not images to consolidate:
#: masks, the pipeline's raw-image backup and an earlier channel sort.
_NOT_CONSOLIDATED = ("masks", "orig", "sorted_channels")
def _copy_mask_as_tiff(source: str, target: str) -> None:
"""Copy a dropped mask to where Make Masks looks for it.
A TIFF is copied as it is; a mask saved in another format is read and
written as a TIFF, because Make Masks keeps every mask as
``<masks folder>/<stem>.tif``.
:param source: the dropped mask.
:param target: ``<image folder>/masks/<image stem>.tif``.
:raises OSError: when it cannot be read or written.
"""
import shutil
os.makedirs(os.path.dirname(target), exist_ok=True)
if source.lower().endswith((".tif", ".tiff")):
shutil.copy2(source, target)
return
from PIL import Image
from ...tiff_io import write_tiff
with Image.open(source) as image:
write_tiff(target, np.asarray(image))
class _FolderJobWorker(QThread):
"""Run one folder job -- consolidating or sorting -- off the GUI thread.
Item 593. Copying a folder tree or moving and merging a plate can take
minutes; the screen stays responsive and the job's lines go to the Make
Masks console, which accepts them from any thread.
"""
def __init__(self, job, parent=None):
"""Remember the job.
:param job: a callable taking no arguments; its return value is kept
as :attr:`result`, an exception it raises as :attr:`error`.
:param parent: parent object.
"""
super().__init__(parent)
self.job = job
self.result = None
self.error: Optional[Exception] = None
def run(self) -> None:
"""Run the job, keeping its result or the exception it raised."""
try:
self.result = self.job()
except Exception as exc:
self.error = exc
LOG.exception("Make Masks folder job failed")
class _SplitMenuButton(QPushButton):
"""A push button that also opens a menu from an arrow on its right.
Pressing the body does what the button always did -- ``clicked`` fires,
and :meth:`click` still presses it -- while the arrow, a right-click or
Alt+Down opens the menu. Unlike ``QPushButton.setMenu`` the body press
is never swallowed by the menu, so the button keeps its own action.
"""
#: Width of the arrow zone on the right, in pixels.
ARROW = 18
def __init__(self, text: str = "", parent=None):
"""A button reading ``text``, with no menu until one is set."""
super().__init__(text, parent)
self._split_menu: Optional[QMenu] = None
def set_split_menu(self, menu: QMenu) -> None:
"""Offer ``menu`` from the arrow."""
self._split_menu = menu
def split_menu(self) -> Optional[QMenu]:
"""The menu the arrow opens, or ``None``."""
return self._split_menu
def sizeHint(self):
"""The push button's size, widened by the arrow zone."""
hint = super().sizeHint()
hint.setWidth(hint.width() + self.ARROW)
return hint
def _pop_menu(self) -> None:
"""Open the menu under the button, if there is one and it is enabled."""
if self._split_menu is not None and self.isEnabled():
self._split_menu.popup(self.mapToGlobal(self.rect().bottomLeft()))
def mousePressEvent(self, event):
"""A press on the arrow opens the menu; anywhere else presses."""
if (self._split_menu is not None and event.button() == Qt.LeftButton
and event.position().x() >= self.width() - self.ARROW - 4):
event.accept()
self._pop_menu()
return
super().mousePressEvent(event)
def contextMenuEvent(self, event):
"""A right-click opens the menu too."""
if self._split_menu is None:
return super().contextMenuEvent(event)
event.accept()
self._pop_menu()
def keyPressEvent(self, event):
"""Alt+Down opens the menu from the keyboard."""
if (event.key() == Qt.Key_Down
and event.modifiers() & Qt.AltModifier):
event.accept()
self._pop_menu()
return
super().keyPressEvent(event)
def paintEvent(self, event):
"""Paint the button, then the down arrow in its right-hand zone."""
super().paintEvent(event)
if self._split_menu is None:
return
from PySide6.QtWidgets import QStyle, QStyleOption
option = QStyleOption()
option.initFrom(self)
size = 8
option.rect.setRect(self.width() - self.ARROW - 2,
(self.height() - size) // 2, size + 4, size)
painter = QPainter(self)
self.style().drawPrimitive(QStyle.PE_IndicatorArrowDown, option,
painter, self)
painter.end()
class _StatusLabel(QLabel):
"""The corner readout: one short line here, every line in the console.
Item 507. About a hundred places in this screen set the corner's text,
and some of them set a whole failure -- a backend's message and the
last forty lines it printed -- which piled up in the bottom right. The
corner now shows the first line, cut short, with the whole text as its
tooltip, and :attr:`said` hands every new text to the screen, which
puts it in its console.
:ivar said: ``(text,)``, each time the text changes to something new.
"""
said = Signal(str)
LIMIT = 160
def __init__(self, text: str = "", parent=None):
"""Start with ``text``, which is not reported."""
super().__init__(parent)
self._full = ""
self._quiet = False
self._show(text)
def text(self) -> str:
"""The whole text last set, not the shortened line shown."""
return self._full
def setText(self, text: str) -> None:
"""Show ``text``'s first line and report the whole of it once."""
text = str(text or "")
owner_ref = getattr(self, "_blind_owner", None)
owner = owner_ref() if owner_ref is not None else None
if owner is not None:
text = owner._blind_text(text)
changed = text != self._full
self._show(text)
if changed and text.strip() and not self._quiet:
self.said.emit(text)
def set_quietly(self, text: str) -> None:
"""Show ``text`` without reporting it; its sender already did."""
self._quiet = True
try:
self.setText(text)
finally:
self._quiet = False
def _show(self, text: str) -> None:
"""Put the first line, shortened, on screen; the rest in the tooltip."""
self._full = text
lines = text.strip().splitlines()
line = lines[0] if lines else ""
if len(line) > self.LIMIT:
line = line[:self.LIMIT - 1].rstrip() + "…"
elif len(lines) > 1:
line = line.rstrip() + " …"
super().setText(line)
self.setToolTip(text if line != text else "")
class _MasksConsole(QWidget):
"""Make Masks' console: every status, progress, warning and failure line.
Item 507. It sits under the shortcut list, right of the image, in a
section that folds (item 471's :class:`FoldSection`). It is a
:class:`~spacr.qt.widgets.console_panel.ConsolePanel` without the chat
and without the application-wide log, so it carries this screen's own
lines, and under it ONE :class:`~spacr.qt.widgets.eliding.ProgressLine`
for the task that is running now: a task that reports a hundred steps
rewrites that line a hundred times rather than stacking a hundred lines.
When the task ends, its last words go into the scrollback and the line
hides.
A line identical to the one before it is not written again, so a
message repeated on every mouse move reads once.
STREAMED OUTPUT. A backend worker's progress bars (tqdm redrawing itself
with a carriage return, ``\\r``, during Cellpose 3 restoration,
segmentation or a model download) and an install's pip lines arrive
faster than anyone reads, on threads that must not wait.
:meth:`stream` keeps only the newest state
and draws it on the progress line at most every :attr:`STREAM_MS`
milliseconds; a bar's finished state goes into the scrollback once. The
console listens to every worker from the moment it is built
(:func:`spacr._segmentation_backends._listen_to_workers`).
:ivar console: the scrollback.
:ivar progress: the in-place progress line; hidden while nothing runs.
:ivar stream_updates: how many times streamed output redrew the
progress line.
"""
_relay = Signal(str, str)
_stream_kick = Signal()
PERCENT = re.compile(r"(\d{1,3}(?:\.\d+)?)\s?%")
STREAM_MS = 100
def __init__(self, parent=None):
"""Build the scrollback and the hidden progress line."""
super().__init__(parent)
from ..widgets.console_panel import ConsolePanel
from ..widgets.eliding import ProgressLine
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(SPACING["xs"])
self.console = ConsolePanel(active_app_label="",
follow_log=False, chat=False)
layout.addWidget(self.console, 1)
self.progress = ProgressLine(self, detail=True, count_below=True)
self.progress.setVisible(False)
layout.addWidget(self.progress)
self._last = None
self._relay.connect(self.post)
self.stream_updates = 0
self._stream_lock = threading.Lock()
self._stream_pending = None
self._stream_finals = []
self._stream_bars = {}
self._stream_armed = False
self._stream_timer = QTimer(self)
self._stream_timer.setSingleShot(True)
self._stream_timer.setInterval(self.STREAM_MS)
self._stream_timer.timeout.connect(self._flush_stream)
self._stream_kick.connect(self._arm_stream)
from ... import _segmentation_backends as backends
stop = backends._listen_to_workers(self._worker_said)
self.destroyed.connect(lambda *_args: stop())
def post(self, text: str, kind: str = "info") -> None:
"""Write one line.
:param text: what to say; blank is ignored.
:param kind: ``progress`` rewrites the progress line; ``stream`` is
one line of a command's running output and rewrites it at most
every :attr:`STREAM_MS` ms (see :meth:`stream`); ``info``,
``warning`` and ``error`` go into the scrollback, in the
console's colours for each, and end any progress shown.
"""
if kind == "stream":
self.stream(str(text or ""))
return
if QThread.currentThread() is not self.thread():
self._relay.emit(str(text or ""), str(kind or "info"))
return
text = str(text or "").rstrip()
if not text.strip():
return
if kind == "progress":
self.show_progress(text)
return
self._settle_stream()
self._write(text, kind)
def _write(self, text: str, kind: str) -> None:
"""Put one line into the scrollback and hide the progress line."""
self.progress.setVisible(False)
if (text, kind) == self._last:
return
self._last = (text, kind)
if kind == "error":
self.console.append_error(text)
elif kind == "warning":
self.console.append_warning(text)
else:
self.console.append_stdout(text + "\n")
def stream(self, line: str, source: str = "") -> None:
"""Take one line of running output; safe from any thread, never waits.
A line ending in a bare ``\\r`` is a progress bar's redraw, and a line
with no ending (an install's output, already split) is one state of
the task: either becomes the newest state of the progress line,
replacing any not yet drawn. A line ending in a newline right after a
redraw is the bar's finished state and goes into the scrollback
once. Any other ended line is the worker's own chatter, kept in the
log and not shown.
:param line: the raw line, with its ending if it had one.
:param source: whose output it is, e.g. ``Cellpose 3``; it prefixes
the line, and each source's bar is followed on its own.
"""
line = str(line or "")
ended = line.endswith("\n")
redraw = line.endswith("\r") and not ended
parts = [part for part in line.rstrip("\r\n").split("\r")
if part.strip()]
text = parts[-1].strip() if parts else ""
shown = "{}: {}".format(source, text) if source and text else text
with self._stream_lock:
in_bar = self._stream_bars.get(source, False)
if redraw or not ended:
if shown:
self._stream_pending = shown
self._stream_bars[source] = redraw or in_bar
else:
self._stream_bars[source] = False
if not (in_bar or len(parts) > 1) or not shown:
return
self._stream_pending = None
self._stream_finals.append(shown)
kick = not self._stream_armed
self._stream_armed = True
if kick:
try:
self._stream_kick.emit()
except RuntimeError:
pass
def _worker_said(self, label: str, line: str) -> None:
"""A backend worker printed ``line``; stream it under its name."""
self.stream(line, source=label)
def _arm_stream(self) -> None:
"""Draw the streamed output once the throttle interval is up."""
if not self._stream_timer.isActive():
self._stream_timer.start()
def _take_stream(self):
"""The newest undrawn state and the finished lines, emptied."""
with self._stream_lock:
pending, finals = self._stream_pending, self._stream_finals
self._stream_pending, self._stream_finals = None, []
self._stream_armed = False
return pending, finals
def _flush_stream(self) -> None:
"""Draw what streamed since the last draw: finished lines into the
scrollback, then the newest state on the progress line."""
pending, finals = self._take_stream()
for text in finals:
self._write(text, "info")
if pending:
self.show_progress(pending)
self.stream_updates += 1
def _settle_stream(self) -> None:
"""A line for the scrollback is coming: write the finished bars
before it and drop the state not yet drawn, which it supersedes."""
self._stream_timer.stop()
_pending, finals = self._take_stream()
for text in finals:
self._write(text, "info")
def show_progress(self, text: str) -> None:
"""Rewrite the one progress line with ``text``.
A percentage in the text moves the bar; without one the bar is busy.
"""
found = self.PERCENT.findall(text)
if found:
self.progress.setRange(0, 100)
self.progress.setValue(int(min(100.0, float(found[-1]))))
else:
self.progress.setRange(0, 0)
self.progress.set_detail(text)
self.progress.setToolTip(text)
self.progress.setVisible(True)
def progress_text(self) -> str:
"""What the progress line says, or ``''`` while it is hidden."""
if not self.progress.isVisibleTo(self):
return ""
return self.progress.toolTip()
def text(self) -> str:
"""The scrollback as plain text."""
return self.console.as_text()
class _MethodGroup(QWidget):
"""One family of detection settings inside the Object detection category.
The category holds four of these -- the threshold family's, Cellpose's,
the organelle methods' and the propagation's -- and shows the one the
chosen mode reads. A GROUP AND NOT A ROW RULE, because these families
are not all forms: the threshold group has toggles, a histogram button
and two nested form layouts, and hiding those one at a time would be a
list of widget names that goes stale the day one is added.
It carries ``body_layout`` so the builders that used to fill a
:class:`~spacr.qt.widgets.section.Section` fill one of these instead,
unchanged.
:param parent: parent widget; ownership only.
"""
def __init__(self, parent=None):
"""Build an empty group with a vertical body layout."""
super().__init__(parent)
self.body_layout = QVBoxLayout(self)
self.body_layout.setContentsMargins(0, 0, 0, 0)
self.body_layout.setSpacing(SPACING["sm"])
class _EnhanceRequest(NamedTuple):
"""One whole-field enhanced picture to build, off the GUI thread.
``key`` is what :class:`_NewestRequestWorker` matches two requests by:
the base array and the chain, which together are the whole of what the
answer depends on.
"""
key: tuple
image: np.ndarray
chain: Any
cancelled: Any = None
class _CompareRequest(NamedTuple):
"""An immutable comparison snapshot with cooperative result cancellation."""
key: tuple
image: np.ndarray
box: tuple
chain: Any
normalized: bool
percentiles: tuple
cancelled: Any
def _compare_picture_for(request):
"""Prepare the detector's input on a worker, stretching before cropping."""
if request.cancelled.is_set():
return None
base = request.image
if request.normalized:
base = engine.normalize_for_detection(base, *request.percentiles)
if request.cancelled.is_set():
return None
x0, y0, x1, y1 = request.box
result = detect_chain.prepare(base[y0:y1, x0:x1], request.chain,
cancel=request.cancelled)
return None if request.cancelled.is_set() else result
class _EnhancedImage(NamedTuple):
"""Scientific intensities and their separately scaled display picture."""
prepared: np.ndarray
picture: np.ndarray
def _enhanced_picture_for(request: _EnhanceRequest) -> Optional[_EnhancedImage]:
"""The enhanced field as a drawable picture. ON THE WORKER THREAD.
The chain in float (:func:`spacr.qt.detect_chain.prepare`), then back
onto the loaded field's own unsigned range so
:func:`_box_grey_table` can index it and :func:`refresh` can stretch
it. A float field has no integer range to return to and is handed back
as it is.
:param request: the field and the chain.
:returns: the picture, or None when there is nothing to draw.
"""
base = request.image
if base is None:
return None
out = detect_chain.prepare(base, request.chain, cancel=request.cancelled)
dtype = np.dtype(base.dtype)
if out is base or dtype.kind != "u":
return _EnhancedImage(out, out)
low = float(np.min(out)) if out.size else 0.0
span = (float(np.max(out)) - low) if out.size else 1.0
top = float(np.iinfo(dtype).max)
scaled = (np.asarray(out, dtype=np.float64) - low) / (span or 1.0) * top
return _EnhancedImage(out, np.clip(scaled, 0, top).astype(dtype))
class _MaskCanvas(QLabel):
"""QLabel that displays the composited image+mask (optionally zoomed
into a sub-region) and captures mouse events for brush / erase /
magic-wand / erase-object / draw / divide / zoom-rectangle / recrop
interactions.
All coordinate math is done against the *full* image; the "zoom
view" is just a crop of the composited pixmap. Mask edits go
directly into `self.mask` (with the correct zoom offset applied).
Recrop is the one gesture here that changes nothing the canvas owns:
it is a rectangle dragged the way a zoom rectangle is, handed on
through :attr:`recrop_requested` in full-image pixels for the screen
to accept or refuse. What comes back is a mark in :attr:`recrop_boxes`
saying that region has been cut out, which is the only thing on screen
that distinguishes a box that was written from one that was not.
:param parent: parent widget; ownership only.
"""
stroke_started = Signal()
stroke_finished = Signal()
zoom_changed = Signal(bool)
#: Something the user did needs a sentence on the status line. Emitted
#: by the Ctrl+click edits, which are the canvas's
#: only gestures that can decline to do anything for a reason worth
#: telling: a click on background, and an object with no waist to cut.
#: A gesture that did nothing and said nothing reads as a broken
#: shortcut.
status = Signal(str)
#: A recrop box was dragged, in FULL-image pixels: (x0, y0, x1, y1).
#: The canvas neither writes it nor judges it — the box may be too
#: small, or a re-draw of one already cut — because what it becomes is
#: two files and a queue position, and none of that is a canvas's job.
recrop_requested = Signal(int, int, int, int)
#: A whole-field enhanced picture finished on the worker thread, as
#: ``(the base array it was made from, the chain, the picture)``.
#: Connected to :meth:`_take_enhanced` in ``__init__``, which is what
#: carries it from the worker thread to this one.
enhanced_ready = Signal(object)
boxes_changed = Signal()
box_selected = Signal(int)
def __init__(self, parent: Optional[QWidget] = None):
"""Build an empty canvas: no image, no mask, no stroke in progress."""
super().__init__(parent)
from ..widgets.image_ruler import ImageRuler
self.ruler = ImageRuler(self)
self.ruler.changed.connect(self.update)
#: What the corner readout says about the pixel under the mouse, or
#: None while the mouse is off the image.
self.readout: Optional[engine.PixelReadout] = None
self._lookup: Optional[engine.ObjectLookup] = None
self._lookup_mask: Optional[np.ndarray] = None
self._lookup_image: Optional[np.ndarray] = None
self._lookup_dirty = True
self._readout_pos: Optional[QPointF] = None
self._readout_queued = False
self.image: Optional[np.ndarray] = None
self.mask: Optional[np.ndarray] = None
self.mode: str = MODE_NONE
self.boxes: List[tuple] = []
self.box_class_id = 0
self.selected_box = None
self.box_classes = ['object']
self.boxes_editable = True
self._box_drag = None
self._box_preview = None
self.brush_radius: int = 10
self.norm_lo: float = 1.0
self.norm_hi: float = 99.9
#: Draw the photographic complement of the image. A VIEW
#: setting beside the two percentiles, not an edit: :attr:`image`
#: keeps the pixels that were read off disk, so the readout, the
#: filter, every detector and the save all see the original numbers
#: whether this is on or off.
self.invert_display: bool = False
self._inverted: Optional[np.ndarray] = None
self._inverted_of: Optional[np.ndarray] = None
#: Give the detectors the field as it is DRAWN -- stretched between
#: the two display percentiles -- rather than as it was loaded.
#: Off by default, which keeps the stretch a view setting.
self.detect_on_normalized: bool = False
self._detection_cache: Optional[tuple] = None
#: The pre-detection chain the detectors read the field through
#: (:mod:`spacr.qt.detect_chain`), and whether the canvas DRAWS
#: what that chain produced instead of the field as loaded. Both
#: are view-and-detection settings in the same sense the two
#: percentiles are: :attr:`image` keeps the numbers off disk.
self.enhance_chain = detect_chain.NO_CHAIN
self.enhance_display: bool = False
self._enhanced_cache: Optional[tuple] = None
self._enhanced_picture: Optional[tuple] = None
self._enhance_failure = None
#: The whole-field enhanced picture is built OFF THIS THREAD; see
#: :meth:`enhanced_picture`. ``_enhance_asked`` is the
#: ``(base, chain)`` a request is already out for, so a repaint
#: while one is running does not ask again.
self._enhance_worker = None
self._enhance_cancel = threading.Event()
self._enhance_pending = None
self._enhance_started = None
self._enhance_timer = QTimer(self)
self._enhance_timer.setSingleShot(True)
self._enhance_timer.setInterval(RESTORATION_SETTLE_MS)
self._enhance_timer.timeout.connect(self._submit_pending_enhance)
self._enhance_asked: Optional[tuple] = None
self.enhanced_ready.connect(self._take_enhanced)
self.wand_tolerance: float = 1000.0
self.wand_relative: bool = True
self.wand_tol_pct: float = 5.0
self.wand_max_pixels: int = 100_000
#: The wand's rescues — see :mod:`spacr.qt.wand_rescue` for what
#: each one catches. They start at the values that tool shipped,
#: which are tuned to be inert on a flood that did not run away.
self.wand_trim_runaway: bool = True
self.wand_runaway_ratio: float = 2.0
self.wand_runaway_warmup: int = 12
self.wand_runaway_min_base: int = 8
self.wand_runaway_confirm: int = 2
self.wand_intensity_border: bool = True
self.wand_intensity_steps: int = 8
self.wand_gradient_taper: bool = True
self.wand_gradient_sigma: float = 2.0
self.wand_gradient_margin: int = 8
self.wand_gradient_erode: int = 3
self.wand_salvage_over_cap: bool = True
#: The smallest object the screen is willing to keep, in pixels, as
#: the Min area box holds it. Ctrl + left click reads it for its
#: seed spacing (:func:`mask_engine.split_object_at`), so the size
#: that decides what is debris also decides what is too small to be
#: two things. Set by the screen; 0 leaves the engine's own floor.
self.split_min_area: int = 0
#: The BUTTON now down that was pressed with Ctrl, or None. It makes
#: that press one of the Ctrl+click edits and NOT the start of a drag:
#: without it the release reaches the magnifier, whose own release
#: with no stroke open IS a click -- so a Ctrl+click that split one
#: object would commit every object in the box on the way back up.
#: It is the button and not a flag so that a SECOND button pressed
#: while the edit is open can have its own release consumed without
#: ending the edit early.
self._ctrl_click = None
#: Buttons whose PRESS this canvas declined to act on, and whose
#: release must therefore be declined too. A release that falls
#: through reaches the generic stroke end at the foot of
#: :meth:`_MaskCanvas.mouseReleaseEvent`, which would close whatever
#: OTHER gesture is open and label it a paint.
self._swallowed: set = set()
self.zoom_speed: float = 1.15
follow_device_ratio(self, self.refresh)
#: What the stroke that just finished did — ``{"kind", "target",
#: "detail"}`` — for the screen to put in the curation ledger. Set
#: by :meth:`_emit_stroke_end` immediately before ``stroke_finished``
#: so a handler reads the edit it was told about, not the one before.
self.last_edit: Optional[dict] = None
self._zoom_x0: Optional[int] = None
self._zoom_y0: Optional[int] = None
self._zoom_x1: Optional[int] = None
self._zoom_y1: Optional[int] = None
self._zoom_drag_start: Optional[QPoint] = None
self._zoom_drag_end: Optional[QPoint] = None
#: Boxes already cut out of THIS field, as
#: ``(x0, y0, x1, y1, name)`` in image pixels. Drawn on the canvas
#: and kept there: without them a box that was written and a box
#: that was refused look identical the moment the mouse comes up,
#: which is how one object reached disk as three crops.
self.recrop_boxes: List[tuple] = []
self._gesture_points: List[QPoint] = []
self._gesture_button = None
self.manual_ids = False
self._brush_value = 255
self.setAlignment(Qt.AlignCenter)
self.setStyleSheet(f"background: {active_palette()['bg']};")
self.setMouseTracking(True)
self.setMinimumSize(600, 400)
self._last_pt: Optional[QPoint] = None
self._stroke_in_progress = False
self._sweeping = False
self._sweep_labels: List[int] = []
self._pan_from: Optional[QPoint] = None
#: The live magnifier the screen gives this canvas, or None. While it
#: is on, a left click commits the objects its box outlines and the
#: wheel zooms the box instead of the view; the right-button sweep
#: and Shift/Alt pan work as they do from any tool.
self.magnifier: Optional["_LiveMagnifier"] = None
#: Prompt-based segmentation the screen gives this canvas, or None.
#: While it is on, a left click, a right click and a left drag are
#: prompts (:class:`_PromptSession`); Shift/Alt pan and the Ctrl
#: edits work as they do from any tool.
self.prompter: Optional["_PromptSession"] = None
def set_image_and_mask(self, image: np.ndarray, mask: np.ndarray) -> None:
"""Load a new image + mask pair and rerender at full-image zoom.
:param image: uint16 grayscale array to display underneath.
:param mask: uint8/uint16 label array painted on top.
"""
self.image = image
self.mask = mask
self.boxes = []
self.selected_box = None
self._box_drag = self._box_preview = None
self.ruler.clear()
self.ruler.set_spacing()
self._gesture_points = []
self._gesture_button = None
self.manual_ids = False
self.recrop_boxes = []
self._inverted = self._inverted_of = None
self._detection_cache = None
self._enhanced_cache = self._enhanced_picture = None
self._enhance_failure = None
self._enhance_asked = None
self._enhance_cancel.set()
self._lookup = self._lookup_mask = self._lookup_image = None
self.readout = None
if self.magnifier is not None:
self.magnifier.forget()
if self.prompter is not None:
self.prompter.forget()
self.reset_zoom(silent=True)
self.refresh()
def _viewport_bounds(self):
"""Return (x0, y0, x1, y1) — inclusive-of-x0, exclusive-of-x1."""
if self.mask is None:
return (0, 0, 0, 0)
if self._zoom_x0 is not None:
return (self._zoom_x0, self._zoom_y0, self._zoom_x1, self._zoom_y1)
h, w = self.mask.shape[:2]
return (0, 0, w, h)
def is_zoomed(self) -> bool:
"""Return True when the canvas is viewing a zoomed sub-region."""
return self._zoom_x0 is not None
def reset_zoom(self, silent: bool = False) -> None:
"""Clear the zoom viewport and rerender the full image.
:param silent: suppress the ``zoom_changed`` signal when True
(used on image load so no callback fires spuriously).
"""
was_zoomed = self.is_zoomed()
self._zoom_x0 = self._zoom_y0 = self._zoom_x1 = self._zoom_y1 = None
self._zoom_drag_start = self._zoom_drag_end = None
if was_zoomed and not silent:
self.zoom_changed.emit(False)
self.refresh()
def displayed_source(self) -> Optional[np.ndarray]:
"""The intensities this canvas DRAWS, before the contrast stretch.
:attr:`image` unless :attr:`invert_display` is on, in which case the
photographic complement of it (:func:`spacr.qt.mask_engine.
invert_intensity`). The complement is computed here and
nowhere else, so everything that measures, detects, filters or saves
goes on reading :attr:`image` and cannot silently be handed inverted
pixels. A curator can leave Invert on all day and the numbers on
disk are the numbers the microscope wrote.
The complement is kept until the image itself changes, because a
refresh runs on every stroke point and re-subtracting a megapixel
field per point would be felt on the brush.
:returns: the array to stretch and draw, or None with no image.
"""
if self.image is None:
return None
if not self.invert_display:
return self.image
if self._inverted is None or self._inverted_of is not self.image:
self._inverted = engine.invert_normalized(self.image)
self._inverted_of = self.image
return self._inverted
def detection_base(self) -> Optional[np.ndarray]:
"""The field the enhancement chain starts from.
:meth:`displayed_source` -- the loaded field, or its inversion with
Invert on -- and, with :attr:`detect_on_normalized` on, that array
stretched between :attr:`norm_lo` and :attr:`norm_hi` exactly as
:meth:`refresh` stretches it for drawing, so Otsu, Cellpose and the
magnifier segment the picture the curator is looking at.
THE STRETCH IS THE CHAIN'S FIRST STAGE and it is here rather than
in :mod:`spacr.qt.detect_chain` because its two levels are
percentiles of the WHOLE FIELD: taken inside the magnifier's box
they would be a different stretch in every box. Everything after it
is applied to whatever region is being detected, which is the box
for the magnifier and the field for the detect buttons.
Cached against the array and the two percentiles: the magnifier asks
on every mouse move, and a percentile pass over a megapixel field
per move would be felt.
:returns: the array, or None with no image.
"""
base = self.displayed_source()
if base is None or not self.detect_on_normalized:
return base
key = (base, float(self.norm_lo), float(self.norm_hi))
cached = self._detection_cache
if cached is not None and cached[0] is base and cached[1:3] == key[1:]:
return cached[3]
out = engine.normalize_for_detection(base, self.norm_lo, self.norm_hi)
self._detection_cache = (base, key[1], key[2], out)
return out
def detection_source(self) -> Optional[np.ndarray]:
"""The WHOLE FIELD as a detector on it reads it: base plus the chain.
What the detect buttons segment and what the active Apply button
draws. The magnifier does NOT come through here: its box is
enhanced on the worker thread, region by region, so a heavy step is
never a frozen window and item 407's progress and Cancel have
something to cover.
Cached against the base array and the chain, because drawing the
enhanced field asks on every repaint.
:returns: the array, or None with no image.
"""
base = self.detection_base()
if base is None:
return None
chain = self.enhance_chain or detect_chain.NO_CHAIN
cached = self._enhanced_cache
if cached is not None and cached[0] is base and cached[1] == chain:
return cached[2]
if chain.psf_operation != 'none' or chain.restoration:
from ..i18n import tr
failure = self._enhance_failure
if failure is not None and failure[0] is base and failure[1] == chain:
raise ValueError(failure[2])
self._ask_for_enhanced(base, chain)
raise ValueError(tr('Image enhancement is updating. Wait for it to finish before detecting objects.'))
out = detect_chain.prepare(base, chain)
self._enhanced_cache = (base, chain, out)
return out
def enhanced_picture(self) -> Optional[np.ndarray]:
"""The enhanced field as a PICTURE, and never at the cost of a frame.
The chain works in float and the box's picture is a look-up table
indexed by an unsigned integer value (:func:`_box_grey_table`), so
an enhanced field is put back on the loaded field's own integer
range before anything draws it. The numbers a detector read are the
float ones; this is the picture of them.
IT IS BUILT ON A WORKER THREAD AND THIS METHOD NEVER WAITS. A chain
over a whole field is filters over four megapixels -- a rolling ball
at the default is about a second, and a curator is free to ask for
the exact one, which is fifteen. This is called from
:meth:`refresh`, which runs on every edit, every stroke point and
every zoom, so a second spent here is a second the window does not
answer in. The first call for a new field or a new chain starts the
work and hands back the field as loaded; when the picture arrives
(:attr:`enhanced_ready`) the canvas refreshes and shows it.
:returns: the enhanced picture, the field as loaded while one is
being built, or None with no image.
"""
base = self.detection_base()
if base is None:
return None
chain = self.enhance_chain or detect_chain.NO_CHAIN
if not detect_chain.pre_active(chain):
return base
cached = self._enhanced_picture
if cached is not None and cached[0] is base and cached[1] == chain:
return cached[2]
failure = self._enhance_failure
if failure is not None and failure[0] is base and failure[1] == chain:
return base
self._ask_for_enhanced(base, chain)
return base
def _ask_for_enhanced(self, base: np.ndarray, chain) -> None:
"""Start the enhanced picture for ``(base, chain)`` on the worker.
One request at a time and the newest wins
(:class:`_NewestRequestWorker`): dragging the background radius
makes a request per step, and every one but the last is about a
picture nobody will see.
A CHAIN WITH CELLPOSE 3 RESTORATION WAITS TO SETTLE (item 507). One
restoration of a whole field is tens of seconds on a CPU, and a
cancelled one still finishes in the worker before the next can
start, so a request is sent only after the chain has stood still
for :data:`RESTORATION_SETTLE_MS`; each change in between replaces
the waiting request rather than sending it.
"""
asked = self._enhance_asked
if asked is not None and asked[0] is base and asked[1] == chain:
return
self._enhance_asked = (base, chain)
self._enhance_failure = None
self._enhance_cancel.set()
self._enhance_cancel = threading.Event()
if self._enhance_worker is None:
self._enhance_worker = _NewestRequestWorker(
_enhanced_picture_for, self._enhanced_done,
name="spacr-enhance")
request = _EnhanceRequest(
key=(id(base), chain, id(self._enhance_cancel)),
image=base, chain=chain, cancelled=self._enhance_cancel)
if getattr(chain, "restoration", False):
self._enhance_pending = request
self._enhance_timer.start()
return
self._enhance_pending = None
self._enhance_timer.stop()
self._enhance_worker.submit(request)
def _submit_pending_enhance(self) -> None:
"""Send the request that waited for the chain to settle, if still wanted."""
request, self._enhance_pending = self._enhance_pending, None
worker = self._enhance_worker
if (request is None or worker is None
or request.cancelled is None or request.cancelled.is_set()):
return
from ..i18n import tr
self._enhance_started = (request.image, request.chain, time.monotonic())
self.status.emit(tr("Cellpose 3 restoration is running on the whole field…"))
worker.submit(request)
def _enhanced_done(self, request, result, error) -> None:
"""Deliver the finished picture or exception to Qt from the worker."""
if request.cancelled is not None and request.cancelled.is_set():
return
if error is not None:
LOG.warning("the enhanced picture could not be built",
exc_info=error)
if error is None and result is None:
return
try:
self.enhanced_ready.emit((request.image, request.chain,
error if error is not None else result))
except RuntimeError:
pass
def _take_enhanced(self, payload) -> None:
"""Keep a finished enhanced picture and draw it, on the GUI thread."""
from ..i18n import tr
base, chain, picture = payload
asked = self._enhance_asked
if asked is not None and asked[0] is base and asked[1] == chain:
self._enhance_asked = None
if self.detection_base() is not base or self.enhance_chain != chain:
return
if isinstance(picture, Exception):
self._enhance_failure = (base, chain, str(picture))
self._enhance_started = None
self.status.emit(tr('Image enhancement failed: {error}', error=str(picture)))
return
self._enhance_failure = None
started = self._enhance_started
if started is not None and started[0] is base and started[1] == chain:
self._enhance_started = None
self.status.emit(tr("Cellpose 3 restoration finished in {seconds} s.",
seconds=round(time.monotonic() - started[2], 1)))
if isinstance(picture, _EnhancedImage):
self._enhanced_cache = (base, chain, picture.prepared)
picture = picture.picture
self._enhanced_picture = (base, chain, picture)
if self.enhance_display:
self.refresh()
def close_enhancer(self) -> bool:
"""Stop the enhanced-picture worker; True when none is left running."""
self._enhance_cancel.set()
self._enhance_timer.stop()
self._enhance_pending = None
worker = self._enhance_worker
self._enhance_worker = None
self._enhance_asked = None
return True if worker is None else worker.close()
def refresh(self) -> None:
"""Recompose image + mask overlay and repaint the canvas pixmap.
Every edit ends in a refresh, so the corner readout is re-read after
one: an object just erased must stop being reported under the mouse.
"""
self._lookup_dirty = True
self._schedule_readout()
if self.image is None or self.mask is None:
return
source = (self.enhanced_picture() if self.enhance_display
else self.displayed_source())
img = engine.normalize_uint16(source, self.norm_lo, self.norm_hi)
x0, y0, x1, y1 = self._viewport_bounds()
sub_img = img[y0:y1, x0:x1]
sub_mask = self.mask[y0:y1, x0:x1]
composed = engine.overlay_mask(sub_img, sub_mask, alpha=0.5)
h, w = composed.shape[:2]
if w <= 0 or h <= 0:
return
qimg = QImage(composed.tobytes(), w, h, 3 * w, QImage.Format_RGB888).copy()
pixmap = QPixmap.fromImage(qimg)
avail_w = max(200, self.width())
avail_h = max(200, self.height())
pixmap = scaled_for(pixmap, self, avail_w, avail_h)
self.setPixmap(pixmap)
def _canvas_to_image(self, x: float, y: float) -> Optional[tuple]:
"""Widget coordinates to IMAGE pixel coordinates, or ``None``.
``None`` means the point is outside the drawn pixmap -- in the letterbox
either side of it, or before an image is set -- and a caller must not
treat that as pixel 0, which is what an unchecked conversion gives.
Accounts for the centring offset and for the zoom viewport, then clamps:
a click on the last row must land on the last row rather than one past it,
which is a rounding error away.
"""
p = self.pixmap()
if self.mask is None or p is None or p.isNull():
return None
shown = logical_size(p)
pw, ph = shown.width(), shown.height()
w, h = self.width(), self.height()
ox = (w - pw) // 2
oy = (h - ph) // 2
cx, cy = float(x) - ox, float(y) - oy
if not (0 <= cx < pw and 0 <= cy < ph):
return None
x0, y0, x1, y1 = self._viewport_bounds()
sub_w = max(1, x1 - x0)
sub_h = max(1, y1 - y0)
img_x = int(x0 + cx * sub_w / pw)
img_y = int(y0 + cy * sub_h / ph)
img_x = max(0, min(self.mask.shape[1] - 1, img_x))
img_y = max(0, min(self.mask.shape[0] - 1, img_y))
return img_x, img_y
def _image_to_canvas(self, img_x: float, img_y: float) -> Optional[QPoint]:
"""Where an image pixel lands on the widget, or ``None``.
The inverse of :meth:`_canvas_to_image`, and the reason a recrop box
stays on the object it was drawn round while the view is zoomed and
panned: the boxes are kept in image pixels and mapped here on every
repaint, rather than being remembered as the widget coordinates the
mouse happened to be at.
"""
p = self.pixmap()
if self.mask is None or p is None or p.isNull():
return None
shown = logical_size(p)
pw, ph = shown.width(), shown.height()
ox = (self.width() - pw) // 2
oy = (self.height() - ph) // 2
x0, y0, x1, y1 = self._viewport_bounds()
sub_w = max(1, x1 - x0)
sub_h = max(1, y1 - y0)
return QPoint(int(round(ox + (float(img_x) - x0) * pw / sub_w)),
int(round(oy + (float(img_y) - y0) * ph / sub_h)))
def _image_delta(self, dx_px: float, dy_px: float) -> tuple:
"""Widget-pixel drag -> the image-pixel shift the viewport must take.
Negated, because a pan moves the *view*, not the picture: dragging
the content to the right has to slide the window left over the
image for the pixel under the cursor to stay under the cursor.
"""
p = self.pixmap()
shown = logical_size(p)
if self.mask is None or not shown.width():
return (0, 0)
x0, y0, x1, y1 = self._viewport_bounds()
return (int(round(-dx_px * (x1 - x0) / shown.width())),
int(round(-dy_px * (y1 - y0) / shown.height())))
def pan_by(self, dx: int, dy: int) -> bool:
"""Slide the zoom viewport by (dx, dy) image px; True if it moved.
Clamped to the image, so a pan cannot walk the view off the edge and
leave the user looking at nothing with no way back but Reset zoom.
Panning an unzoomed canvas does nothing and says so by returning
False: the whole image is already on screen, there is nowhere to go.
"""
if self.mask is None or not self.is_zoomed():
return False
h, w = self.mask.shape[:2]
view_w = self._zoom_x1 - self._zoom_x0
view_h = self._zoom_y1 - self._zoom_y0
new_x0 = max(0, min(w - view_w, self._zoom_x0 + int(dx)))
new_y0 = max(0, min(h - view_h, self._zoom_y0 + int(dy)))
if new_x0 == self._zoom_x0 and new_y0 == self._zoom_y0:
return False
self._zoom_x0, self._zoom_x1 = new_x0, new_x0 + view_w
self._zoom_y0, self._zoom_y1 = new_y0, new_y0 + view_h
self.refresh()
return True
def zoom_at(self, img_x: int, img_y: int, factor: float) -> None:
"""Scale the viewport by ``factor`` about the image point given.
``factor > 1`` magnifies. The point under the cursor keeps its place
in the view, which is what makes wheel-zoom feel like it is aimed at
something rather than at the middle of the window. Zooming back out
past the whole image resets to the full-image view instead of
letting the viewport grow beyond the data.
"""
if self.mask is None or factor <= 0:
return
h, w = self.mask.shape[:2]
x0, y0, x1, y1 = self._viewport_bounds()
view_w, view_h = max(1, x1 - x0), max(1, y1 - y0)
new_w = min(w, max(min(MIN_VIEWPORT, w), int(round(view_w / factor))))
new_h = min(h, max(min(MIN_VIEWPORT, h), int(round(view_h / factor))))
if new_w >= w and new_h >= h:
self.reset_zoom()
return
frac_x = (img_x - x0) / view_w
frac_y = (img_y - y0) / view_h
new_x0 = max(0, min(w - new_w, int(round(img_x - frac_x * new_w))))
new_y0 = max(0, min(h - new_h, int(round(img_y - frac_y * new_h))))
was_zoomed = self.is_zoomed()
self._zoom_x0, self._zoom_x1 = new_x0, new_x0 + new_w
self._zoom_y0, self._zoom_y1 = new_y0, new_y0 + new_h
if not was_zoomed:
self.zoom_changed.emit(True)
self.refresh()
def wheelEvent(self, event):
"""Zoom about the cursor, ``zoom_speed`` per notch, from any tool.
The speed is adjustable because one step size does not suit both
jobs: finding a cell in a 4k field wants big jumps, trimming its
boundary wants a step small enough that the next notch does not
overshoot the object.
"""
if self.mask is None:
return super().wheelEvent(event)
notches = event.angleDelta().y()
if (self.magnifier is not None and self.magnifier.enabled
and event.modifiers() & Qt.ShiftModifier):
notches = notches or event.angleDelta().x()
if notches:
self.magnifier.wheel_size(notches > 0)
self.update()
event.accept()
return
if not notches:
return super().wheelEvent(event)
if self.magnifier is not None and self.magnifier.enabled:
self.magnifier.wheel(notches > 0)
self.update()
event.accept()
return
speed = max(1.001, float(self.zoom_speed))
factor = speed if notches > 0 else 1.0 / speed
anchor = self._canvas_to_image(event.position().x(),
event.position().y())
if anchor is None:
x0, y0, x1, y1 = self._viewport_bounds()
anchor = ((x0 + x1) // 2, (y0 + y1) // 2)
self.zoom_at(anchor[0], anchor[1], factor)
event.accept()
def effective_wand_tolerance(self, source=None) -> float:
"""The tolerance the wand will actually flood with, right now.
Relative by default: a percentage of this image's own intensity
range, so the same setting behaves the same on 8-bit and 16-bit
data. Switching ``wand_relative`` off restores a plain absolute
value for the case where somebody knows the exact grey-level
distance they want.
:param source: optional applied picture; defaults to loaded pixels.
"""
source = self.image if source is None else source
if self.wand_relative and source is not None:
return engine.relative_tolerance(source, self.wand_tol_pct)
return float(self.wand_tolerance)
def wand_source(self):
"""Return original pixels with Apply off, or the ready applied picture.
Classical filters use the display-scaled picture. PSF and deep
restoration use the detector's float output, including normalized
model units for restoration; absolute tolerances refer to that output.
Relative tolerances
follow its new intensity range. While enhancement is pending or
failed, return None and explain why; no raw-pixel flood substitutes
for an applied enhancement. Post-detection morphology and splitting
remain detector operations, not manual Wand edits.
"""
from ..i18n import tr
if not self.enhance_display:
return self.image
base = self.detection_base()
chain = self.enhance_chain or detect_chain.NO_CHAIN
if base is None:
return None
if not detect_chain.pre_active(chain):
return base
cached = self._enhanced_picture
if cached is not None and cached[0] is base and cached[1] == chain:
if (chain.psf_operation != 'none' or chain.restoration) and self._enhanced_cache is not None:
return self._enhanced_cache[2]
return cached[2]
failure = self._enhance_failure
if failure is not None and failure[0] is base and failure[1] == chain:
self.status.emit(tr('Image enhancement failed: {error}', error=failure[2]))
return None
self._ask_for_enhanced(base, chain)
self.status.emit(tr('Image enhancement is updating. Try the Wand again when it finishes.'))
return None
def wand_rescue_settings(self) -> dict:
"""The rescue settings, keyed as :mod:`spacr.qt.wand_rescue` wants.
One place builds this dict, so a control added to the panel reaches
the flood by being read here rather than by being threaded through
the click handler as well.
"""
return {
"trim_runaway": bool(self.wand_trim_runaway),
"runaway_ratio": float(self.wand_runaway_ratio),
"runaway_warmup": int(self.wand_runaway_warmup),
"runaway_min_base": int(self.wand_runaway_min_base),
"runaway_confirm": int(self.wand_runaway_confirm),
"intensity_border": bool(self.wand_intensity_border),
"intensity_steps": int(self.wand_intensity_steps),
"gradient_taper": bool(self.wand_gradient_taper),
"gradient_sigma": float(self.wand_gradient_sigma),
"gradient_margin": int(self.wand_gradient_margin),
"gradient_erode": int(self.wand_gradient_erode),
"salvage_over_cap": bool(self.wand_salvage_over_cap),
}
def _mask_radius_for_brush(self) -> int:
"""Scale the brush radius (in screen px) to full-image px, taking
the current zoom into account."""
p = self.pixmap()
shown = logical_size(p)
if self.mask is None or not shown.width():
return self.brush_radius
x0, _, x1, _ = self._viewport_bounds()
sub_w = max(1, x1 - x0)
return max(1, int(self.brush_radius * sub_w / shown.width()))
def paintEvent(self, event):
"""Draw the base pixmap plus whichever gesture is in flight.
A draw or divide only reaches the mask on release, so until then the
outline being traced and the cut being aimed exist nowhere but here:
without the preview the user is dragging an invisible line.
"""
super().paintEvent(event)
self._paint_boxes()
self._paint_recrop_boxes()
self._paint_magnifier()
self._paint_prompter()
self._paint_drag()
self._paint_readout()
if self.ruler.start is not None:
painter = QPainter(self)
self.ruler.paint(painter, lambda x, y: self._image_to_canvas(x + 0.5, y + 0.5))
painter.end()
def _paint_boxes(self) -> None:
"""Overlay class-labelled boxes in whole-image coordinates."""
if self.image is None or not self.pixmap():
return
painter = QPainter(self)
shown = logical_size(self.pixmap())
painter.setClipRect(QRectF((self.width() - shown.width()) // 2,
(self.height() - shown.height()) // 2,
shown.width(), shown.height()))
palette = active_palette()
for index, box in enumerate(self.boxes + ([self._box_preview]
if self._box_preview else [])):
class_id, x0, y0, x1, y1 = box
start = self._image_to_canvas(x0, y0)
end = self._image_to_canvas(x1, y1)
if start is None or end is None:
continue
painter.setPen(QPen(QColor(palette['accent']),
3 if index == self.selected_box else 2))
painter.drawRect(QRectF(QPointF(start), QPointF(end)).normalized())
name = (self.box_classes[int(class_id)]
if 0 <= int(class_id) < len(self.box_classes) else str(class_id))
painter.drawText(QPointF(start) + QPointF(3, 14), name)
painter.end()
def _box_hit(self, point):
"""Return the topmost annotation containing an image pixel."""
if point is None:
return None
x, y = point
return next((i for i in reversed(range(len(self.boxes)))
if self.boxes[i][1] <= x < self.boxes[i][3]
and self.boxes[i][2] <= y < self.boxes[i][4]), None)
def _box_press(self, event):
"""Select, start drawing or remove an annotation without mask edits."""
if not self.boxes_editable:
return
if event.buttons() not in (Qt.LeftButton, Qt.RightButton):
return
point = self._canvas_to_image(event.position().x(), event.position().y())
if point is not None:
point = QPoint(*point)
if event.button() == Qt.LeftButton and event.modifiers() & PAN_MODIFIERS:
self._pan_from = event.position().toPoint()
self.setCursor(Qt.ClosedHandCursor)
return
hit = self._box_hit((point.x(), point.y()) if point is not None else None)
if event.button() == Qt.RightButton:
self._box_drag = self._box_preview = None
if hit is not None:
del self.boxes[hit]
self.selected_box = None
self.boxes_changed.emit()
self.update()
return
if event.button() != Qt.LeftButton or point is None:
return
if event.modifiers() & Qt.ControlModifier:
hit = None
self.selected_box = hit
original = self.boxes[hit] if hit is not None else None
corner = None
if original is not None:
self.box_selected.emit(int(original[0]))
for cx, cy in ((original[1], original[2]),
(original[3] - 1, original[2]),
(original[1], original[4] - 1),
(original[3] - 1, original[4] - 1)):
shown = self._image_to_canvas(cx + 0.25, cy + 0.25)
if (QPointF(shown) - event.position()).manhattanLength() <= 10:
corner = (cx == original[1], cy == original[2])
break
self._box_drag = (point, hit, original, corner)
self._box_preview = None
self.update()
def _box_move(self, event):
"""Preview a draw, translation or corner resize in image pixels."""
if self._pan_from is not None:
current = event.position().toPoint()
delta = current - self._pan_from
dx, dy = self._image_delta(delta.x(), delta.y())
if (dx or dy) and self.pan_by(dx, dy):
self._pan_from = current
return
if self._box_drag is None:
return
point = self._canvas_to_image(event.position().x(), event.position().y())
if point is None:
return
point = QPoint(*point)
start, hit, original, corner = self._box_drag
dx, dy = point.x() - start.x(), point.y() - start.y()
height, width = self.image.shape[:2]
if hit is None:
if dx == 0 and dy == 0:
self._box_preview = None
else:
self._box_preview = (self.box_class_id,
min(start.x(), point.x()), min(start.y(), point.y()),
max(start.x(), point.x()) + 1, max(start.y(), point.y()) + 1)
elif corner is None:
class_id, x0, y0, x1, y1 = original
dx = max(-x0, min(dx, width - x1))
dy = max(-y0, min(dy, height - y1))
self._box_preview = (class_id, x0 + dx, y0 + dy, x1 + dx, y1 + dy)
else:
class_id, x0, y0, x1, y1 = original
if corner[0]:
x0 = min(point.x(), x1 - 1)
else:
x1 = max(point.x() + 1, x0 + 1)
if corner[1]:
y0 = min(point.y(), y1 - 1)
else:
y1 = max(point.y() + 1, y0 + 1)
self._box_preview = (class_id, x0, y0, x1, y1)
self.update()
def _box_release(self, event):
"""Commit one completed annotation gesture to the separate history."""
if event.button() != Qt.LeftButton:
return
if self._pan_from is not None:
self._pan_from = None
self.unsetCursor()
return
if self._box_drag is None:
return
if self._canvas_to_image(event.position().x(), event.position().y()) is None:
self._box_drag = self._box_preview = None
self.update()
return
self._box_move(event)
_start, hit, original, _corner = self._box_drag
preview = self._box_preview
self._box_drag = self._box_preview = None
if preview is not None and preview != original:
if hit is None:
self.boxes.append(preview)
self.selected_box = len(self.boxes) - 1
else:
self.boxes[hit] = preview
self.boxes_changed.emit()
self.update()
def cancel_gesture(self):
"""Discard uncommitted outlines when another tool is selected."""
self._box_drag = self._box_preview = None
self._gesture_points = []
self._gesture_button = None
self._zoom_drag_start = self._zoom_drag_end = None
self._last_pt = None
self._pan_from = None
if self._stroke_in_progress:
self._emit_stroke_end()
self._sweeping = False
self._ctrl_click = None
self._swallowed.clear()
self.update()
def _paint_drag(self) -> None:
"""Draw the outline, cut or rectangle being dragged, if there is one."""
if self.mode in (MODE_DRAW, MODE_DIVIDE):
self._paint_gesture()
return
if self.mode not in (MODE_ZOOM, MODE_RECROP):
return
if self._zoom_drag_start is None or self._zoom_drag_end is None:
return
painter = QPainter(self)
pen = QPen(QColor(active_palette()["accent"]))
pen.setWidth(2)
pen.setStyle(Qt.DashLine)
painter.setPen(pen)
rect = QRect(self._zoom_drag_start, self._zoom_drag_end).normalized()
painter.drawRect(rect)
painter.end()
def _object_lookup(self) -> Optional[engine.ObjectLookup]:
"""The objects of the mask on screen, rebuilt only when it changed.
A refresh marks the lookup stale, and a stale one is compared with a
copy of the mask it was built from before it is rebuilt: a zoom or a
pan refreshes without changing a label, and rebuilding for those
would cost a 2048 px field tens of milliseconds per wheel notch.
:returns: the lookup, or ``None`` with no field open.
"""
if self.mask is None or self.image is None:
return None
lookup = self._lookup
if lookup is not None and self._lookup_image is self.image and (
not self._lookup_dirty
or (self._lookup_mask.shape == self.mask.shape
and np.array_equal(self._lookup_mask, self.mask))):
self._lookup_dirty = False
return lookup
try:
lookup = engine.ObjectLookup(self.mask, self.image,
preserve_ids=self.retain_label_ids())
except Exception: # noqa: BLE001
LOG.debug("the readout could not measure the mask", exc_info=True)
return None
self._lookup = lookup
self._lookup_mask = np.array(self.mask, copy=True)
self._lookup_image = self.image
self._lookup_dirty = False
return lookup
def update_readout(self, pos=None, *, measure: bool = True):
"""Point the corner readout at the widget position ``pos``.
:param pos: where the mouse is, in widget coordinates, or ``None``
when it has left the canvas.
THE READOUT READS :meth:`detection_base` AND NOT
:meth:`detection_source`, so it never runs the enhancement chain.
This method is called on EVERY MOUSE MOVE, and a chain is filters
over a whole field: pointing the readout at the chain's output made
choosing a background subtraction freeze the window, because the
first move after choosing it ran the estimate on the GUI thread.
What the readout is for is the value under the cursor -- as loaded,
or stretched when "Detect on the normalized image" is on -- and the
stretch is exactly what :meth:`detection_base` is.
:param measure: also report the object under the pixel. False while
a button is held, because a brush stroke changes the mask on every
move and the object is re-read when the stroke ends.
:returns: the readout now shown, or ``None``.
"""
self._readout_pos = None if pos is None else QPointF(pos)
readout = None
spot = (None if pos is None or self.image is None
else self._canvas_to_image(pos.x(), pos.y()))
if spot is not None:
source = self.detection_base()
lookup = self._object_lookup() if measure else None
if lookup is not None:
readout = lookup.at(*spot)
if readout is not None and (self.invert_display
or self.detect_on_normalized):
readout = readout._replace(
intensity=self._value_at(source, spot))
else:
readout = engine.PixelReadout(
spot[0], spot[1], self._value_at(source, spot))
if readout != self.readout:
before = self.readout_rect()
self.readout = readout
for rect in (before, self.readout_rect()):
if rect is not None:
self.update(rect.adjusted(-2, -2, 2, 2))
return readout
@staticmethod
def _value_at(source, spot) -> float:
"""The intensity at ``spot`` of whichever array is handed in.
Reading :attr:`image` always would leave the number under the mouse
unchanged while the picture showed the negative with Invert on --
and the readout is the instrument a curator checks the inversion
WITH, so a readout that does not move makes Invert look broken.
It now reads what is DRAWN, so the number agrees with the picture.
What is saved and what the Filter measures still read the loaded
pixels, which is why the caption says which of the two a number is.
:param source: the array actually on screen.
:param spot: ``(x, y)`` in image pixels.
:returns: the value, averaged over channels for a colour field.
"""
value = np.asarray(source[spot[1], spot[0]], dtype=np.float32)
return float(value.mean())
def _schedule_readout(self) -> None:
"""Re-read the readout once the current event has been handled.
The canvas is the timer's context object, so a canvas deleted before
the timer fires cancels it rather than being called after it has gone.
"""
if self._readout_pos is None or self._readout_queued:
return
self._readout_queued = True
QTimer.singleShot(0, self, self._reread_readout)
def _reread_readout(self) -> None:
"""Measure the pixel the mouse is resting on again, after an edit."""
self._readout_queued = False
if self._readout_pos is None or QApplication.mouseButtons() \
!= Qt.NoButton:
return
self.update_readout(self._readout_pos)
def readout_text(self) -> str:
"""The corner readout as it is painted, or ``""`` when there is none.
The first line is the pixel -- its position and raw intensity -- and
the second, over an object, is that object's id, area and mean
intensity, in the units and to the decimals the filter's own boxes
use, so a value read here can be typed there.
"""
from ..i18n import tr
readout = self.readout
if readout is None:
return ""
if self.detect_on_normalized:
lines = [tr("x {x}, y {y} intensity {value} (normalized, as detected)",
x=readout.x, y=readout.y,
value=_readout_number(readout.intensity))]
elif self.invert_display:
lines = [tr("x {x}, y {y} intensity {value} (inverted)",
x=readout.x, y=readout.y,
value=_readout_number(readout.intensity))]
else:
lines = [tr("x {x}, y {y} intensity {value}", x=readout.x,
y=readout.y,
value=_readout_number(readout.intensity))]
if readout.label:
if self.invert_display or self.detect_on_normalized:
lines.append(tr(
"Object {label} area {area} px mean intensity "
"{mean} (as loaded)",
label=readout.label, area=readout.area,
mean=_readout_number(readout.mean_intensity, decimals=2)))
else:
lines.append(tr(
"Object {label} area {area} px mean intensity {mean}",
label=readout.label, area=readout.area,
mean=_readout_number(readout.mean_intensity, decimals=2)))
return "\n".join(lines)
def readout_rect(self) -> Optional[QRect]:
"""Where the readout is painted: the top-left corner of the image.
:returns: the box in widget coordinates, or ``None`` when there is
nothing to show.
"""
text = self.readout_text()
rendered = self.pixmap()
if not text or rendered is None or rendered.isNull():
return None
shown = logical_size(rendered)
left = max(0, (self.width() - shown.width()) // 2)
top = max(0, (self.height() - shown.height()) // 2)
margin = SPACING["xs"]
metrics = self.fontMetrics()
bounds = metrics.boundingRect(QRect(0, 0, 10_000, 10_000),
int(Qt.AlignLeft | Qt.AlignTop), text)
return QRect(left + margin, top + margin,
bounds.width() + 2 * SPACING["sm"],
bounds.height() + 2 * SPACING["xs"])
def _paint_readout(self) -> None:
"""Paint the readout in the image's top-left corner, over everything.
On a translucent plate of the page colour, so it reads over a bright
field and a dark one alike.
"""
rect = self.readout_rect()
if rect is None:
return
palette = active_palette()
plate = QColor(palette["bg"])
plate.setAlpha(215)
painter = QPainter(self)
try:
painter.setRenderHint(QPainter.Antialiasing, True)
painter.setPen(Qt.NoPen)
painter.setBrush(plate)
painter.drawRoundedRect(QRectF(rect), 4, 4)
painter.setPen(QPen(QColor(palette["fg"])))
painter.drawText(
rect.adjusted(SPACING["sm"], SPACING["xs"], 0, 0),
int(Qt.AlignLeft | Qt.AlignTop), self.readout_text())
finally:
painter.end()
def _paint_magnifier(self) -> None:
"""Draw the live magnifier's box, when there is one to draw."""
magnifier = self.magnifier
if magnifier is None or not magnifier.enabled:
return
painter = QPainter(self)
try:
magnifier.paint(painter)
finally:
painter.end()
def _paint_prompter(self) -> None:
"""Draw the prompt and the mask it gave, while prompting is on."""
prompter = self.prompter
if prompter is None or not prompter.enabled:
return
painter = QPainter(self)
try:
prompter.paint(painter)
finally:
painter.end()
def event(self, event):
"""Keep Enter, Backspace and Escape for a prompt that wants them.
The screen binds Escape to resetting the zoom; while a prompt is on
the canvas, Escape discards the prompt instead, and the canvas says
so to Qt before the shortcut is looked up.
"""
prompter = getattr(self, "prompter", None)
if (event.type() == QEvent.ShortcutOverride and prompter is not None
and prompter.wants_key(event.key())):
event.accept()
return True
return super().event(event)
def keyPressEvent(self, event):
"""Hand Enter, Backspace and Escape to a prompt that wants them."""
prompter = getattr(self, "prompter", None)
if prompter is not None and prompter.key(event.key()):
event.accept()
self.update()
return
super().keyPressEvent(event)
def _paint_recrop_boxes(self) -> None:
"""Mark every region already cut out of this field, with its name.
A recrop writes two files somewhere else and leaves the field on
screen untouched, so without this the canvas looks exactly the same
whether the box was written or refused. That is not a cosmetic
difference: the standalone this came from put one object on disk
three times as three near-identical crops, because the user could
only tell a box had worked by drawing it again.
"""
rendered = self.pixmap()
if not self.recrop_boxes or self.mask is None \
or rendered is None or rendered.isNull():
return
painter = QPainter(self)
accent = QColor(active_palette()["accent"])
fill = QColor(accent)
fill.setAlpha(55)
for box in self.recrop_boxes:
x0, y0, x1, y1 = (int(v) for v in box[:4])
rect = QRect(self._image_to_canvas(x0, y0),
self._image_to_canvas(x1, y1)).normalized()
painter.fillRect(rect, fill)
pen = QPen(accent)
pen.setWidth(2)
painter.setPen(pen)
painter.drawRect(rect)
name = str(box[4]) if len(box) > 4 else ""
if name:
painter.setPen(QPen(QColor(active_palette()["fg"])))
painter.drawText(rect.adjusted(6, 4, 0, 0),
Qt.AlignLeft | Qt.AlignTop, name)
def _paint_gesture(self) -> None:
"""Draw the outline being traced, or the cut being aimed.
The draw preview shows the segment that will close the loop as a
dashed line back to the first point, because that segment is part of
what gets filled and is the one part of the outline the user did not
trace.
"""
if len(self._gesture_points) < 2:
return
painter = QPainter(self)
colour = QColor(active_palette()["accent"])
if self.mode == MODE_DIVIDE:
pen = QPen(colour)
pen.setWidth(2)
pen.setStyle(Qt.SolidLine if self._gesture_button == Qt.RightButton else Qt.DashLine)
painter.setPen(pen)
painter.drawLine(self._gesture_points[0], self._gesture_points[-1])
return
pen = QPen(colour)
pen.setWidth(2)
painter.setPen(pen)
for start, end in zip(self._gesture_points, self._gesture_points[1:]):
painter.drawLine(start, end)
closing = QPen(colour)
closing.setWidth(1)
closing.setStyle(Qt.DashLine)
painter.setPen(closing)
painter.drawLine(self._gesture_points[-1], self._gesture_points[0])
def _emit_stroke_start(self):
"""Open a stroke, once. A stroke already open is not reopened.
The ledger records one edit per stroke, so a second start would split a
single drag into two entries.
"""
if not self._stroke_in_progress:
self._stroke_in_progress = True
self.stroke_started.emit()
def _emit_stroke_end(self, kind: str = "paint", target=None, **detail):
"""Close the open stroke, naming what it did for the ledger.
A no-op when no stroke is open, so the release handler can call it
unconditionally after a tool (erase-object, wand) that already
closed its own stroke on press — without that guard the release
would overwrite :attr:`last_edit` with a second, empty description
of an edit that has already been recorded.
"""
if not self._stroke_in_progress:
return
self._stroke_in_progress = False
self.last_edit = {"kind": str(kind), "target": target,
"detail": dict(detail)}
self.stroke_finished.emit()
def _sweep_delete_at(self, pt) -> bool:
"""Delete the object under ``pt`` as part of the open sweep.
The stroke is opened here, on the first object actually hit, rather
than on the button press: a right-click that lands on background has
then changed nothing and leaves no undo step and no ledger entry to
step back through.
"""
if self.mask is None or pt is None:
return False
x, y = pt
h, w = self.mask.shape[:2]
if not (0 <= y < h and 0 <= x < w) or int(self.mask[y, x]) <= 0:
return False
self._emit_stroke_start()
magnifier = self.magnifier
if magnifier is not None and magnifier.enabled:
magnifier.hold_auto_accept(self.mask == self.mask[y, x])
removed = engine.erase_object_in_place(self.mask, x, y)
if removed and removed not in self._sweep_labels:
self._sweep_labels.append(removed)
self.refresh()
return True
def _ctrl_edit_at(self, pt, *, split: bool) -> bool:
"""Split or remove the object under ``pt``.
Ctrl + left click splits the hovered object; Ctrl + right click
removes it.
BOTH ARE ONE STROKE, so each is one undo step and one ledger entry,
like every other edit on this canvas. The stroke is opened only once
something is actually going to change: a Ctrl+click on background,
or on an object with no waist to cut, leaves no empty undo step to
step back through and says why on the status line instead.
:param pt: the image pixel clicked, or None when the click was off
the image.
:param split: True for the left button's split, False for the right
button's removal.
:returns: whether the mask changed.
"""
if pt is None or self.mask is None:
return False
x, y = pt
height, width = self.mask.shape[:2]
if not (0 <= y < height and 0 <= x < width):
return False
target = int(self.mask[y, x])
if target <= 0:
self.status.emit(
"Ctrl+click acts on an object — there is none under the "
"cursor.")
return False
if not split:
self._emit_stroke_start()
self.mask = engine.erase_object_at(self.mask, x, y)
self.refresh()
self._emit_stroke_end(kind="delete", target=target,
gesture="ctrl_click")
self.status.emit(
f"Removed object {target} — Ctrl+Z to undo")
return True
out, new_ids = engine.split_object_at(
self.mask, x, y, min_area=int(self.split_min_area))
if not new_ids:
self.status.emit(
f"Object {target} has one centre, so there is no waist to "
"split it at. The Divide tool cuts along a line you draw.")
return False
self._emit_stroke_start()
self.mask = out
self.refresh()
self._emit_stroke_end(kind="split", target=target,
gesture="ctrl_click", into=list(new_ids),
n_objects=len(new_ids) + 1)
made = ", ".join(str(new) for new in new_ids)
self.status.emit(
f"Split object {target} into {len(new_ids) + 1} — new id(s) "
f"{made}. Ctrl+Z to undo")
return True
def mousePressEvent(self, event):
"""Dispatch a click to the current tool (brush/erase/wand/zoom/…).
Three gestures are checked before the tool, because they work from
*any* tool: Ctrl + left splits (or erases a region with Wand selected)
and Ctrl + right removes the object
under the cursor, the right button sweep-deletes except in Divide /
Merge where it traces a line that joins objects,
and Shift/Alt + left pans. All of them are things you want mid-edit
without putting the brush down and picking it up again. With the
magnifier on in whole-image scope the right button removes the one
object under it instead of sweeping.
CTRL IS TESTED FIRST, before the magnifier and before the pan, or
the two edits would exist only while the magnifier was off: its own
press handler takes every left click, and its whole-image mode takes
every right one.
A CTRL EDIT OWNS THE MOUSE, and only starts when it can: the whole
branch is behind ``event.buttons() == event.button()``, so it runs
only when nothing else is already down. Pressing Ctrl+left in the
middle of a right-button sweep would otherwise end the SWEEP's
stroke and label it a split, and the sweep's own release would then
be swallowed as this gesture's -- leaving the sweep open with no
ledger entry.
SUCH A PRESS IS SWALLOWED RATHER THAN HANDED ON. Falling through is
not the safe default it looks like: the rest of this handler would
read a Ctrl+left as an ordinary left press and open a PAINT stroke,
so a chord meant to split an object would paint over one instead.
Ctrl with another button already down does nothing at all, and so
does a second button pressed while a Ctrl edit is open -- whose
release is then consumed without ending that edit.
ITS RELEASE IS SWALLOWED WITH IT, through :attr:`_swallowed`. A
release that falls through reaches the generic stroke end at the
foot of :meth:`mouseReleaseEvent`, which closes whatever stroke is
open -- the SWEEP's -- and labels it a paint, which is the whole
damage this guard exists to stop.
:attr:`_ctrl_click` is still rewritten by every press that arrives
alone, so a release that never came -- a grab lost to a dialog --
cannot leave it standing and swallow the next drag.
WITH PROMPTING ON (:attr:`prompter`) a left press, a right press and
a left drag are prompts. They are read after the Ctrl edits and
before everything else except a Shift/Alt pan, so the right button
adds a point off the object instead of sweeping objects away.
"""
if self.mask is None:
return super().mousePressEvent(event)
if self.mode == MODE_BOX:
self._box_press(event)
return
if self.ruler.handle(event, lambda p: self._canvas_to_image(p.x(), p.y())):
return
magnifier = self.magnifier
if (magnifier is not None and magnifier.enabled
and magnifier._lock_key_down
and event.modifiers() & Qt.ControlModifier
and event.button() == Qt.RightButton):
if event.buttons() == event.button():
self._swallowed.clear()
self._ctrl_click = None
if not magnifier.locked:
magnifier.hover(event.position())
magnifier.set_locked(not magnifier.locked)
self._swallowed.add(event.button())
event.accept()
return
if event.buttons() == event.button():
self._swallowed.clear()
if self.mode in (MODE_DRAW, MODE_DIVIDE):
self._gesture_points = []
self._gesture_button = None
self._ctrl_click = (
event.button()
if (event.modifiers() & Qt.ControlModifier
and event.button() in (Qt.LeftButton, Qt.RightButton))
else None)
if self._ctrl_click is not None:
pt = self._canvas_to_image(event.position().x(), event.position().y())
if (self.mode in (MODE_WAND_ADD, MODE_WAND_ERASE)
and event.button() == Qt.LeftButton):
self._wand_edit_at(pt, action="erase")
else:
self._ctrl_edit_at(pt, split=event.button() == Qt.LeftButton)
self.update()
return
elif (self._ctrl_click is not None
or event.modifiers() & Qt.ControlModifier):
self._swallowed.add(event.button())
return
prompter = self.prompter
if (prompter is not None and prompter.enabled
and not (event.button() == Qt.LeftButton
and event.modifiers() & PAN_MODIFIERS)
and prompter.press(event.button(), event.position())):
self.setFocus(Qt.MouseFocusReason)
self.update()
return
if (event.button() == Qt.RightButton and self.magnifier is not None
and self.magnifier.enabled
and self.magnifier.scope == "image"
and self.mode != MODE_DIVIDE):
self.magnifier.hover(event.position())
self.magnifier.remove_at(self._canvas_to_image(
event.position().x(), event.position().y()))
self.update()
return
if (self.mode == MODE_DRAW and event.button() == Qt.RightButton
and self._gesture_points):
self._swallowed.add(event.button())
return
if self.mode == MODE_DIVIDE and event.button() == Qt.RightButton:
if self._gesture_points:
self._swallowed.add(event.button())
return
if self._canvas_to_image(event.position().x(), event.position().y()) is not None:
self._gesture_points = [event.position().toPoint()]
self._gesture_button = Qt.RightButton
self.update()
return
if event.button() == Qt.RightButton:
self._sweeping = True
self._sweep_labels = []
self._sweep_delete_at(
self._canvas_to_image(event.position().x(),
event.position().y()))
return
if event.button() == Qt.LeftButton and (
event.modifiers() & PAN_MODIFIERS):
self._pan_from = event.position().toPoint()
self.setCursor(Qt.ClosedHandCursor)
return
if (event.button() == Qt.LeftButton and self.magnifier is not None
and self.magnifier.enabled):
self.magnifier.hover(event.position())
self.magnifier.press()
self.update()
return
if self.mode == MODE_NONE:
return super().mousePressEvent(event)
if self.mode in (MODE_ZOOM, MODE_RECROP):
self._zoom_drag_start = event.position().toPoint()
self._zoom_drag_end = event.position().toPoint()
self.update()
return
pt = self._canvas_to_image(event.position().x(), event.position().y())
if pt is None:
return
if self.mode in (MODE_DRAW, MODE_DIVIDE):
if self._gesture_points:
self._swallowed.add(event.button())
return
self._gesture_points = [event.position().toPoint()]
self._gesture_button = Qt.LeftButton
self.update()
return
if self.mode in (MODE_WAND_ADD, MODE_WAND_ERASE):
self._wand_edit_at(pt, action="add" if self.mode == MODE_WAND_ADD else "erase")
return
if self.mode == MODE_BRUSH:
self._brush_value = int(engine.next_label(self.mask)) if self.manual_ids else 255
if self.manual_ids:
self.mask = self.mask.astype(np.result_type(
self.mask.dtype, np.min_scalar_type(self._brush_value)), copy=False)
self._emit_stroke_start()
if self.mode == MODE_ERASE_OBJECT:
removed = int(self.mask[pt[1], pt[0]])
self.mask = engine.erase_object_at(self.mask, *pt)
self.refresh()
self._emit_stroke_end(kind="delete", target=removed)
return
radius = self._mask_radius_for_brush()
value = self._brush_value if self.mode == MODE_BRUSH else 0
engine.paint_disk(self.mask, pt[0], pt[1], radius, value)
self._last_pt = QPoint(*pt)
self.refresh()
def _wand_edit_at(self, pt, *, action):
"""Apply one bounded intensity-region edit with the Wand settings.
:param pt: clicked image coordinates, or None outside the image.
:param action: add the region or erase it.
"""
if pt is None:
return
wand_input = self.wand_source()
if wand_input is None:
return
self._emit_stroke_start()
tolerance = self.effective_wand_tolerance(wand_input)
before = self.mask
self.mask, report = wand_rescue.magic_wand(
wand_input, self.mask, pt[0], pt[1],
tolerance, self.wand_max_pixels, action=action,
**self.wand_rescue_settings(),
)
target = 255 if action == "add" else 0
if self.manual_ids and action == "add":
target = int(engine.next_label(before))
out = self.mask.astype(np.int64, copy=True)
out[(self.mask == 255) & (before != 255)] = target
self.mask = engine._fit_label_width(out, before)
self.refresh()
self._emit_stroke_end(
kind="wand", target=target,
action=action, tolerance=round(float(tolerance), 3),
relative=bool(self.wand_relative), **report,
input_kind='enhanced_picture' if self.enhance_display else 'as_loaded',
invert=bool(self.enhance_display and self.invert_display),
normalization_percentiles=([float(self.norm_lo), float(self.norm_hi)]
if self.enhance_display and self.detect_on_normalized else None),
**detect_chain.provenance(
self.enhance_chain._replace(morphology='none', split=False)
if self.enhance_display else detect_chain.NO_CHAIN,
percentile_stretch=self.enhance_display and self.detect_on_normalized),
)
def mouseMoveEvent(self, event):
"""Move the readout; extend a sweep, a pan, a stroke or a zoom drag.
A move while a Ctrl+click is still down moves the readout and
nothing else: that click was one edit and is finished, so dragging
away from it must not turn into a magnifier stroke or a sweep.
"""
if self.mask is None:
return
if self.mode == MODE_BOX:
self._box_move(event)
return
if self.ruler.handle(event, lambda p: self._canvas_to_image(p.x(), p.y())):
return
self.update_readout(event.position(),
measure=event.buttons() == Qt.NoButton)
if self._ctrl_click is not None:
return
if self.prompter is not None and self.prompter.move(event.position()):
self.update()
return
if self._sweeping and event.buttons() & Qt.RightButton:
self._sweep_delete_at(
self._canvas_to_image(event.position().x(),
event.position().y()))
return
if self._pan_from is not None and event.buttons() & Qt.LeftButton:
now = event.position().toPoint()
dx, dy = self._image_delta(now.x() - self._pan_from.x(),
now.y() - self._pan_from.y())
if (dx or dy) and self.pan_by(dx, dy):
self._pan_from = now
return
if self.magnifier is not None and self.magnifier.enabled:
self.magnifier.hover(event.position())
if event.buttons() & Qt.LeftButton:
self.magnifier.drag()
elif (self.mode == MODE_DIVIDE and self._gesture_points
and self._gesture_button == Qt.RightButton
and event.buttons() & Qt.RightButton):
self._gesture_points = [self._gesture_points[0],
event.position().toPoint()]
self.update()
return
if self.mode in (MODE_ZOOM, MODE_RECROP) \
and self._zoom_drag_start is not None \
and event.buttons() & Qt.LeftButton:
self._zoom_drag_end = event.position().toPoint()
self.update()
return
if self.mode in (MODE_DRAW, MODE_DIVIDE) and self._gesture_points \
and self._gesture_button is not None \
and event.buttons() & self._gesture_button:
now = event.position().toPoint()
if self.mode == MODE_DIVIDE:
self._gesture_points = [self._gesture_points[0], now]
else:
self._gesture_points.append(now)
self.update()
return
if self.mode in (MODE_BRUSH, MODE_ERASE) and event.buttons() & Qt.LeftButton:
pt = self._canvas_to_image(event.position().x(), event.position().y())
if pt is None:
return
self._emit_stroke_start()
radius = self._mask_radius_for_brush()
value = self._brush_value if self.mode == MODE_BRUSH else 0
if self._last_pt is not None:
engine.paint_line(self.mask,
self._last_pt.x(), self._last_pt.y(),
pt[0], pt[1], radius, value)
else:
engine.paint_disk(self.mask, pt[0], pt[1], radius, value)
self._last_pt = QPoint(*pt)
self.refresh()
def mouseReleaseEvent(self, event):
"""Close a sweep or pan, commit a zoom rect, or finalize a stroke.
The readout is re-measured once the release has been handled, since a
held button kept it to the pixel while the mask was changing.
A Ctrl+click is over the moment it was pressed, so its release is
consumed here rather than handed on — see :attr:`_ctrl_click`. Any
release arriving while one is open is consumed, but only the button
that OPENED it closes it: a second button pressed meanwhile started
nothing, so its release must end nothing. A button whose press was
declined outright (:attr:`_swallowed`) is dropped for the same
reason, before the generic stroke end below can close somebody
else's stroke with it.
"""
if self.mode == MODE_BOX:
self._box_release(event)
return
if self.ruler.handle(event, lambda p: self._canvas_to_image(p.x(), p.y())):
return
self._schedule_readout()
if self._ctrl_click is not None:
if event.button() == self._ctrl_click:
self._ctrl_click = None
return
if event.button() in self._swallowed:
self._swallowed.discard(event.button())
return
if self.prompter is not None and self.prompter.release(
event.button(), event.position()):
self.update()
return
if event.button() == Qt.RightButton and self._sweeping:
self._sweeping = False
labels, self._sweep_labels = self._sweep_labels, []
self._emit_stroke_end(kind="sweep_delete", target=list(labels),
n_objects=len(labels))
return
if self._pan_from is not None and event.button() == Qt.LeftButton:
self._pan_from = None
self.unsetCursor()
return
if (event.button() == Qt.LeftButton and self.magnifier is not None
and (self.magnifier.enabled
or self.magnifier._stroke is not None)):
self.magnifier.release()
self.update()
return
if (event.button() == Qt.RightButton and self.magnifier is not None
and self.magnifier.enabled
and self.magnifier.scope == "image"
and self.mode != MODE_DIVIDE):
return
if self.mode in (MODE_ZOOM, MODE_RECROP) \
and self._zoom_drag_start is not None \
and self._zoom_drag_end is not None:
p0 = self._canvas_to_image(self._zoom_drag_start.x(),
self._zoom_drag_start.y())
p1 = self._canvas_to_image(self._zoom_drag_end.x(),
self._zoom_drag_end.y())
self._zoom_drag_start = None
self._zoom_drag_end = None
if self.mode == MODE_RECROP:
if p0 is not None and p1 is not None:
self.recrop_requested.emit(int(p0[0]), int(p0[1]),
int(p1[0]) + 1,
int(p1[1]) + 1)
self.update()
return
if p0 is not None and p1 is not None:
x0, x1 = sorted((p0[0], p1[0]))
y0, y1 = sorted((p0[1], p1[1]))
if x1 - x0 > 4 and y1 - y0 > 4:
self._zoom_x0, self._zoom_y0 = x0, y0
self._zoom_x1, self._zoom_y1 = x1 + 1, y1 + 1
self.zoom_changed.emit(True)
self.refresh()
return
if self.mode in (MODE_DRAW, MODE_DIVIDE) and self._gesture_points \
and event.button() == self._gesture_button:
points, self._gesture_points = self._gesture_points, []
merge = self._gesture_button == Qt.RightButton
self._gesture_button = None
if (merge and self.mode == MODE_DIVIDE
and self.magnifier is not None and self.magnifier.enabled
and self.is_click(points)):
start = points[0]
self.magnifier.remove_at(
self._canvas_to_image(start.x(), start.y()))
self.update()
return
self._finish_region_gesture(points, merge=merge)
self.update()
return
if self._last_pt is not None:
self._last_pt = None
self._emit_stroke_end(
kind="erase" if self.mode == MODE_ERASE else "paint",
target=(0 if self.mode == MODE_ERASE else self._brush_value),
radius=int(self.brush_radius),
)
@staticmethod
def is_click(points) -> bool:
"""Whether a gesture's canvas points are a click rather than a drag.
Shorter than the platform's drag distance is a click (item 685), so
a right click with the magnifier on deletes in Divide / Merge while
a right drag still merges.
"""
if len(points) < 2:
return True
travel = points[-1] - points[0]
return travel.manhattanLength() < QApplication.startDragDistance()
def retain_label_ids(self) -> bool:
"""Retain explicit primary identities or deliberate manual groupings."""
return bool(getattr(self, 'preserve_ids', False) or self.manual_ids)
def _finish_region_gesture(self, points, *, merge=False) -> None:
"""Commit a finished draw, divide or merge gesture, or drop it.
The mask is touched here and nowhere else for these two tools, and
only when the gesture did something: a traced outline that enclosed
nothing and a line that crossed no object both leave the mask, the
undo history and the ledger exactly as they were. A dividing line
that crosses an object without separating it is kept as a cut
(item 685), so the next line continues it.
The path is converted to image pixels here rather than as it is
drawn, so that a gesture whose points fall outside the pixmap loses
those points instead of the whole edit.
:param points: canvas-pixel vertices of the completed gesture.
:param merge: join crossed IDs instead of dividing. No background
pixels are painted, and the first crossed object keeps its ID.
"""
if self.mask is None:
return
image_points = [p for p in
(self._canvas_to_image(q.x(), q.y()) for q in points)
if p is not None]
if self.mode == MODE_DIVIDE:
if len(image_points) < 2:
return
if merge:
source = (self.mask if self.retain_label_ids()
else engine.canonical_labels(self.mask))
grouped, kept, joined = engine._merge_objects_along_line(
source, image_points[0], image_points[-1])
if not joined:
return
self._emit_stroke_start()
self.manual_ids = True
self.mask = grouped
self.refresh()
self._emit_stroke_end(kind="merge", target=kept,
joined_labels=joined, n_objects=len(joined))
return
divided, splits, cut = engine._divide_or_cut(
self.mask, image_points[0], image_points[-1])
if not splits and not cut:
return
self._emit_stroke_start()
self.mask = divided
self.refresh()
self._emit_stroke_end(
kind="divide",
target=[int(source) for source, _ in splits],
new_labels=[int(made) for _, made in splits],
n_objects=len(splits),
cut_labels=[int(label) for label in cut],
)
if cut and not splits:
from ..i18n import tr
self.status.emit(tr(
"Cut kept in object(s) {labels}; draw another line to "
"finish dividing. Ctrl+Z to undo").format(
labels=", ".join(str(label) for label in cut)))
return
filled, new_label = engine.fill_polygon(self.mask, image_points)
if not new_label:
return
self._emit_stroke_start()
self.mask = filled
self.refresh()
self._emit_stroke_end(kind="draw", target=int(new_label),
n_points=len(image_points))
def resizeEvent(self, event):
"""Refit the composited pixmap to the new canvas size."""
super().resizeEvent(event)
self.refresh()
def leaveEvent(self, event):
"""Put the magnifier's box and the readout away as the mouse leaves."""
self.update_readout(None)
if self.magnifier is not None and self.magnifier.enabled:
self.magnifier.hover(None)
self.update()
super().leaveEvent(event)
#: Cellpose's own default for the threshold on the cell-probability map.
#: Lowering it keeps dimmer pixels, raising it keeps only confident ones.
CELLPROB_THRESHOLD = 0.0
#: Cellpose's own default for the flow-error threshold. A candidate mask
#: whose flows disagree with the ones the network predicted by more than
#: this is thrown away, so lowering it is stricter, not looser.
FLOW_THRESHOLD = 0.4
#: What the Recrop button says about itself. The only tool in the row
#: whose result is not a change to the picture under the cursor, so it is
#: the one that cannot be understood by pressing it and looking.
RECROP_TOOLTIP = (
"Drag a box round one object and that region of the image and the mask "
"becomes a field of its own, queued straight after this one. Objects "
"the box cuts through are dropped. The field you cut them from is "
"moved into recropped_originals/ when you move on, not deleted."
)
#: What the probability and flow panes say before Cellpose has run. They
#: are empty for a reason and the reason is worth a sentence: a blank
#: black pane reads as a broken view.
FLOW_RESTING_TEXT = (
"Run Object detection to see the cell-probability map\n"
"and the flow field for this field."
)
def _readout_number(value, decimals: int = 0) -> str:
"""A number as the corner readout writes it.
A whole number, which every pixel of an integer image is, is written
without a decimal point; anything else to ``decimals`` places, which for
a mean is the two the filter's intensity boxes take.
:param value: the number, or ``None``.
:param decimals: places for a number that is not whole.
:returns: the text.
"""
if value is None:
return ""
value = float(value)
if value.is_integer():
return str(int(value))
return f"{value:.{max(1, int(decimals))}f}"
[docs]
def stretch_to_uint8(array: np.ndarray,
lower_pct: float = 1.0,
upper_pct: float = 99.0) -> np.ndarray:
"""Percentile-stretch any float array to 0-255 uint8.
The probability map runs roughly -12..+8 and the flow components
±5; the intensity image is 16-bit counts. Shown raw beside each
other they are three different scales and none of them is readable.
Stretching each one by its own percentiles is what puts them on the
same footing as the contrast-stretched intensity image the canvas
already draws, which is the only way the panes can be compared with
it by eye.
:param array: any numeric array; it is read as float32, and an empty array
gives an empty uint8 array.
:param lower_pct: percentile mapped to 0.
:param upper_pct: percentile mapped to 255.
"""
values = np.asarray(array, dtype=np.float32)
if not values.size:
return np.zeros(values.shape, dtype=np.uint8)
lo = float(np.percentile(values, lower_pct))
hi = float(np.percentile(values, upper_pct))
span = max(hi - lo, 1e-6)
scaled = np.clip((values - lo) / span, 0.0, 1.0)
return (scaled * 255.0).astype(np.uint8)
[docs]
def cellprob_heatmap(cellprob: np.ndarray) -> np.ndarray:
"""Cellpose's probability map as an RGB heatmap, ``(H, W, 3)`` uint8.
A greyscale probability map beside a greyscale image is two pictures
that look alike and mean different things. A colour ramp says at a
glance which pixels the network was confident about, which is the
whole reason to look at this map before moving a threshold.
Matplotlib's ``magma`` is used where it is importable, and a plain
black-to-white ramp stands in where it is not, so the pane is never
the thing that fails.
:param cellprob: Cellpose's cell-probability map, a 2-D float array; it is
percentile-stretched with :func:`stretch_to_uint8` before colouring.
"""
scaled = stretch_to_uint8(cellprob).astype(np.float32) / 255.0
try:
import matplotlib
cmap = matplotlib.colormaps["magma"]
except Exception:
return np.repeat((scaled * 255).astype(np.uint8)[..., None], 3, axis=2)
return (np.asarray(cmap(scaled))[..., :3] * 255).astype(np.uint8)
[docs]
def flow_rgb(flow: np.ndarray) -> Optional[np.ndarray]:
"""Cellpose's flow field as ``(H, W, 3)`` uint8, or None if it is not one.
``eval`` hands back the flow field twice over and the two entries are
not the same thing: ``flows[0]`` is already an RGB *picture* of the
field (hue = direction), while ``flows[1]`` is the raw ``(2, H, W)``
vector field. This takes the picture where it is given one and builds
an equivalent from the vectors otherwise, so the pane fills whichever
entry a caller passes.
:param flow: either Cellpose's RGB flow picture, an array of shape (H, W, 3
or more), or the raw vector field of shape (2, H, W); ``None`` or any
other shape gives ``None``.
"""
if flow is None:
return None
array = np.asarray(flow)
if array.ndim == 3 and array.shape[0] == 2:
dy, dx = stretch_to_uint8(array[0]), stretch_to_uint8(array[1])
mag = stretch_to_uint8(np.hypot(array[0], array[1]))
return np.ascontiguousarray(np.stack([dx, dy, mag], axis=-1))
if array.ndim == 3 and array.shape[2] >= 3:
return np.ascontiguousarray(array[..., :3].astype(np.uint8))
return None
#: The item-data role marking a Model box row that came from the model zoo.
_ZOO_ROLE = int(Qt.UserRole) + 17
#: Item-data role holding the zoo entry of a Model row that is listed but not
#: downloaded. Such a row stores no path, so it can never be what a detect
#: run loads; choosing it starts the download instead.
_ZOO_PENDING_ROLE = int(Qt.UserRole) + 18
def _zoo_cellpose_models() -> List[tuple]:
"""``(key, path or None, entry)`` for every Cellpose model in the zoo.
:func:`_zoo_models_of_kind` for the ``cellpose`` kind.
"""
return _zoo_models_of_kind("cellpose")
def _zoo_cellpose_dino_models() -> List[tuple]:
"""``(model setting, caption)`` for each Cellpose-DINO model downloaded.
Item 525. The setting is ``cellpose_dino:<path>``, which
:func:`load_cellpose_model` and :func:`_backend_model` run in the
Cellpose-DINO backend, and the caption is the one the Model zoo...
button gives such a model. A row that is not downloaded is left out:
the Model zoo picker is where one is downloaded, next to its backend's
install.
"""
from ..i18n import tr
from ..._segmentation_backends import _cellpose_dino_value
return [(_cellpose_dino_value(path),
tr("Cellpose-DINO · {model}", model=os.path.basename(path)))
for _key, path, _entry in _zoo_models_of_kind("cellpose_dino")
if path]
def _zoo_prefixed_models() -> List[tuple]:
"""``(model setting, caption)`` for each model of each installed
prefixed backend -- StarDist, InstanSeg, Omnipose (items 551-553).
The setting is ``<prefix><model>``, which :func:`load_cellpose_model`
and :func:`_backend_model` run in that backend; the caption is
"<backend> · <model>". A backend that is not installed lists nothing
here: the Mode box offers its install.
"""
from ..i18n import tr
from ..._segmentation_backends import (_SPECS, _prefixed_names,
_prefixed_value)
out = []
for name in _prefixed_names():
if not _state_ready(name):
continue
spec = _SPECS[name]
out.extend((_prefixed_value(name, model),
tr("{backend} · {model}", backend=spec.label,
model=model))
for model in spec.models
if not _prefixed_alpha_hidden(name, model))
return out
def _prefixed_alpha_hidden(name: str, model: str) -> bool:
"""Whether the alpha gate hides backend ``name``'s ``model`` (item 569).
The model is hidden when its Model Zoo row (``<name>_<model>``) or its
backend's row (``<name>_v1``) is registered in
``spacr.settings.ALPHA_FEATURES`` and Show alpha features is off -- the
same rows the Model Zoo folds away, so the Mode box and the Model list
offer exactly what the zoo shows.
"""
from ..preferences import _is_alpha_visible
return not (_is_alpha_visible("models", f"{name}_{model}")
and _is_alpha_visible("models", f"{name}_v1"))
def _zoo_models_of_kind(kind: str) -> List[tuple]:
"""``(key, path or None, entry)`` for every zoo model of ``kind``.
``path`` is where the model is on this machine -- the entry's own path,
or its file in the folder the Model zoo picker downloads into -- and None
for one not downloaded. ``entry`` is the zoo's own record, which is what
a download starts from. Read without waiting on the network (the
community rows come from the zoo's cache), and never raises: a zoo that
cannot be read leaves the Model box with the Cellpose installed here.
OFFERS WHAT THE ZOO OFFERS, AND NOTHING ELSE. The source headings the
Model zoo picker carries are a preference, not a property of
one dialog: a user who folded bioimage.io away in the picker has said
they do not want those models, and a Mode box that listed them anyway
would be the one place that ignored them. So the same persisted headings
filter this list.
:param kind: the zoo kind, ``cellpose`` or ``cellpose_dino``.
"""
try:
from ... import model_zoo
from ..widgets.model_zoo_picker import (remembered_model_dir,
remembered_sources)
entries = model_zoo.catalogue(remote=True, block=False)
folder = remembered_model_dir()
sources = set(remembered_sources())
except Exception: # noqa: BLE001
LOG.debug("the model zoo could not be read", exc_info=True)
return []
found = []
for entry in entries:
if getattr(entry, "kind", "") != kind:
continue
if model_zoo.source_of(entry) not in sources:
continue
path = str(getattr(entry, "path", "") or "")
if not (path and os.path.isfile(path)):
candidate = os.path.join(folder, str(entry.name))
path = candidate if os.path.isfile(candidate) else ""
found.append((str(entry.key or entry.name), path or None, entry))
return found
[docs]
def load_cellpose_model(model_name: str):
"""Load a Cellpose model through spaCR's own resolver.
:func:`spacr.utils._resolve_cellpose_pretrained` is what the pipeline
itself calls: it maps every pre-SAM name onto ``cpsam``, keeps a
fine-tuned checkpoint path as itself, and raises rather than quietly
substituting stock weights for a checkpoint that is not there. Going
around it with a second, simpler call would give this screen a
different answer from the run it is meant to be correcting.
ON A CPU THE WEIGHTS ARE LOADED IN FLOAT32. Cellpose-SAM loads them in
bfloat16 by default, and a processor without native bfloat16
arithmetic runs every tile through a slow emulation of it: measured on
an AMD Ryzen 9 5950X, one 256 px tile took 162-197 s in bfloat16 and
31-46 s in float32, alternating, under the same load. A whole field is a hundred such tiles, so this is
the difference between most of an hour and most of a day. On a GPU
nothing changes: :func:`spacr.accelerator.cellpose_kwargs` decides
there, as it does for the pipeline.
A ``cellpose3:<name or path>`` model -- a stock Cellpose 3 model or a
bioimage.io Cellpose 3 checkpoint -- is not a Cellpose 4 model at all:
Cellpose 4 would load such a checkpoint and segment nonsense with it. It
is loaded by :func:`_backend_model`, in the Cellpose 3 backend's own
environment, as Mask generation loads it. A ``cellpose_dino:<path>``
model, a Cellpose-DINO checkpoint, is loaded the same way in the
Cellpose-DINO backend, and a ``stardist:``, ``instanseg:``
or ``omnipose:`` model in its own backend.
:param model_name: a Cellpose model name, the path of a fine-tuned
checkpoint, resolved by
:func:`spacr.utils._resolve_cellpose_pretrained`, or
``cellpose3:<name or path>``.
"""
import inspect
from ..._segmentation_backends import (_cellpose3_choice,
_cellpose_dino_choice,
_prefixed_backend)
if (_cellpose3_choice(model_name) is not None
or _cellpose_dino_choice(model_name) is not None
or _prefixed_backend(model_name) is not None):
return _backend_model(str(model_name).strip())
import torch
from cellpose import models as cp_models
from ...utils import _resolve_cellpose_pretrained
pretrained = _resolve_cellpose_pretrained(model_name)
from ...accelerator import cellpose_kwargs
kwargs = cellpose_kwargs()
if not kwargs.get("gpu"):
try:
accepts = "use_bfloat16" in inspect.signature(
cp_models.CellposeModel).parameters
except (TypeError, ValueError):
accepts = False
if accepts:
kwargs["use_bfloat16"] = False
return cp_models.CellposeModel(pretrained_model=pretrained, **kwargs)
[docs]
def cellpose_detect(image: np.ndarray, model, *,
diameter: int = 0,
normalize: bool = True,
flow_threshold: float = FLOW_THRESHOLD,
cellprob_threshold: float = CELLPROB_THRESHOLD,
min_size: int = 0) -> tuple:
"""Segment one field with ``model``; return labels and both intermediates.
The image goes in as a **batch of one**, which is what
:func:`spacr.spacr_cellpose.parse_cellpose4_output` — the repository's
own reader of this return value — is written for. Handed a bare 2-D
array instead, ``eval`` returns a flat three-member flows list and
that function reads ``len(masks)`` as the number of images and finds
the image height, so the parse fails on an image that segmented
perfectly well.
``diameter`` is passed as None when it is 0, which is Cellpose's
"work it out from the image"; it is the one pre-SAM sizing argument
Cellpose 4 still honours, since ``eval`` rescales by ``30/diameter``.
:param image: one 2-D field, as the canvas holds it.
:param model: a loaded ``CellposeModel`` (see
:func:`load_cellpose_model`), or anything with the same ``eval``.
:returns: ``(labels, cellprob, flow_rgb)`` — an int32 label image, and
the two maps as :func:`cellpose_intermediates` reads them.
"""
return _cellpose_detect_with_vectors(
image, model, diameter=diameter, normalize=normalize,
flow_threshold=flow_threshold,
cellprob_threshold=cellprob_threshold, min_size=min_size)[:3]
def _cellpose_detect_with_vectors(image: np.ndarray, model, *,
diameter: int = 0,
normalize: bool = True,
flow_threshold: float = FLOW_THRESHOLD,
cellprob_threshold: float =
CELLPROB_THRESHOLD,
min_size: int = 0) -> tuple:
""":func:`cellpose_detect` with the network's flow vectors fourth.
The vectors are the ``(2, H, W)`` float32 flows a flow-error check
compares an outline with, or None when this Cellpose gave none.
:param image: one 2-D field.
:param model: a loaded ``CellposeModel`` or anything with its ``eval``.
:returns: ``(labels, cellprob, flow_rgb, vectors)``.
"""
import inspect
from ...spacr_cellpose import cellpose_channel_axis, parse_cellpose4_output
field = np.asarray(image)
kwargs = dict(
batch_size=1,
normalize=bool(normalize),
channel_axis=cellpose_channel_axis(field),
diameter=(int(diameter) or None),
flow_threshold=float(flow_threshold),
cellprob_threshold=float(cellprob_threshold),
min_size=int(min_size),
)
try:
params = inspect.signature(model.eval).parameters
except (TypeError, ValueError):
params = None
if params is not None and not any(
p.kind is inspect.Parameter.VAR_KEYWORD for p in params.values()):
kwargs = {k: v for k, v in kwargs.items() if k in params}
output = model.eval([field], **kwargs)
masks, flows0, flows1, flows2, _flows3 = parse_cellpose4_output(output)
labels = np.asarray(masks[0], dtype=np.int32)
cellprob, rgb = cellpose_intermediates(
[flows0[0] if flows0 else None,
flows1[0] if flows1 else None,
flows2[0] if flows2 else None])
vectors = flows1[0] if flows1 else None
if vectors is not None:
vectors = np.asarray(vectors, dtype=np.float32)
if vectors.shape != (2,) + labels.shape:
vectors = None
return labels, cellprob, rgb, vectors
#: The magnifier's starting settings and their ranges. Size is the side of the
#: square region the model segments, in IMAGE pixels; zoom is how many times
#: larger than the canvas draws that region the box draws it; a sensitivity
#: of 0 is each model's own default cut.
_MAGNIFIER_SIZE = 128
#: The size's range BEFORE A FIELD IS OPEN. Once one is, the top of the range
#: is that field's own longer side, so the magnifier can cover the whole
#: field -- see :func:`_magnifier_size_range`.
_MAGNIFIER_SIZE_RANGE = (32, 512)
_MAGNIFIER_ZOOM = 2.0
_MAGNIFIER_ZOOM_RANGE = (1.0, 8.0)
_MAGNIFIER_SENSITIVITY = 0.0
_MAGNIFIER_SENSITIVITY_RANGE = (-6.0, 6.0)
#: What the magnifier segments, in the order the Segment box offers them.
#: ``region`` runs the model on the box as the mouse moves; ``image`` runs it
#: once on the whole field in the background, and the box then reads those
#: objects instead of asking the model again.
_MAGNIFIER_SCOPES = ("region", "image")
#: The Overlap rule a fresh panel is on, and what the magnifier assumes
#: until the screen's Overlap box says otherwise.
_MAGNIFIER_OVERLAP_DEFAULT = "clip"
#: Megabytes of whole-image objects the magnifier may keep for fields it has
#: left, so coming back to one does not segment it again -- minutes, on a CPU.
#: A label image is int32, so this is sixteen 2048 px fields or four 4096 px
#: ones. It is a CEILING and not an allowance: the user's own cache ceiling
#: (:func:`spacr.qt.preferences.get_cache_ceiling_mb`) wins when it is lower,
#: and the idle timeout beside it releases what nobody has come back to.
_MAGNIFIER_IMAGE_CACHE_MB = 256
#: Serialises every use of a Cellpose model between the magnifier's worker
#: thread and the screen's own detect button, which share the loaded models.
#: Re-entrant, because the detect button loads a model inside the same hold.
_CELLPOSE_LOCK = threading.RLock()
def _magnifier_size_range(shape=None) -> tuple:
"""``(smallest, largest)`` box side, in image pixels, for a field shaped so.
The box is square, so its largest useful side is the field's LONGER side:
a box that size, centred anywhere, reaches across the field the long way.
A field narrower than the usual smallest box lowers the floor with it,
so the range is never empty.
:param shape: the open field's shape, ``(height, width, ...)``, or None
when no field is open -- which gives :data:`_MAGNIFIER_SIZE_RANGE`.
"""
if shape is None or len(shape) < 2:
return _MAGNIFIER_SIZE_RANGE
largest = max(1, int(shape[0]), int(shape[1]))
return (min(_MAGNIFIER_SIZE_RANGE[0], largest), largest)
class _MagnifierRequest(NamedTuple):
"""One region to segment, with everything the model reads copied in.
Copied rather than referenced: the worker reads it on another thread
while the GUI thread goes on editing the mask and moving the box.
``key`` is what makes two requests the same request -- the field, the box
(or ``"image"`` for the whole field), every setting a model reads and,
for a region, whether cut objects are left out -- and it is what a click
is matched to its result by.
``otsu_classes`` and ``otsu_foreground_class`` snapshot Multi-Otsu's
class count and selected zero-based intensity band. The default count
of 2 preserves legacy callers; Multi-Otsu enforces at least 3 classes.
A None foreground class selects the brightest band. Both fields are
appended to the request and settings key to preserve existing positions.
``detection_percentiles`` is None for raw detector input or the saved
whole-field (low, high) display percentiles applied before cropping.
It is separate from model normalization and is also part of the key.
"""
key: tuple
crop: np.ndarray
box: tuple
shape: tuple
mode: str
sensitivity: float
bright: bool
min_area: int
model_name: str
diameter: int
colour: tuple
#: Whether the objects the box's own edge cuts are left out of the answer.
exclude_border: bool = True
#: ``region`` for the box under the mouse, ``image`` for the whole field.
scope: str = "region"
#: The Object detection settings' flow threshold, cell-probability
#: threshold and normalization, which the models read.
flow_threshold: float = FLOW_THRESHOLD
cellprob_threshold: float = CELLPROB_THRESHOLD
normalize: bool = True
#: What Otsu's level is multiplied by in the Otsu mode.
otsu_correction: float = 1.0
#: Gaussian sigma the Otsu mode smooths the region with before it cuts.
otsu_smoothing: float = OTSU_SMOOTHING
#: Whether the Otsu mode closes the holes inside what it thresholded.
otsu_fill_holes: bool = True
#: Whether the Otsu mode cuts a blob with two centres into two objects.
otsu_split: bool = True
#: Whether :attr:`crop` was inverted before it was copied in. It is
#: carried on the request, though nothing downstream
#: reads it, because it is part of what makes two requests the same
#: request: the same box under the same settings with Invert on and off
#: are two different questions with two different answers.
invert: bool = False
#: The pre- and post-detection chain (:mod:`spacr.qt.detect_chain`).
#: Applied by :func:`_segment_region` on the WORKER thread: a non-local
#: means over a whole field is minutes, and item 407's progress and
#: Cancel are only worth having if the slow part is behind them.
chain: Any = detect_chain.NO_CHAIN
#: Every organelle method's parameters
#: (:class:`spacr.qt.organelle_modes.MethodParams`). All of them
#: whichever mode is chosen, for the reason every other setting is
#: here: a mode that cannot run hands the request to another, which
#: must find its own settings in it.
method_params: Any = organelle_modes.DEFAULT_PARAMS
#: The CPU modes' parameters
#: (:class:`spacr.qt.cpu_modes.CpuParams`) -- Sauvola's and Niblack's
#: k, and everything Maxima + propagate reads.
cpu_params: Any = cpu_modes.DEFAULT_PARAMS
#: The local window, in pixels, that Sauvola and Niblack measure in.
#: The Otsu category's own "Local window", read by them too.
otsu_window: int = OTSU_LOCAL_WINDOW
#: The Overlap rule the box is to draw its promise in, and the mask it
#: is to be read against: the pixels the mask already owns inside
#: ``box``, and a number that changes whenever the canvas is handed a
#: different mask. NOT PART OF ``key``, because neither changes what the
#: model is asked -- a rule and a mask edit change what the ANSWER
#: means, which is why the worker is given both and
#: :meth:`_LiveMagnifier.refresh` compares them beside the key.
overlap: str = "replace"
occupied: Optional[np.ndarray] = None
mask_token: int = 0
#: A whole-image run's :class:`_RunTicket`: how far the model has got
#: and whether the run is still wanted. None for a region, which is
#: small enough to finish. Not part of ``key``, for the same reason.
ticket: Any = None
otsu_classes: int = 2
otsu_foreground_class: Optional[int] = None
detection_percentiles: Optional[tuple] = None
primary_token: tuple = ()
primary_labels: Optional[np.ndarray] = None
primary_provenance: Optional[dict] = None
class _RunCancelled(Exception):
"""A whole-image run was stopped, between two of the model's tiles."""
class _RunTicket:
"""How far one whole-image run has got, and whether it is still wanted.
A MODEL CALL COULD NOT BE STOPPED, and on a CPU that was the difference
between a tool and a trap. Cellpose-SAM on a CPU takes most of an hour
for one 2,000 px field, and Cancel could only throw the answer
away once it arrived: the model went on holding the processor, and the
shared model lock with it, for the rest of those minutes. Cellpose runs
a field as a series of 256 px tiles, one network call each, so the run
CAN be asked between two of them -- this is what it is asked.
Shared between the GUI thread, which reads the progress and cancels,
and the worker, which counts and checks; every field is one assignment
or one Event, so neither side takes a lock.
:param total: how many tiles the run is expected to take, when known.
"""
def __init__(self, total: int = 0):
"""A ticket for a run that has not started a tile yet."""
self._stop = threading.Event()
#: Tiles the model has STARTED, and how many it will run in all
#: (0 while unknown).
self.done = 0
self.total = int(total)
#: ``time.monotonic()`` when the first and the latest tile started.
self.first_at: Optional[float] = None
self.last_at: Optional[float] = None
def cancel(self) -> None:
"""Ask the run to stop at its next tile."""
self._stop.set()
def cancelled(self) -> bool:
"""Whether the run has been asked to stop."""
return self._stop.is_set()
def check(self) -> None:
"""Raise :class:`_RunCancelled` if the run has been asked to stop."""
if self._stop.is_set():
raise _RunCancelled()
def step(self, tiles: int = 1) -> None:
"""Count ``tiles`` starting now, unless the run is to stop instead.
:raises _RunCancelled: when :meth:`cancel` was called.
"""
self.check()
now = time.monotonic()
if self.first_at is None:
self.first_at = now
self.last_at = now
self.done += max(1, int(tiles))
def remaining_seconds(self) -> Optional[float]:
"""How long the tiles still to run will take, from this run's pace.
MEASURED ON THIS RUN, so the first run of a session has an estimate
too -- the one run that most needs one, on a CPU, and the one the
per-megapixel pace (:meth:`_LiveMagnifier.remaining_seconds`) has to
stay silent through. The pace is the time between the first tile
and the latest one, so the model's load, which comes before the
first, is not counted as tile time.
:returns: seconds, or None before two tiles have started, when the
total is unknown, or once the last tile is under way -- what
follows it, turning the network's output into objects, is not
made of tiles and is not guessed at.
"""
done, total = self.done, self.total
first, last = self.first_at, self.last_at
if total <= 0 or done < 2 or first is None or last is None \
or done >= total:
return None
pace = (last - first) / (done - 1)
left = pace * (total - done + 1) - (time.monotonic() - last)
return max(0.0, left)
def _cellpose_tile_count(shape, diameter: int = 0, bsize: int = 256,
tile_overlap: float = 0.1) -> int:
"""How many network calls Cellpose makes for one field of ``shape``.
``cellpose.core.run_net``'s own arithmetic, for one 2-D image: the
field is rescaled by ``30 / diameter`` when a diameter is set, padded
by ``cellpose.transforms.get_pad_yx``, and cut into overlapping tiles of
``bsize``. Cellpose-SAM's ``bsize`` is 256 and cannot be changed. Used
only to say how far a run has got; a Cellpose that tiles differently
makes the bar wrong and nothing else, and the bar never claims more than
it has counted (:meth:`_RunTicket.remaining_seconds`).
:param shape: the field's ``(height, width, ...)``.
:param diameter: the Object detection diameter; 0 for none.
:returns: the number of tiles, at least 1.
"""
height, width = int(shape[0]), int(shape[1])
if diameter and int(diameter) > 0:
rescale = 30.0 / float(diameter)
height, width = int(height * rescale), int(width * rescale)
try:
from cellpose.transforms import get_pad_yx
ypad1, ypad2, xpad1, xpad2 = get_pad_yx(
height, width, min_size=(bsize, bsize))
except Exception: # noqa: BLE001
ypad1 = ypad2 = xpad1 = xpad2 = 0
padded_y = height + ypad1 + ypad2
padded_x = width + xpad1 + xpad2
ny = 1 if padded_y <= bsize else int(
math.ceil((1.0 + 2 * tile_overlap) * padded_y / bsize))
nx = 1 if padded_x <= bsize else int(
math.ceil((1.0 + 2 * tile_overlap) * padded_x / bsize))
return max(1, ny * nx)
def _counting_tiles(model, ticket):
"""Count ``model``'s network calls on ``ticket``, and stop on a cancel.
A forward PRE-hook on the model's network: it runs before each tile, so
a cancelled run stops before spending another tile on an answer nobody
wants, and the exception it raises leaves ``eval`` the way any error
would -- the model lock is released by its own ``with``.
:returns: the hook's handle, to ``remove()`` afterwards, or None when
there is no ticket or the model has no torch network to hook -- a
stand-in, or a backend that runs out of process.
"""
net = getattr(model, "net", None)
register = getattr(net, "register_forward_pre_hook", None)
if ticket is None or not callable(register):
return None
def before_a_tile(_module, inputs):
"""Count this call's tiles, or stop the run here."""
batch = inputs[0] if inputs else None
ticket.step(int(getattr(batch, "shape", (1,))[0] or 1))
return register(before_a_tile)
#: Everything a model reads, in the order :meth:`_LiveMagnifier._model_settings`
#: gives it and a request key carries it after ``(field, box)``. The mode is
#: first, which is what a key's ``[2]`` is read as. ADDING ONE GOES AT THE
#: END, beside the same name in :meth:`_LiveMagnifier._model_settings`: a
#: request key is this tuple positionally, and an insertion in the middle
#: would make every key already cached mean something else.
def _cellpose3_auto_diameter_note() -> str:
"""Why a Cellpose 3 run with Diameter 0 over a whole field is slow.
Measured for item 507 on a real 1994 x 1994 Toxoplasma field: Cellpose 3
estimated 16 px for vacuoles whose median is 44 px, rescaled the field
by 30/16 and took 153 s on the CPU, matching 7 of 33 objects; Diameter
44 took 12 s and matched 24.
"""
from ..i18n import tr
return tr(
"Cellpose 3 with Diameter 0 first estimates the object size and "
"rescales the whole field to it. On a 1994 x 1994 Toxoplasma field on "
"the CPU that took 153 s and guessed 16 px for vacuoles of 44 px; with "
"Diameter 44 it took 12 s. Set Diameter to the objects' size in pixels "
"to skip the estimate.")
def _diameter_zero_estimates(model) -> bool:
"""Whether Diameter 0 makes ``model`` estimate the object size first.
Decided by the route each model setting runs, not by its spelling, and
checked against the code on each route (2026-09-26). A named Cellpose 3
model -- ``cellpose3:cyto3``, ``cyto2``, ``cyto``, ``nuclei``, or a bare
``cellpose3:``, which runs cyto3 -- is a ``models.Cellpose`` whose size
model estimates the diameter when it is 0. Every other Cellpose route
has no size model and no estimate: a ``cellpose3:`` checkpoint runs
``CellposeModel`` at the diameter it was trained at, and
``cellpose_dino:`` checkpoints and Cellpose-SAM run Cellpose 4, whose
``eval`` leaves the field at its own scale when the diameter is 0.
:param model: a model setting or magnifier mode, e.g. ``"cellpose3:cyto3"``.
:returns: True when a whole-field run with Diameter 0 pays for the
estimate, so :func:`_cellpose3_auto_diameter_note` is true of it.
"""
from ..._segmentation_backends import _CELLPOSE3_MODELS, _cellpose3_choice
chosen = _cellpose3_choice(model)
return chosen is not None and (chosen or "cyto3") in _CELLPOSE3_MODELS
def _magnifier_route_model(values) -> str:
"""The model a magnifier request really runs, for :func:`_diameter_zero_estimates`.
A backend mode names its model itself; the ``cellpose`` mode runs the
Object detection model setting, which may be a Cellpose 3 one chosen
from the model zoo.
:param values: the request's settings by :data:`_MODEL_SETTING_FIELDS`.
"""
mode = canonical_magnifier_mode(values.get("mode"))
return str(values.get("model_name") or "") if mode == "cellpose" else mode
_MODEL_SETTING_FIELDS = ("mode", "sensitivity", "bright", "min_area",
"model_name", "diameter", "flow_threshold",
"cellprob_threshold", "normalize", "otsu_correction",
"otsu_smoothing", "otsu_fill_holes", "otsu_split",
"invert", "chain", "method_params", "cpu_params",
"otsu_window", "otsu_classes", "otsu_foreground_class",
"detection_percentiles", "primary_token")
class _MagnifierResult(NamedTuple):
"""The objects found for one request, in that request's CROP coordinates.
``labels`` is exactly what the box outlines and exactly what a click
commits: unless the request keeps them, it has already lost the objects
the box edge cut (:func:`spacr.qt.mask_engine._drop_cut_objects`). A
whole-image request's crop is the whole field, so nothing in it is cut.
``mode`` is the mode that actually ran and ``note`` says why when that is
not the one asked for; ``overlay`` is the RGBA picture of the outlines
-- for a whole-image result, of the whole field, which the box slices
rather than outlining its part of the field again on every move;
``count`` is how many objects ``labels`` holds.
``ghost`` is ``overlay`` again with the pixels the request's Overlap
rule would NOT add faded to a quarter of their alpha -- what the box
draws, when the rule takes something away. It is built beside the
outlines on the worker thread and is None when there is nothing to fade:
under Replace, over an empty mask, or when the rule keeps everything.
See :func:`_ghosted_overlay`.
"""
request: _MagnifierRequest
labels: np.ndarray
mode: str
note: str
overlay: Optional[np.ndarray]
count: int = 0
ghost: Optional[np.ndarray] = None
#: A whole-image result's ``scipy.ndimage.find_objects`` of ``labels``:
#: every object's bounding box, from one pass on the worker. What the
#: Overlap rule's promise for one object is computed over, since the
#: rule's answer for an object depends on all of it and not on the
#: part the box shows. See :func:`_object_window`.
extents: Optional[tuple] = None
def nbytes(self) -> int:
"""What this result holds in memory: its labels and its pictures."""
return int(sum(np.asarray(part).nbytes
for part in (self.labels, self.overlay, self.ghost)
if part is not None))
def _cellpose_segmenter(request: _MagnifierRequest, load_model=None):
"""Segment the region with the Object detection settings the screen has.
ONE SOURCE OF TRUTH: the model, the flow threshold, the
cell-probability threshold, the diameter and the normalization are the
Object detection category's, exactly as the detect button passes them, so
the box and the button cannot disagree about what Cellpose was asked. The
magnifier's own sensitivity is the Otsu mode's and is not read here.
A WHOLE-IMAGE RUN COUNTS ITS TILES AND CAN BE STOPPED between two of
them, through the request's :class:`_RunTicket`: on a CPU that run is
minutes long, and a Cancel that could only discard the answer left the
processor and the model lock taken until it arrived.
"""
loader = load_model or load_cellpose_model
ticket = request.ticket
if ticket is not None:
ticket.check()
ticket.total = _cellpose_tile_count(request.crop.shape,
int(request.diameter))
with _CELLPOSE_LOCK:
if ticket is not None:
ticket.check()
model = loader(request.model_name)
hook = _counting_tiles(model, ticket)
try:
labels, _cellprob, _flow = cellpose_detect(
request.crop, model,
diameter=int(request.diameter),
normalize=bool(request.normalize),
flow_threshold=float(request.flow_threshold),
cellprob_threshold=float(request.cellprob_threshold),
min_size=int(request.min_area),
)
finally:
if hook is not None:
hook.remove()
if ticket is not None:
ticket.check()
return labels
#: The optional models the magnifier runs through
#: :mod:`spacr._segmentation_backends`: ``mode -> (backend, the label the
#: Mode box shows)``. A Cellpose 3 mode names its model after the colon.
#: Each is always listed, greyed until its backend is installed, and
#: choosing a greyed one offers the install -- into an environment of its
#: own rather than into spaCR's, so a backend's dependencies cannot break
#: spaCR's.
_MAGNIFIER_BACKENDS = {
"cellpose3:cyto3": ("cellpose3", "Cellpose 3 · cyto3"),
"cellpose3:cyto2": ("cellpose3", "Cellpose 3 · cyto2"),
"cellpose3:cyto": ("cellpose3", "Cellpose 3 · cyto"),
"cellpose3:nuclei": ("cellpose3", "Cellpose 3 · nuclei"),
"dinocell": ("dinocell", "DINOCell"),
"samcell": ("samcell", "SAMCell"),
}
def _offer_cellpose_dino_modes() -> List[str]:
"""Make each downloaded Cellpose-DINO model a magnifier mode (item 525).
Unlike Cellpose 3's four stock models, a Cellpose-DINO model is a
checkpoint the user downloaded, so its modes are found when the Mode box
is built rather than written here: ``cellpose_dino:<path>`` joins
:data:`_MAGNIFIER_BACKENDS` under its backend and caption, and
:data:`_MAGNIFIER_SEGMENTERS` with :func:`_backend_segmenter`, which
loads it through :func:`_backend_model`. Greying and the install offer
then treat it as every other backend mode.
:returns: the modes added, in the zoo's order.
"""
added = []
for mode, label in _zoo_cellpose_dino_models():
if mode in _MAGNIFIER_BACKENDS:
continue
_MAGNIFIER_BACKENDS[mode] = ("cellpose_dino", label)
_MAGNIFIER_SEGMENTERS[mode] = _backend_segmenter
added.append(mode)
return added
def _offer_prefixed_modes() -> List[str]:
"""Make each StarDist, InstanSeg and Omnipose model a magnifier mode.
Items 551-553. ``<prefix><model>`` joins :data:`_MAGNIFIER_BACKENDS`
under its backend and the caption "<backend> · <model>", and
:data:`_MAGNIFIER_SEGMENTERS` with :func:`_backend_segmenter`, whose
:func:`_backend_model` loads it. Every model is listed whether or not
its backend is installed; greying and the install offer are the
per-backend code every backend mode already has. A model the alpha
gate hides (:func:`_prefixed_alpha_hidden`) is not listed, and one
listed before Show alpha features was turned off is taken out again
the next time the Mode box is built.
:returns: the modes added, in the backends' order.
"""
from ..i18n import tr
from ..._segmentation_backends import (_SPECS, _prefixed_names,
_prefixed_value)
added = []
for name in _prefixed_names():
spec = _SPECS[name]
for model in spec.models:
mode = _prefixed_value(name, model)
if _prefixed_alpha_hidden(name, model):
_MAGNIFIER_BACKENDS.pop(mode, None)
_MAGNIFIER_SEGMENTERS.pop(mode, None)
continue
if mode in _MAGNIFIER_BACKENDS:
continue
_MAGNIFIER_BACKENDS[mode] = (name, tr(
"{backend} · {model}", backend=spec.label, model=model))
_MAGNIFIER_SEGMENTERS[mode] = _backend_segmenter
added.append(mode)
return added
#: Loaded DINOCell and SAMCell models, by backend name, for the life of the
#: process. Building one loads a ViT checkpoint, and the box asks on every
#: move; a backend that fails to build is not kept, so it is tried again.
_BACKEND_MODELS: dict = {}
#: ``mode -> the environment folder the cached model was built against``, for
#: the modes whose backend really had one. See :func:`_backend_model`.
_BACKEND_MODEL_ENVS: dict = {}
def _state_ready(backend: str) -> bool:
"""Whether ``backend``'s environment is here and can segment now.
File checks only -- no import, no subprocess -- so the Mode box can ask
while it is built and a cached model can be checked before it is used.
"""
from ... import _segmentation_backends
try:
return _segmentation_backends._backend_state(backend).ready
except (OSError, ValueError):
return False
def _backend_model(name: str):
"""The backend model a magnifier mode names, built once.
Built by :func:`spacr._segmentation_backends._load_backend`, the same
loader the mask pipeline's ``segmentation_backend`` setting uses. A
``cellpose3:<model>`` mode loads that Cellpose 3 model.
A CACHED MODEL IS CHECKED AGAINST THE FOLDER IT WAS BUILT FROM.
Uninstalling the backend from the Model Zoo while Make Masks is open left
this dictionary holding a model whose environment had been deleted;
asking it to segment then tried to start a Python that is no longer
there, and the magnifier reported a missing file instead of a missing
backend. The folder, and not the backend's state, because a model built
without one -- a stand-in, or a backend that lives in spaCR's own
environment -- was never on disk to lose.
:raises ImportError: naming the Model Zoo, when the backend is missing.
"""
with _CELLPOSE_LOCK:
from ... import _segmentation_backends
backend, _colon, model_name = str(name).partition(":")
model = _BACKEND_MODELS.get(name)
env = _BACKEND_MODEL_ENVS.get(name)
if model is not None and env and not os.path.isdir(env):
_BACKEND_MODELS.pop(name, None)
_BACKEND_MODEL_ENVS.pop(name, None)
model = None
if model is None:
model = _segmentation_backends._load_backend(
backend, model_name=model_name or None)
_BACKEND_MODELS[name] = model
_BACKEND_MODEL_ENVS[name] = _model_env(backend)
return model
def _model_env(backend: str) -> str:
"""The folder a model of ``backend`` was just built from, or ``''``.
Empty for a backend that is not installed in one of its own -- a
stand-in, or one an older spaCR put in spaCR's own environment -- which
is a model :func:`_backend_model` must not go on to drop.
"""
from ... import _segmentation_backends
try:
state = _segmentation_backends._backend_state(backend)
except (OSError, ValueError):
return ""
return state.env if state.ready and not state.in_process else ""
def _backend_ready(mode: str) -> bool:
"""Whether the backend a magnifier mode needs can segment now."""
return _state_ready(_MAGNIFIER_BACKENDS[mode][0])
def _backend_segmenter(request: _MagnifierRequest, load_model=None):
"""Segment the region with Cellpose 3, DINOCell or SAMCell.
A backend answers ``CellposeModel.eval``'s own call, so the region goes
through :func:`cellpose_detect` with the Object detection settings as
Cellpose's does: Cellpose 3 reads both thresholds and the diameter (0
lets its size model estimate it), DINOCell reads the cell-probability
threshold (through the logistic function, so 0 is its own 0.5) and
SAMCell uses its own thresholds. Each runs in its own environment, out
of process. ``load_model`` is the CELLPOSE loader and is not used: a
backend name handed to it would load stock cpsam without a word.
"""
with _CELLPOSE_LOCK:
model = _backend_model(request.mode)
labels, _cellprob, _flow = cellpose_detect(
request.crop, model,
diameter=int(request.diameter),
normalize=bool(request.normalize),
flow_threshold=float(request.flow_threshold),
cellprob_threshold=float(request.cellprob_threshold),
min_size=int(request.min_area),
)
return labels
def _cellpose_installed() -> bool:
"""Whether Cellpose can be imported here, WITHOUT importing it.
:func:`importlib.util.find_spec`, so a package that pulls in torch is
located and not loaded. A spec lookup that raises counts as absent.
"""
try:
return find_spec("cellpose") is not None
except (ImportError, ValueError):
return False
def _threshold_label(name: str) -> str:
"""The caption a threshold algorithm goes under in a box."""
if name == "otsu":
return "Otsu"
return cpu_modes.THRESHOLD_LABELS.get(name, str(name).title())
def _threshold_segmenter(request: _MagnifierRequest, load_model=None):
"""Cut the region at the level the chosen ALGORITHM finds.
Otsu, Li's minimum cross entropy, Yen, Triangle, IsoData, Mean,
Minimum, Multi-Otsu, Sauvola and Niblack all arrive here, and the mode
name maps to the algorithm name
(:func:`spacr.qt.cpu_modes.engine_algorithm`). Every named threshold
except plain Otsu uses :func:`spacr.qt.mask_engine._otsu_instances`,
the same engine as the detect button. The request supplies smoothing,
correction, polarity, hole filling, splitting and minimum area. Only
Multi-Otsu reads its class count and band; Sauvola and Niblack read
window and local k. None of these modes reads magnifier Sensitivity
or applies the legacy crop stretch, opening or noise-floor fallback.
Thresholds are estimated from the requested pixels, so different
crops can still yield different results.
Plain Otsu keeps :func:`spacr.qt.mask_engine._classical_region_labels`
and its existing sensitivity/noise-floor behavior.
"""
mode = canonical_magnifier_mode(request.mode)
params = request.cpu_params
if mode in cpu_modes.THRESHOLD_LABELS:
multi = mode == cpu_modes.MULTIOTSU
return engine._otsu_instances(
request.crop, bright=request.bright, min_area=request.min_area,
correction=request.otsu_correction,
smoothing=request.otsu_smoothing,
fill_holes=request.otsu_fill_holes,
split_touching=request.otsu_split,
algorithm=cpu_modes.engine_algorithm(mode),
classes=max(3, int(request.otsu_classes)) if multi else 2,
foreground_class=request.otsu_foreground_class if multi else None,
window=int(request.otsu_window), local_k=float(params.local_k))
return engine._classical_region_labels(
request.crop, sensitivity=request.sensitivity,
bright=request.bright, min_area=request.min_area,
correction=request.otsu_correction,
smoothing=request.otsu_smoothing,
fill_holes=request.otsu_fill_holes,
split_touching=request.otsu_split,
algorithm=cpu_modes.engine_algorithm(mode),
window=int(request.otsu_window),
local_k=float(params.local_k))
def _propagate_segmenter(request: _MagnifierRequest, load_model=None):
"""Grow one object out of each bright centre of the region.
Seeded watershed on inverted intensity; see
:func:`spacr.qt.mask_engine.maxima_propagate_instances`. How many
centres were found is carried back on the request's ticket-free path
through :data:`_LAST_PROPAGATE_SEEDS`, because it is the number a
curator tunes the settings against and the labels alone do not show
it: an object that never grew and a centre that was never found look
the same.
"""
ticket = request.ticket
if ticket is not None:
ticket.check()
found = cpu_modes.propagate(
request.crop, request.cpu_params, min_area=int(request.min_area),
fill_holes=bool(request.otsu_fill_holes))
_LAST_PROPAGATE_SEEDS[request.scope] = (found.seeds, found.level)
if ticket is not None:
ticket.check()
return found.labels
def _puncta_segmenter(request: _MagnifierRequest, load_model=None):
"""Detect spots inside the copied parent mask, without an Otsu fallback."""
if request.primary_labels is None:
from ..i18n import tr
raise ValueError(tr("Load a parent mask before detecting puncta."))
return cpu_modes.puncta(request.crop, request.primary_labels,
request.cpu_params)
def _secondary_segmenter(request: _MagnifierRequest, load_model=None):
"""Grow the request's copied primary labels without discovering new seeds."""
if request.primary_labels is None:
raise ValueError("Choose and load a primary mask before growing secondary objects.")
return cpu_modes.secondary(
request.crop, request.primary_labels, request.cpu_params,
min_area=request.min_area, fill_holes=request.otsu_fill_holes).labels
#: ``scope -> (centres found, the level they were grown to)`` for the last
#: propagation of each scope. Read by the screen's status line. A plain
#: dict and not a signal: it is written on the worker and read on the GUI
#: thread right after the result arrives, and a stale entry can only say a
#: number about a run that has just been superseded.
_LAST_PROPAGATE_SEEDS: dict = {}
def _organelle_segmenter(request: _MagnifierRequest, load_model=None):
"""Segment the region with one of organelle detection's own methods.
Adaptive, LoG, DoG, ridge, hysteresis and U-Net reach this one
function, and it reaches :mod:`spacr.qt.organelle_modes`, which reaches
:func:`spacr.object._segment_single_image` -- the routine the organelle
mask pipeline's own workers call. THERE IS NO SECOND COPY of any of
these methods: a block size tuned on the box under the mouse is the
block size a mask run will read, and a method that is fixed in the
engine is fixed here on the same day.
``load_model`` is the CELLPOSE loader and is not used; a U-Net is
loaded from the path the U-Net parameters name.
A WHOLE-IMAGE RUN CAN BE CANCELLED BETWEEN STEPS AND NOT INSIDE ONE.
:class:`_RunTicket`'s progress is Cellpose's tiles, counted by a
forward hook on its network (:func:`_counting_tiles`); a scikit-image
filter has no tiles, so the bar stays indeterminate and Cancel is
honoured at the boundaries -- before the chain, before the detector,
after it. That is the honest bar rather than a thin one: these runs are
seconds on a field where Cellpose-SAM on a CPU is most of an hour. The
exception a curator can feel is a heavy chain step over a whole field,
which :func:`spacr.qt.detect_chain.heavy_steps` warns about before it
is switched on.
"""
ticket = request.ticket
if ticket is not None:
ticket.check()
labels = organelle_modes.segment(
request.crop, canonical_magnifier_mode(request.mode),
request.method_params, min_area=int(request.min_area))
if ticket is not None:
ticket.check()
return labels
#: ``mode -> segmenter``, in the order the Mode box offers them. A segmenter
#: takes ``(request, load_model)`` and returns labels shaped like
#: ``request.crop``. ADDING A MODEL IS ONE FUNCTION AND ONE LINE HERE, and
#: :func:`_segment_region` gives it the Otsu fallback for nothing.
_MAGNIFIER_SEGMENTERS = {
"otsu": _threshold_segmenter,
**{mode: _threshold_segmenter for mode in cpu_modes.threshold_modes()},
cpu_modes.PROPAGATE: _propagate_segmenter,
cpu_modes.SECONDARY: _secondary_segmenter,
cpu_modes.PUNCTA: _puncta_segmenter,
**{mode: _organelle_segmenter for mode in organelle_modes.modes()},
"cellpose": _cellpose_segmenter,
**{mode: _backend_segmenter for mode in _MAGNIFIER_BACKENDS},
}
#: Mode names this screen used to carry, and what they are called now. The
#: magnifier's threshold mode was once called ``classical`` and is now
#: ``otsu``; a mode name reaches this module from
#: a saved session, a script or a test, so the old one still arrives and is
#: translated rather than refused.
_MAGNIFIER_MODE_ALIASES = {"classical": "otsu"}
[docs]
def canonical_magnifier_mode(mode) -> str:
"""The name a magnifier mode goes under today.
:param mode: a mode name, possibly one this screen has renamed.
:returns: the current name; an unknown mode is handed back unchanged, so
:func:`_segment_region` can still say it does not know it.
"""
name = str(mode or "otsu")
return _MAGNIFIER_MODE_ALIASES.get(name, name)
#: The modes the Mode box builds itself, as ``mode -> its caption``: Otsu,
#: organelle detection's own methods (:mod:`spacr.qt.organelle_modes`) and
#: Cellpose. The rest are named by :data:`_MAGNIFIER_BACKENDS`.
_MAGNIFIER_MODE_LABELS = {"otsu": "Otsu",
**cpu_modes.MODE_LABELS,
**organelle_modes.MODE_LABELS,
"cellpose": "Cellpose"}
def _magnifier_mode_label(mode: str) -> str:
"""The caption the Mode box shows for ``mode``, translated.
A status line that names a mode has to name it the way the box the user
picked it from does. A mode under its old name is named under the new
one, through :func:`canonical_magnifier_mode`; an unknown mode is handed
back unchanged rather than hidden, so a key that has lost its caption
still reads as itself.
:param mode: a key of :data:`_MAGNIFIER_SEGMENTERS`, or one this screen
has renamed.
:returns: the caption, in the current language.
"""
from ..i18n import tr
key = canonical_magnifier_mode(mode)
name = _MAGNIFIER_MODE_LABELS.get(key)
if name is None:
backend = _MAGNIFIER_BACKENDS.get(key)
name = backend[1] if backend is not None else str(mode)
return tr(name)
def _updating_caption(name: str) -> str:
"""The magnifier's "Updating <model>…" mark, in the current language.
A language whose catalog has no row for the named form yet keeps its
reviewed "Updating…" and names the model after it, rather than showing
English words in a translated window.
:param name: what is running, from :meth:`running_name`.
:returns: the caption.
"""
from ..i18n import current_language, tr
template = "Updating {name}…"
translated = tr("Updating {name}…", name=name)
if (current_language() or "en").startswith("en") or \
translated != template.format(name=name):
return translated
return f"{tr('Updating…')} {name}"
def _segment_region(request: _MagnifierRequest, load_model=None) -> tuple:
"""Run the request's mode, falling back to Otsu when it cannot run.
The fallback is why the magnifier never simply does nothing: a model
whose import or weights fail answers with the Otsu mode's objects
and a note saying why, rather than with an empty box.
:param request: the region and its settings.
:param load_model: ``name -> model`` for modes that load one; called on
the worker thread.
:returns: ``(labels, mode_used, note)``; ``note`` is empty unless the mode
asked for could not run.
Classical CPU detection errors are reported to the caller without
substituting Otsu. For example, Minimum may not find two histogram
maxima and Multi-Otsu may have too few distinct intensities. Neither
failure means the method is unavailable; a later crop can try again.
"""
if request.ticket is not None:
request.ticket.check()
if canonical_magnifier_mode(request.mode) == cpu_modes.PUNCTA:
labels = _puncta_segmenter(request, load_model)
if request.ticket is not None:
request.ticket.check()
return labels, cpu_modes.PUNCTA, ""
chain = request.chain or detect_chain.NO_CHAIN
prepared = detect_chain.prepare(
request.crop, chain,
cancel=request.ticket.cancelled if request.ticket is not None else None)
if prepared is not request.crop:
request = request._replace(crop=prepared)
if request.ticket is not None:
request.ticket.check()
mode = canonical_magnifier_mode(request.mode)
segmenter = _MAGNIFIER_SEGMENTERS.get(mode)
note = ""
if mode == cpu_modes.SECONDARY:
labels = segmenter(request, load_model)
if request.ticket is not None:
request.ticket.check()
return labels, mode, ""
if segmenter is None:
note = f"no magnifier mode is called {request.mode!r}"
elif (mode in cpu_modes.THRESHOLD_LABELS
or mode in organelle_modes.MODE_LABELS and mode != "unet"):
labels = segmenter(request, load_model)
return _finished_labels(labels, chain, prepared), mode, ""
elif mode != "otsu":
try:
labels = segmenter(request, load_model)
return _finished_labels(labels, chain, prepared), mode, ""
except _RunCancelled:
raise
except Exception as exc: # noqa: BLE001
LOG.warning("magnifier mode %s could not run; using Otsu",
request.mode, exc_info=True)
note = f"{type(exc).__name__}: {exc}"
return (_threshold_segmenter(request._replace(mode="otsu"), load_model),
"otsu", note)
def _finished_labels(labels, chain, image):
"""The chain's last two stages, for every mode but Otsu.
THE OTSU MODE IS LEFT ALONE and keeps the "Fill holes inside an object"
and "Split objects that touch" of its own category, which it has always
had. The chain's morphology and split would arrive on top of them, and
the first thing either does is read the detection back as ONE
foreground -- so a pair Otsu had just cut apart would be joined again
before being cut a second time. The chain's split is there to give the
other threshold methods what Otsu already has, not to give Otsu it
twice.
:param labels: what the mode found.
:param chain: the request's chain.
:param image: the image the mode read, as the split's landscape.
"""
return detect_chain.finish(labels, chain, intensity=image)
def _candidate_overlay(labels: np.ndarray, colour) -> np.ndarray:
"""The objects found, as RGBA: a faint fill inside a solid outline."""
from skimage.segmentation import find_boundaries
lab = np.asarray(labels)
rgba = np.zeros(lab.shape + (4,), dtype=np.uint8)
inside = lab > 0
if inside.any():
red, green, blue = (int(c) for c in tuple(colour)[:3])
rgba[inside] = (red, green, blue, 60)
rgba[find_boundaries(lab, mode="inner") & inside] = (
red, green, blue, 255)
return rgba
#: How many pixels of a box the contrast stretch may look at. The stretch
#: is two percentiles, and a percentile over four megapixels is most of what
#: a move at the largest box used to cost; over a grid of at most this many
#: of them the two levels are the same to a fraction of a step, and the box
#: goes on being stretched over the WHOLE region it covers rather than over
#: whichever corner of it is on screen.
_STRETCH_SAMPLES = 262144
def _stretch_for_box(image: np.ndarray, box, part,
lower_pct: float, upper_pct: float) -> np.ndarray:
"""``part`` of ``box``, contrast-stretched over all of ``box``'s levels.
:func:`spacr.qt.mask_engine.normalize_uint16` reads the levels from the
pixels it is handed and rescales the same pixels; the box needs the two
halves of that separated, because what it must stretch over is the
region it magnifies and what it must PAY FOR is the part of that region
a user can see. At the default box the two are one thing and this
returns exactly what that function would (``part`` is the whole box and
the sample is every pixel of it); at the largest box allowed,
with most of the lens off the canvas, it is the difference between a
move a user feels and one nobody can.
:param image: the field.
:param box: ``(x0, y0, x1, y1)`` the box magnifies, in image pixels.
:param part: ``(x0, y0, x1, y1)`` of it to return, inside ``box``.
:param lower_pct: the percentile that becomes black.
:param upper_pct: the percentile that becomes white.
"""
x0, y0, x1, y1 = (int(v) for v in box)
whole = image[y0:y1, x0:x1]
if not whole.size:
return np.ascontiguousarray(whole)
low, high = _box_levels(image, box, lower_pct, upper_pct)
vx0, vy0, vx1, vy1 = (int(v) for v in part)
crop = np.ascontiguousarray(image[vy0:vy1, vx0:vx1])
out = (np.clip(crop, low, high).astype(np.float64) - low) / (high - low)
return (out * float(np.iinfo(crop.dtype).max)).astype(crop.dtype)
def _box_levels(image: np.ndarray, box, lower_pct: float,
upper_pct: float) -> tuple:
"""The two levels the box is stretched between: ``(black, white)``.
Read from the WHOLE region the box magnifies, over a grid of at most
:data:`_STRETCH_SAMPLES` of its pixels -- every pixel, at a box the
window holds. :func:`_stretch_for_box` and :func:`_box_picture` both
read them here, so the two cannot stretch one box two ways.
:param image: the field.
:param box: ``(x0, y0, x1, y1)`` in image pixels; not empty.
:param lower_pct: the percentile that becomes black.
:param upper_pct: the percentile that becomes white.
"""
x0, y0, x1, y1 = (int(v) for v in box)
whole = image[y0:y1, x0:x1]
step = max(1, int(math.sqrt(whole.size / _STRETCH_SAMPLES)))
sample = whole[::step, ::step]
low, high = np.percentile(sample, [lower_pct, upper_pct])
if high <= low:
high = low + 1
return low, high
#: ``(dtype, black, white) -> the pixel each value is drawn as``, for the
#: last levels the box was drawn at. One entry: the levels change when the
#: box moves over a different region, and a table for levels nobody is
#: looking at any more is 256 KB held for nothing.
_BOX_GREY_TABLE: dict = {}
def _rgb32(rgb: np.ndarray) -> np.ndarray:
"""``(h, w, 3)`` uint8 as ``(h, w)`` ``0xFFRRGGBB`` -- Qt's own RGB32."""
rgb = np.asarray(rgb, dtype=np.uint32)
return (np.uint32(0xFF000000) | (rgb[..., 0] << 16)
| (rgb[..., 1] << 8) | rgb[..., 2]).astype(np.uint32)
def _box_grey_table(dtype, low, high) -> Optional[np.ndarray]:
"""Every value of ``dtype`` as the grey pixel the box draws it as, or None.
The box's picture is two passes that read ONE value each --
:func:`_stretch_for_box` rescales a pixel between the levels,
:func:`spacr.qt.mask_engine.overlay_mask` turns the result into 8 bits
-- so the pair is a function of the pixel's value and the levels alone,
and for a 16-bit field it is a table of 65,536 entries. Built by
running those same two formulas over every value, which is what makes a
look-up in it byte-identical to the two passes rather than close to
them; `test_the_fast_box_picture_is_the_slow_one_to_the_byte` holds
it to that.
:param dtype: the field's dtype.
:param low: the black level, from :func:`_box_levels`.
:param high: the white level.
:returns: a ``0xFFgggggg`` uint32 table indexed by value, or None for a
dtype it cannot index -- a float field, a signed one, or one wider
than 16 bits -- which then takes the two passes.
"""
dtype = np.dtype(dtype)
if dtype.kind != "u" or dtype.itemsize > 2:
return None
key = (dtype.str, float(low), float(high))
table = _BOX_GREY_TABLE.get(key)
if table is None:
values = np.arange(np.iinfo(dtype).max + 1, dtype=dtype)
out = (np.clip(values, low, high).astype(np.float64) - low) \
/ (high - low)
stretched = (out * float(np.iinfo(dtype).max)).astype(dtype)
grey = (stretched.astype(np.float32) / 256.0).clip(0, 255) \
.astype(np.uint32)
table = (np.uint32(0xFF000000) | (grey << 16) | (grey << 8)
| grey).astype(np.uint32)
_BOX_GREY_TABLE.clear()
_BOX_GREY_TABLE[key] = table
return table
def _box_picture(image: np.ndarray, box, part, mask_part: np.ndarray,
lower_pct: float, upper_pct: float,
step: int = 1) -> np.ndarray:
"""What the box draws of ``part``: stretched, in grey, with the mask over it.
EXACTLY ``overlay_mask(_stretch_for_box(...), mask, alpha=0.5)``, and
made to cost a frame at the largest box the Size box allows. That box
is as wide as the field and most of it is on the canvas: 1.4 Mpx per
move on a 2,048 px field, and the two passes spent 41.5 ms of float
arithmetic over 4.2 M channel values on it, per move, on the GUI thread
-- three frames, measured by ``tools/perf_paint.py --only magnifier``.
THE DESIGN DECISION: NOTHING IS CACHED BETWEEN MOVES. The picture could
have been built once per field, mask and levels and blitted from, but
the levels move with the box and the mask moves with every edit, so
that cache misses exactly when the user is working -- and its miss is
the whole field, a 150 ms frame of its own. Instead every move is made
cheap, and there is no first move that pays for the rest:
* the stretch and the 8-bit conversion become one look-up in a table
built for the levels (:func:`_box_grey_table`), straight into Qt's
native 32-bit pixel, so the picture is never converted again on its
way to the screen, as a 24-bit one is on every draw;
* the blend, which at ``alpha`` 0.5 is the floor of the mean of grey
and colour, is the carry-free byte average ``(a & b) + ((a ^ b) >> 1)``
over whole pixels, and only where the mask has an object.
Nothing is approximated --
`test_the_fast_box_picture_is_the_slow_one_to_the_byte`.
``step`` thins the picture when the lens shows more image pixels than
the screen has device pixels for them (a zoom below one device pixel per
image pixel): drawing unsmoothed, Qt would drop those pixels anyway, so
they are not made. At every zoom that magnifies, it is 1 and nothing is
dropped -- the box stays exactly as sharp as it was.
:param image: the field the box magnifies.
:param box: ``(x0, y0, x1, y1)`` the box covers; the levels come from it.
:param part: ``(x0, y0, x1, y1)`` of it to draw, inside ``box``.
:param mask_part: the mask's labels over ``part``.
:param lower_pct: the percentile that becomes black.
:param upper_pct: the percentile that becomes white.
:param step: take every ``step``-th pixel of ``part`` each way.
:returns: a C-contiguous ``(h, w)`` uint32 picture, ``0xFFRRGGBB``.
"""
step = max(1, int(step))
vx0, vy0, vx1, vy1 = (int(v) for v in part)
crop = image[vy0:vy1:step, vx0:vx1:step]
labels = np.asarray(mask_part)[::step, ::step]
low, high = _box_levels(image, box, lower_pct, upper_pct)
table = _box_grey_table(image.dtype, low, high)
if table is None:
stretched = _stretch_for_box(image, box, part, lower_pct, upper_pct)
return np.ascontiguousarray(_rgb32(engine.overlay_mask(
stretched[::step, ::step], labels, alpha=0.5)))
out = table[crop]
flat = np.ascontiguousarray(labels).reshape(-1)
inside = np.flatnonzero(flat != 0)
if inside.size:
top = int(flat.max())
rng = np.random.default_rng(0)
colours = rng.integers(30, 255, size=(top + 1, 3), dtype=np.uint8)
colours[0] = [0, 0, 0]
paint = _rgb32(colours)
pixels = out.reshape(-1)
grey = pixels[inside]
colour = paint[flat[inside]]
pixels[inside] = (grey & colour) + (
((grey ^ colour) >> 1) & np.uint32(0x7F7F7F))
return out
def _canonical_overlap_rule(rule) -> str:
"""The Overlap rule ``rule`` names, or ``replace`` when it names none.
A rule reaches the worker inside a request, and a request outlives the
box it was built for: a name the engine does not know must leave the
box drawing the model's own outlines rather than raise on a thread
whose exception the user would meet as an empty box.
"""
name = str(rule or "")
return name if name in engine._MAGNIFIER_OVERLAP_RULES else "replace"
def _ghosted_overlay(labels: np.ndarray, overlay: np.ndarray,
request: _MagnifierRequest) -> Optional[np.ndarray]:
"""``overlay`` with what the Overlap rule would NOT add ghosted.
The box used to outline what the MODEL found, which is not what a click
adds: the Overlap rule and Min area stand between the two, and a click
that added half an object, or nothing, had said nothing first. The
pixels the rule takes away keep a quarter of their alpha, so they read
as "found, not yours" beside the solid objects a click would commit.
RUN HERE, ON THE WORKER, AND NOT WHERE THE BOX IS DRAWN. The first
version of this was a paintEvent's work, and the rule is counted over
the box's pixels: on the largest box allowed -- the field's own
longer side -- that measured 125.5 ms per delivered result on the GUI
thread, nine frames of the interactive frame budget, for a picture the worker
could have brought with it. What it needs from the GUI thread is the
mask, and the mask is copied into the request beside the crop
(:meth:`_LiveMagnifier.build_request`), which is how everything else a
model reads gets here.
:param labels: the objects found, in crop coordinates.
:param overlay: their outlines as RGBA, from :func:`_candidate_overlay`.
:param request: the request, for its rule, its mask crop and Min area.
:returns: the picture, or None when the rule takes nothing away --
under Replace, over an empty mask, or when everything survives.
"""
occupied = request.occupied
rule = _canonical_overlap_rule(request.overlap)
if occupied is None or overlay is None or rule == "replace":
return None
exact_ids = request.mode == cpu_modes.SECONDARY
occupied = ((np.asarray(occupied) > 0) & (np.asarray(occupied) != labels)
if exact_ids else np.asarray(occupied) > 0)
if occupied.shape != np.asarray(labels).shape or not occupied.any():
return None
kept = engine._surviving_region_objects(
labels, occupied, overlap=rule, min_area=int(request.min_area),
preserve_ids=exact_ids)
lost = (np.asarray(labels) > 0) & (kept == 0)
if not lost.any():
return None
rgba = np.array(overlay, copy=True)
rgba[lost, 3] = rgba[lost, 3] // 4
return rgba
def _rgba_qimage(rgba: np.ndarray) -> QImage:
"""An owned QImage of an ``(H, W, 4)`` uint8 array."""
data = np.ascontiguousarray(rgba, dtype=np.uint8)
height, width = data.shape[:2]
return QImage(data.data, width, height, 4 * width,
QImage.Format_RGBA8888).copy()
def _object_count(labels: np.ndarray) -> int:
"""How many distinct objects a label image holds."""
return int(np.count_nonzero(np.unique(np.asarray(labels))))
def _object_extents(labels: np.ndarray) -> tuple:
"""Every object's bounding box in ``labels``, in one pass.
``scipy.ndimage.find_objects``: entry ``id - 1`` is ``(rows, cols)`` as
slices, or None for an id with no pixels.
"""
from scipy import ndimage
lab = np.asarray(labels)
if not lab.size or int(lab.max()) <= 0:
return ()
return tuple(ndimage.find_objects(lab.astype(np.int32, copy=False)))
def _object_window(result: _MagnifierResult, label: int) -> Optional[tuple]:
"""``(x0, y0, x1, y1)`` around object ``label`` of a whole-image result.
Read from :attr:`_MagnifierResult.extents` when the worker counted
them, which is every whole-image result it builds; otherwise found by a
pass over the whole field, which is what this used to cost per click.
:returns: the window, exclusive ends, or None when no pixel has the id.
"""
label = int(label)
extents = result.extents
if extents is not None and 0 < label <= len(extents):
found = extents[label - 1]
if found is None:
return None
rows, cols = found
return (int(cols.start), int(rows.start),
int(cols.stop), int(rows.stop))
body = np.asarray(result.labels) == label
rows = np.flatnonzero(body.any(axis=1))
cols = np.flatnonzero(body.any(axis=0))
if not rows.size:
return None
return (int(cols[0]), int(rows[0]), int(cols[-1]) + 1, int(rows[-1]) + 1)
def _magnifier_provenance(request: _MagnifierRequest, mode: str,
note: str = "") -> dict:
"""JSON-safe detector settings from a completed request, never the panel.
``mode`` is the algorithm that actually ran; ``request.mode`` and
``note`` preserve a model fallback. Common model controls retain their
historical keys; ``method_parameters`` holds the applicable CPU or
organelle settings. Display percentile normalization is separate from
the model's ``normalize`` option. A whole-image object pick records the
full detection box as well as its smaller paste box at the call site.
Paste-time overlap and minimum area are added by the commit handler.
"""
percentiles = request.detection_percentiles
height, width = request.shape
detail = {
"mode": mode, "requested_mode": request.mode, "fallback_note": str(note),
"scope": request.scope,
"detection_box": ([0, 0, int(width), int(height)] if request.scope == "image"
else [int(v) for v in request.box]),
"sensitivity": float(request.sensitivity), "bright": bool(request.bright),
"min_area": int(request.min_area), "exclude_border": bool(request.exclude_border),
"invert": bool(request.invert), "model": str(request.model_name),
"flow_threshold": float(request.flow_threshold),
"cellprob_threshold": float(request.cellprob_threshold),
"diameter": int(request.diameter), "normalize": bool(request.normalize),
"otsu_correction": float(request.otsu_correction),
"detect_on_normalized": percentiles is not None,
"normalization_percentiles": (None if percentiles is None else
[float(v) for v in percentiles]),
"method_parameters": (organelle_modes.provenance(mode, request.method_params)
or cpu_modes.provenance(mode, request.cpu_params)),
}
if mode == "otsu" or mode in cpu_modes.THRESHOLD_LABELS:
multi = mode == cpu_modes.MULTIOTSU
count = max(3, int(request.otsu_classes)) if multi else 2
detail.update(
otsu_smoothing=float(request.otsu_smoothing),
otsu_fill_holes=bool(request.otsu_fill_holes),
otsu_split=bool(request.otsu_split), otsu_classes=count,
otsu_foreground_class=(count - 1 if request.otsu_foreground_class is None
else int(request.otsu_foreground_class)) if multi else 1,
otsu_local=False)
if mode in engine.LOCAL_THRESHOLDS:
detail["otsu_window"] = int(request.otsu_window)
elif mode == cpu_modes.PROPAGATE:
detail["otsu_fill_holes"] = bool(request.otsu_fill_holes)
detail.update(detect_chain.provenance(
request.chain or detect_chain.NO_CHAIN, percentile_stretch=percentiles is not None))
if mode == cpu_modes.PUNCTA:
detail['primary_source'] = request.primary_provenance
detail['detect_on_normalized'] = False
detail['normalization_percentiles'] = None
detail['invert'] = False
if mode == cpu_modes.SECONDARY:
detail['primary_source'] = request.primary_provenance
detail['preserve_ids'] = True
detail['otsu_fill_holes'] = bool(request.otsu_fill_holes)
return detail
def _single_object(result: _MagnifierResult, label: int) -> _MagnifierResult:
"""One object of a whole-image result, as a result of its own.
Cut to the object's bounding box, so a commit pastes that small window at
its corner rather than the whole field. Every pixel of the object goes
in, including any part outside the box on screen: the whole image was
segmented, so nothing cut it.
:param result: a whole-image result.
:param label: an id present in ``result.labels``.
"""
x0, y0, x1, y1 = _object_window(result, label)
body = np.asarray(result.labels)[y0:y1, x0:x1] == int(label)
labels = np.where(body, int(label), 0).astype(np.int32)
request = result.request._replace(box=(x0, y0, x1, y1))
return result._replace(request=request, labels=labels, overlay=None,
count=1, ghost=None, extents=None)
def _detect_cellpose_snapshot(request, models):
"""Prepare and segment a captured field without reading any Qt object.
Returns what :func:`cellpose_detect` returns, the labels after the
detection chain's finishing steps; with ``request['vectors']`` set, the
flow vectors come fourth.
"""
image = request['image']
if request['invert']:
image = engine.invert_normalized(image)
if request['percentiles'] is not None:
image = engine.normalize_for_detection(image, *request['percentiles'])
image = detect_chain.prepare(image, request['chain'])
with _CELLPOSE_LOCK:
name = request['model']
if name not in models:
models[name] = load_cellpose_model(name)
detect = (_cellpose_detect_with_vectors if request.get('vectors')
else cellpose_detect)
output = detect(image, models[name], **request['parameters'])
labels = detect_chain.finish(output[0], request['chain'], intensity=image)
return (labels,) + tuple(output[1:])
def _uncertainty_snapshot(request, models):
"""Segment captured fields under flips and a turn, and score the spread.
Each field is segmented once per test-time transform of
:func:`spacr.active_learning._tta_passes` with the detection settings
captured in ``request['detect']``, keeping Cellpose's cell probability
and flow vectors, or with ``request['segment']`` when one is given, and
the passes are scored by
:func:`spacr.active_learning._segmentation_uncertainty`: disagreement
area plus near misses per field, disagreement or flow error per
object. Reads no Qt object, so it runs on a worker thread.
:param request: ``kind`` is ``field`` (with ``image``) or ``rank`` (with
``fields``, ``(folder, file name)`` pairs, and ``layout``, the mask
loader's keywords).
:param models: the screen's loaded Cellpose models, shared with
detection.
:returns: for ``field``, the score dictionary with its map; for
``rank``, ``{(folder, file name): score dictionary}`` without the
maps, a field that cannot be read or segmented being left out.
"""
from ...segmentation_uncertainty import compute_uncertainty
segment = request.get('segment')
threshold = 0.0
if segment is None:
threshold = float(request['detect'].get('parameters', {}).get(
'cellprob_threshold', 0.0))
def segment(image):
""":param image: one transformed field.
:returns: its labels, cell probability and flow vectors under
the captured detection settings.
"""
labels, cellprob, _rgb, vectors = _detect_cellpose_snapshot(
dict(request['detect'], image=image, vectors=True), models)
return labels, cellprob, vectors
secondary = request.get('second_segment')
second_model = request.get('second_model')
if second_model and secondary is None:
if second_model == request['detect']['model']:
raise ValueError("The ensemble model must differ from the primary model.")
def secondary(image):
"""Segment a transform with the captured second model.
:param image: one transformed field.
:returns: labels, cell probability and vectors.
"""
labels, cellprob, _rgb, vectors = _detect_cellpose_snapshot(
dict(request['detect'], model=second_model, image=image, vectors=True), models)
return labels, cellprob, vectors
def _score(image):
"""Score primary TTA and the optional second model in one frame.
:param image: one field.
:returns: its uncertainty over four or eight aligned passes.
"""
return compute_uncertainty(image, segment, second_segment=secondary,
probability_threshold=threshold)
if request['kind'] == 'field':
return _score(request['image'])
scores = {}
for folder, name in request['fields']:
try:
image, _mask = engine.load_image_and_mask(folder, name,
**request['layout'])
result = _score(image)
except Exception: # noqa: BLE001
LOG.warning("uncertainty of %s could not be scored", name,
exc_info=True)
continue
result.pop('map', None)
scores[(folder, name)] = result
return scores
def _uncertainty_heatmap(uncertainty: np.ndarray,
image: Optional[np.ndarray] = None) -> np.ndarray:
"""An uncertainty map in colour over the field in grey, ``(H, W, 3)``.
The map is already in ``[0, 1]`` and is coloured without stretching, so
one colour means one uncertainty on every field. It is blended over the
field so the uncertain objects can be found on it.
:param uncertainty: the per-pixel map, values in ``[0, 1]``.
:param image: the field it was computed on, or None for the map alone.
:returns: uint8 RGB.
"""
scaled = np.clip(np.asarray(uncertainty, dtype=np.float32), 0.0, 1.0)
try:
import matplotlib
colour = np.asarray(matplotlib.colormaps["magma"](scaled))[..., :3]
except Exception: # noqa: BLE001
colour = np.repeat(scaled[..., None], 3, axis=2)
if image is not None and np.asarray(image).shape[:2] == scaled.shape:
field = np.asarray(image)
if field.ndim == 3:
field = field.mean(axis=2)
grey = stretch_to_uint8(field).astype(np.float32)[..., None] / 255.0
weight = 0.35 + 0.65 * scaled[..., None]
colour = weight * colour + (1.0 - weight) * grey
return (np.clip(colour, 0.0, 1.0) * 255).astype(np.uint8)
class _NewestRequestWorker:
"""Runs requests one at a time on a background thread, newest first.
A mouse that has moved on makes every region still waiting stale, so a
request superseded before it starts is DROPPED rather than run:
:meth:`submit` replaces the one request waiting instead of queueing
behind it. The pace is the worker's own -- the next request is taken when
the last one finishes, not on a timer -- so a slow model is asked less
often rather than falling further and further behind the mouse.
A request submitted with ``pin=True`` is one a click is waiting on.
Pinned requests run before the newest unpinned one, in the order they
were clicked, and nothing supersedes them.
A plain Python thread rather than a QThread: it owns no Qt object, starts
when there is work and exits as soon as there is none, so an idle
magnifier holds no thread at all and a closing screen has nothing to
destroy out from under a running one.
:param work: ``request -> result``, run on the worker thread.
:param deliver: ``(request, result, error) -> None``, run on the worker
thread after each request, ``error`` being the exception ``work``
raised or None. It must hand over to the GUI thread itself.
:param name: the thread's name, as a stack dump shows it.
"""
def __init__(self, work, deliver, name: str = "spacr-magnifier"):
"""Build a worker with no thread; the first request starts one."""
self._work = work
self._deliver = deliver
self._name = str(name)
self._lock = threading.Lock()
self._pending = None
self._pinned: deque = deque()
self._running = None
self._thread: Optional[threading.Thread] = None
self._closed = False
#: How many requests were replaced before they started.
self.superseded = 0
def submit(self, request, *, pin: bool = False) -> bool:
"""Ask for ``request``; return False once the worker is closed.
:param request: anything with a ``key``; two requests with one key
are the same request, and the second is not run again while the
first is waiting or running.
:param pin: True for a request a click is waiting on.
"""
key = getattr(request, "key", None)
with self._lock:
if self._closed:
return False
running = getattr(self._running, "key", None)
if pin:
already = key is not None and (
running == key
or any(getattr(p, "key", None) == key for p in self._pinned))
if not already:
if (self._pending is not None and key is not None
and getattr(self._pending, "key", None) == key):
self._pending = None
self._pinned.append(request)
elif key is not None and running == key:
if self._pending is not None:
self.superseded += 1
self._pending = None
else:
if self._pending is not None:
self.superseded += 1
self._pending = request
if self._thread is None and (self._pinned or self._pending is not None):
self._thread = threading.Thread(
target=self._loop, name=self._name, daemon=True)
self._thread.start()
return True
def idle(self) -> bool:
"""Whether nothing is running or waiting."""
with self._lock:
return self._thread is None
def drop_waiting(self) -> int:
"""Drop every request not yet started; one already running finishes.
:returns: how many requests were dropped.
"""
with self._lock:
dropped = len(self._pinned) + (self._pending is not None)
self._pending = None
self._pinned.clear()
return int(dropped)
def close(self, timeout: float = 2.0) -> bool:
"""Drop what is waiting and wait up to ``timeout`` for what is running.
:returns: True when no worker thread is left running.
"""
with self._lock:
self._closed = True
self._pending = None
self._pinned.clear()
thread = self._thread
if thread is None or thread is threading.current_thread():
return True
thread.join(max(0.0, float(timeout)))
return not thread.is_alive()
def _loop(self) -> None:
"""Take requests until there are none; the thread then ends."""
me = threading.current_thread()
try:
while True:
with self._lock:
if self._closed or (self._pending is None
and not self._pinned):
self._thread = None
return
if self._pinned:
request = self._pinned.popleft()
else:
request, self._pending = self._pending, None
self._running = request
result, error = None, None
try:
result = self._work(request)
except Exception as exc: # noqa: BLE001
error = exc
with self._lock:
self._running = None
closed = self._closed
if closed:
continue
try:
self._deliver(request, result, error)
except Exception: # noqa: BLE001
LOG.exception("a magnifier result could not be delivered")
finally:
with self._lock:
self._running = None
if self._thread is me:
self._thread = None
#: How far, in widget pixels, a press on the canvas must travel before
#: prompting reads it as a box rather than a point.
_PROMPT_DRAG_PX = 5
def _prompt_image(source, low: float, high: float) -> np.ndarray:
"""The field as micro-SAM is shown it: the canvas's own stretch, 8-bit.
micro-SAM stretches whatever it is given from its minimum to its
maximum, which on a 16-bit field with a few hot pixels leaves every
cell nearly black. The field is therefore stretched here between the
same two percentiles the canvas draws it with, per channel, so the model
sees the contrast the curator sees.
:param source: the field, ``H x W`` or ``H x W x C``.
:param low: the lower percentile, 0 to 100.
:param high: the upper percentile, 0 to 100.
:returns: a ``uint8`` array of the same shape.
"""
array = np.asarray(source, dtype=np.float32)
bottom = np.percentile(array, float(low), axis=(0, 1), keepdims=True)
top = np.percentile(array, float(high), axis=(0, 1), keepdims=True)
span = np.where(top > bottom, top - bottom, 1.0)
out = np.clip((array - bottom) / span, 0.0, 1.0)
return np.round(out * 255.0).astype(np.uint8)
class _PromptRequest(NamedTuple):
"""One prompt on one field, as the worker thread runs it.
:param key: the prompt itself, so an identical one is not run twice.
:param generation: the session's generation it was made in; a request
from an earlier one is abandoned.
:param field: the field's name, for the ledger.
:param source: the field's pixels, as the canvas draws them.
:param low: the canvas's lower percentile.
:param high: the canvas's upper percentile.
:param points: ``((y, x, on_object), ...)`` in image pixels.
:param box: ``(y0, x0, y1, x1)`` in image pixels, or None.
"""
key: tuple
generation: int
field: str
source: Any
low: float
high: float
points: tuple
box: Optional[tuple]
class _PromptSession(QObject):
"""Prompt-based segmentation on the canvas, one object at a time.
While it is on, a left click on the canvas puts a point on the object,
a right click puts one off it, and a left drag draws a box round it.
Every prompt goes to micro-SAM, in its own environment, and the mask it
returns is drawn over the field; more points refine it. Enter accepts it
(:attr:`accept_requested`, which the screen turns into one undoable
edit), Backspace takes the last point or the box back, and Escape
discards the prompt.
THE SLOW PART IS PAID ONCE PER FIELD. The worker embeds a field the
first time it is prompted and answers every later prompt on it from
that embedding, so only the first click on a field waits for the image
encoder. The field is identified by its content as the model sees it,
so going back to a field, or back to its contrast, finds its embedding
still there.
THE WINDOW NEVER WAITS. Prompts run on a background thread through
:class:`_NewestRequestWorker`, so a click made while the model is busy
replaces the one still waiting instead of queueing behind it. Moving to
another field, switching prompting off or Escape abandons what is
running; the worker, and the model it has loaded, stay up.
:param canvas: the canvas prompts are drawn on and read from.
:param client: what answers a prompt, with the
:class:`spacr._segmentation_backends._PromptClient` interface; built
on first use when None.
:param gate: returns whether prompting may run now; the screen passes
whether its card is shown.
:param field_name: returns the name of the field on screen.
:param parent: the owning object.
"""
#: Something on the canvas needs repainting.
changed = Signal()
#: ``(text, kind)`` for the console, kinds as :meth:`_MasksConsole.post`,
#: or ``status`` for the corner line, which the console copies.
said = Signal(str, str)
#: Enter was pressed on the canvas with a mask shown.
accept_requested = Signal()
_delivered = Signal(object, object, object)
def __init__(self, canvas, *, client=None, gate=None, field_name=None,
parent=None):
"""Build an idle session; nothing runs until the first prompt."""
super().__init__(parent)
self._canvas = canvas
self._client = client
self._gate = gate or (lambda: True)
self._field_name = field_name or (lambda: "")
self._enabled = False
self._closed = False
#: ``[(y, x, on_object), ...]`` in image pixels.
self.points: List[tuple] = []
#: ``(y0, x0, y1, x1)`` in image pixels, or None.
self.box: Optional[tuple] = None
#: The newest answer: the client's reply plus the prompt behind it.
self.pending: Optional[dict] = None
self.busy = False
self.generation = 0
self._press = None
self._drag = None
self._overlay = None
self._prepared = None
self._prepared_lock = threading.Lock()
self._worker = _NewestRequestWorker(
self._work, self._handover, name="spacr-sam-prompt")
self._delivered.connect(self._take)
@property
def enabled(self) -> bool:
"""Whether clicks on the canvas are prompts right now."""
return self._enabled and not self._closed and bool(self._gate())
def client(self):
"""What answers prompts, built the first time it is needed."""
if self._client is None:
from ... import _segmentation_backends as backends
self._client = backends._PromptClient()
return self._client
def set_enabled(self, on: bool) -> None:
"""Start or stop reading canvas clicks as prompts.
Stopping discards the prompt and whatever mask it had; nothing was
in the label image until it was accepted.
"""
self._enabled = bool(on)
if not on:
self.forget()
self._canvas.setFocusPolicy(Qt.ClickFocus if on else Qt.NoFocus)
if on:
self._canvas.setCursor(Qt.CrossCursor)
else:
self._canvas.unsetCursor()
self.changed.emit()
def forget(self) -> None:
"""Discard the prompt and its mask, and abandon a prompt still running."""
self.generation += 1
self._worker.drop_waiting()
self.points = []
self.box = None
self.pending = None
self.busy = False
self._overlay = None
self._press = self._drag = None
self.changed.emit()
def close(self) -> None:
"""Stop for good: the screen is going."""
self._closed = True
self.generation += 1
self._worker.close(timeout=0)
def press(self, button, pos) -> bool:
"""A mouse button went down on the canvas.
:param button: the Qt mouse button.
:param pos: where, in widget pixels.
:returns: True when the press was a prompt's and nothing else's.
"""
if button not in (Qt.LeftButton, Qt.RightButton):
return False
point = self._canvas._canvas_to_image(pos.x(), pos.y())
self._press = (button, QPointF(pos), point)
self._drag = None
return True
def move(self, pos) -> bool:
"""The mouse moved: a left press dragged far enough draws a box.
:returns: True when a prompt's press is under way.
"""
if self._press is None:
return False
button, start, _point = self._press
if button == Qt.LeftButton and (
self._drag is not None
or math.hypot(pos.x() - start.x(), pos.y() - start.y())
> _PROMPT_DRAG_PX):
self._drag = QPointF(pos)
self.changed.emit()
return True
def release(self, button, pos) -> bool:
"""A button came up: add the point, or the box that was dragged.
:returns: True when the release belonged to a prompt's press.
"""
if self._press is None or button != self._press[0]:
return self._press is not None
pressed, _start, point = self._press
dragged, self._press, self._drag = self._drag, None, None
if point is None:
self.changed.emit()
return True
if dragged is not None:
end = self._canvas._canvas_to_image(pos.x(), pos.y())
if end is not None:
(x0, y0), (x1, y1) = point, end
self.box = (min(y0, y1), min(x0, x1),
max(y0, y1) + 1, max(x0, x1) + 1)
self._submit()
return True
self.points.append((int(point[1]), int(point[0]),
pressed == Qt.LeftButton))
self._submit()
return True
def undo_prompt(self) -> bool:
"""Take back the last point, or the box when no point is left.
:returns: False when there was nothing to take back.
"""
if self.points:
self.points.pop()
elif self.box is not None:
self.box = None
else:
return False
if self._has_object():
self._submit()
else:
self.generation += 1
self._worker.drop_waiting()
self.pending = None
self._overlay = None
self.busy = False
self.changed.emit()
return True
def key(self, key) -> bool:
"""Enter accepts, Backspace takes a prompt back, Escape discards.
:param key: a ``Qt.Key``.
:returns: True when the key did something here.
"""
if not self.enabled:
return False
if key in (Qt.Key_Return, Qt.Key_Enter):
if self.pending is None:
return False
self.accept_requested.emit()
return True
if key == Qt.Key_Backspace:
return self.undo_prompt()
if key == Qt.Key_Escape:
if not (self.points or self.box is not None or self.pending):
return False
self.forget()
return True
return False
def wants_key(self, key) -> bool:
"""Whether ``key`` would do something here, so the canvas keeps it
from the screen's own shortcuts (Escape resets the zoom there)."""
if not self.enabled:
return False
if key in (Qt.Key_Return, Qt.Key_Enter):
return self.pending is not None
if key in (Qt.Key_Backspace, Qt.Key_Escape):
return bool(self.points or self.box is not None or self.pending)
return False
def _has_object(self) -> bool:
"""Whether the prompt says where the object is: a box or a point on it."""
return self.box is not None or any(on for _y, _x, on in self.points)
def _submit(self) -> None:
"""Send the whole prompt as it now stands; the newest one wins."""
from ..i18n import tr
if not self._has_object():
self.said.emit(tr(
"Put a point on the object, or drag a box round it, before "
"points off it."), "info")
self.changed.emit()
return
source = self._canvas.displayed_source()
if source is None:
return
request = _PromptRequest(
key=(self.generation, tuple(self.points), self.box),
generation=self.generation, field=str(self._field_name() or ""),
source=source, low=float(self._canvas.norm_lo),
high=float(self._canvas.norm_hi), points=tuple(self.points),
box=self.box)
self.busy = True
self._worker.submit(request)
self.changed.emit()
def _prepare(self, request):
"""The field as the model sees it and the key its embedding is kept
under, computed once per field and contrast."""
import hashlib
stamp = (id(request.source), request.low, request.high)
with self._prepared_lock:
cached = self._prepared
if cached is not None and cached[0] == stamp \
and cached[1] is request.source:
return cached[2], cached[3]
image = _prompt_image(request.source, request.low, request.high)
digest = hashlib.blake2b(image.tobytes(), digest_size=16)
digest.update(repr(image.shape).encode())
key = digest.hexdigest()
with self._prepared_lock:
self._prepared = (stamp, request.source, image, key)
return image, key
def _work(self, request):
"""Run one prompt; on the worker thread."""
from ..i18n import tr
image, key = self._prepare(request)
started = time.monotonic()
result = self.client().segment(
key, image, points=[(y, x) for y, x, _on in request.points],
labels=[1 if on else 0 for _y, _x, on in request.points],
box=request.box,
should_cancel=lambda: (self._closed
or request.generation != self.generation),
on_start=lambda: self.said.emit(tr(
"micro-SAM is starting in its own environment…"),
"progress"),
on_embed=lambda: self.said.emit(tr(
"micro-SAM is embedding this field; the first prompt on a "
"field waits for it…"), "progress"))
result = dict(result)
result["total_seconds"] = time.monotonic() - started
result["key"] = key
return result
def _handover(self, request, result, error) -> None:
"""Carry a finished prompt to the GUI thread."""
self._delivered.emit(request, result, error)
def _take(self, request, result, error) -> None:
"""Show a finished prompt's mask, or say why there is none."""
from ..i18n import tr
if self._closed or request.generation != self.generation:
return
self.busy = not self._worker.idle()
if error is not None:
from ... import _segmentation_backends as backends
if not isinstance(error, backends._BackendCancelled):
self.said.emit(tr("micro-SAM could not segment: {error}",
error=error), "error")
self.changed.emit()
return
mask = np.asarray(result.get("mask"), dtype=bool)
canvas_mask = self._canvas.mask
if canvas_mask is None or mask.shape != tuple(canvas_mask.shape[:2]):
self.changed.emit()
return
self.pending = dict(result, points=request.points, box=request.box,
field=request.field)
self._overlay = self._render(mask)
pixels = int(mask.sum())
embed = result.get("embed_seconds")
if embed is not None:
self.said.emit(tr(
"micro-SAM embedded this field in {embed:.1f} s on {device}; "
"the prompt then took {prompt:.2f} s.", embed=float(embed),
device=result.get("device") or "?",
prompt=float(result.get("seconds") or 0.0)), "info")
self.said.emit(tr(
"micro-SAM outlined {pixels} px in {seconds:.2f} s. Enter adds "
"it, Backspace takes the last prompt back, Escape discards it.",
pixels=pixels,
seconds=float(result.get("total_seconds") or 0.0)), "status")
self.changed.emit()
def _render(self, mask: np.ndarray):
"""The mask as a translucent picture over its bounding box.
:returns: ``(QImage, x0, y0, x1, y1)`` in image pixels, or None for
an empty mask.
"""
rows = np.flatnonzero(mask.any(axis=1))
cols = np.flatnonzero(mask.any(axis=0))
if not rows.size:
return None
y0, y1 = int(rows[0]), int(rows[-1]) + 1
x0, x1 = int(cols[0]), int(cols[-1]) + 1
crop = mask[y0:y1, x0:x1]
padded = np.pad(crop, 1)
inner = (padded[:-2, 1:-1] & padded[2:, 1:-1]
& padded[1:-1, :-2] & padded[1:-1, 2:])
edge = crop & ~inner
colour = QColor(active_palette()["accent"])
rgba = np.zeros(crop.shape + (4,), dtype=np.uint8)
rgba[..., 0] = colour.red()
rgba[..., 1] = colour.green()
rgba[..., 2] = colour.blue()
rgba[..., 3] = np.where(edge, 255, np.where(crop, 96, 0))
height, width = crop.shape
picture = QImage(rgba.data, width, height, 4 * width,
QImage.Format_RGBA8888).copy()
return picture, x0, y0, x1, y1
def paint(self, painter) -> None:
"""Draw the mask, the box, the box being dragged and the points."""
canvas = self._canvas
if self._overlay is not None:
picture, x0, y0, x1, y1 = self._overlay
top_left = canvas._image_to_canvas(x0, y0)
bottom_right = canvas._image_to_canvas(x1, y1)
if top_left is not None and bottom_right is not None:
painter.drawImage(QRectF(QPointF(top_left),
QPointF(bottom_right)), picture)
palette = active_palette()
pen = QPen(QColor(palette["accent"]))
pen.setWidth(2)
pen.setStyle(Qt.DashLine)
painter.setPen(pen)
painter.setBrush(Qt.NoBrush)
if self.box is not None:
y0, x0, y1, x1 = self.box
a = canvas._image_to_canvas(x0, y0)
b = canvas._image_to_canvas(x1, y1)
if a is not None and b is not None:
painter.drawRect(QRect(a, b).normalized())
if self._drag is not None and self._press is not None:
painter.drawRect(QRectF(self._press[1], self._drag).normalized())
painter.setPen(QPen(QColor(palette["bg"]), 1))
for y, x, on in self.points:
where = canvas._image_to_canvas(x + 0.5, y + 0.5)
if where is None:
continue
painter.setBrush(QColor(palette["success" if on else "error"]))
painter.drawEllipse(QPointF(where), 5.0, 5.0)
class _LiveMagnifier(QObject):
"""A box under the mouse that shows its region magnified and segmented.
FOUR COORDINATE SYSTEMS MEET HERE, and every conversion between them is in
this class or in :func:`spacr.qt.mask_engine._magnifier_box`:
* **widget** -- where the mouse is on the canvas, in logical pixels;
* **image** -- the field's own pixels, which the mask shares;
* **crop** -- the region the model sees, ``image[y0:y1, x0:x1]``, clipped
at the image border and never padded;
* **lens** -- the crop drawn ``zoom`` times larger than the canvas draws
it, placed so the image pixel under the cursor stays under the cursor.
The model answers in crop coordinates and a commit pastes that answer at
``(x0, y0)``, so nothing a model returns is ever in lens or widget pixels
and zoom cannot move a committed object.
Inference runs on a :class:`_NewestRequestWorker`. This object builds
requests on the GUI thread, draws the box, and hands the result a click
asked for to the screen, which owns the mask, the undo history and the
ledger.
A CLICK COMMITS THE RESULT FOR THE REGION CLICKED. If that result is
already on screen it goes in at once; if the box is still updating, the
click's request is pinned on the worker -- later mouse movement cannot
supersede it -- and it goes in when it arrives.
WHOLE-IMAGE SCOPE runs the model ONCE on the entire field, on a second
worker, and the box then draws its slice of those objects with no model
call per move. A click commits only the object under it, and a
right-click asks the screen to remove the mask object under it. A change
to any setting the model reads discards those objects and starts a new
run; a run that was cancelled or failed is not started again until a
click, or a setting, asks for it.
:param canvas: the canvas the box is drawn over.
:param parent: owning QObject.
:param load_model: ``name -> model`` for modes that need one; called on
the worker thread.
:param context: ``() -> dict`` of the screen's own settings a model reads
-- ``model_name``, ``diameter``, ``bright``, ``min_area``,
``flow_threshold``, ``cellprob_threshold``, ``normalize`` and
``otsu_correction`` -- read on the GUI thread whenever a request is
built.
"""
_delivered = Signal(object)
#: The result a click was waiting on; the screen commits it.
commit_ready = Signal(object)
#: The wheel moved the zoom; carries the new value.
zoom_changed = Signal(float)
#: Shift + the wheel moved the size; carries the new value.
size_changed = Signal(int)
#: A field opened and the size's range is now ``(smallest, largest)``.
size_range_changed = Signal(int, int)
#: A sentence for the status line.
status = Signal(str)
#: The mask object under the mouse should go; carries image ``(x, y)``.
remove_requested = Signal(int, int)
#: A whole-image run started (True) or ended (False).
busy_changed = Signal(bool)
#: A press-and-drag's objects as ``(outcome, final)``: shown
#: while the button is down, committed once when ``final``.
drag_ready = Signal(object)
locked_changed = Signal(bool)
def __init__(self, canvas, parent=None, *, load_model=None, context=None):
"""Build a magnifier that is off and holds no thread."""
super().__init__(parent)
from ..bridge import emit_safely
self._emit_safely = emit_safely
self.canvas = canvas
self.enabled = False
self.locked = False
self._lock_key_down = False
self.mode = "otsu"
self.size = _MAGNIFIER_SIZE
self.zoom = _MAGNIFIER_ZOOM
self.sensitivity = _MAGNIFIER_SENSITIVITY
self.scope = "region"
self.exclude_border = True
#: ``request -> labels`` or ``(labels, mode_used, note)``, run on the
#: worker thread. Replaceable, which is how a test puts a stub in.
self.segment = partial(_segment_region, load_model=load_model)
self._context = context
self._field = 0
self._said_error: Optional[str] = None
self._said_diameter_note = False
self._cursor: Optional[tuple] = None
self._anchor: Optional[QPointF] = None
self._requested_key: Optional[tuple] = None
self._shown: Optional[_MagnifierResult] = None
self._shown_image: Optional[QImage] = None
self._waiting: set = set()
self._unavailable: dict = {}
#: ``(model key, labels, mode, note)`` for the last region the model
#: answered. Read and written only on the region worker's thread, so
#: a setting that only filters the answer does not ask again.
self._raw: Optional[tuple] = None
#: The key of the whole-image run on its way, or None.
self._image_key: Optional[tuple] = None
#: The objects of the last whole-image run, whole-field labels.
self._image_result: Optional[_MagnifierResult] = None
#: The key of a run cancelled or failed, not started again by itself.
self._image_halted: Optional[tuple] = None
#: ``(result, (box, object under the mouse, rule, mask), picture,
#: its pixels)`` last drawn; the pixels are what the picture reads.
self._image_view: Optional[tuple] = None
#: ``(result, (object, rule, mask), what the rule takes)`` -- see
#: :meth:`_image_promise`.
self._image_promised: Optional[tuple] = None
#: What a click would add where the mask already has objects; the
#: Overlap rule the screen's box is on. See :func:`_ghosted_overlay`.
self.overlap = _MAGNIFIER_OVERLAP_DEFAULT
self.auto_accept = False
self._auto_accepted = None
self._auto_hold = None
self._auto_explicit_key = None
#: The mask the canvas was last seen holding, and how many different
#: ones it has held. See :meth:`mask_generation`.
self._mask_seen = None
self._mask_token = 0
#: Whether a request for a ghost gone stale is already on its way,
#: so a stream of repaints asks once rather than once each.
self._asking = False
#: :meth:`_stamp` of the request the box is waiting on, or None.
self._requested_stamp: Optional[tuple] = None
#: When the whole-image run on its way was handed to its worker, and
#: how long it is expected to take -- None when nothing says.
self._image_started: Optional[float] = None
self._image_estimate: Optional[float] = None
#: The :class:`_RunTicket` of the whole-image run on its way, which
#: counts its tiles and is how that run is stopped.
self._image_ticket: Optional[_RunTicket] = None
#: ``(mode, model) -> seconds per megapixel`` from the last run
#: that finished under it and was worth believing. See
#: :meth:`_note_pace`, which says which runs those are, and
#: ``_image_paced`` beside it, which is every pair measured at all.
self._image_pace: dict = {}
self._image_paced: set = set()
#: Which field is on screen, as the screen names it -- the path of
#: the image file. It identifies a field across a trip to another
#: one and back, which the generation counter above cannot: that
#: counts loads, so the same field twice is two numbers.
self._field_name: str = ""
#: ``(field name, settings) -> result`` for fields segmented whole
#: already, and ``-> when it was last used`` beside it. See
#: :meth:`_keep_image_result`.
self._image_cache: dict = {}
self._image_cache_used: dict = {}
#: ``(the field's array, that field inverted)`` -- see
#: :meth:`inverted_field`.
self._inverted_field: Optional[tuple] = None
self._busy = False
self._worker = _NewestRequestWorker(self._run, self._hand_over)
self._image_worker = _NewestRequestWorker(
self._run, self._hand_over, name="spacr-magnifier-image")
self._delivered.connect(self._on_delivered, Qt.QueuedConnection)
self._init_stroke()
def eventFilter(self, watched, event):
"""Track the held L in Ctrl+L+right-click without stealing edit keys."""
from PySide6.QtCore import QEvent
kind = event.type()
if kind in (QEvent.ApplicationDeactivate, QEvent.WindowDeactivate):
self._lock_key_down = False
elif kind == QEvent.KeyRelease and event.key() in (Qt.Key_L, Qt.Key_Control):
if not event.isAutoRepeat():
self._lock_key_down = False
elif (kind == QEvent.KeyPress and event.key() == Qt.Key_L
and event.modifiers() & Qt.ControlModifier
and self.enabled and self.canvas.isVisible()
and isinstance(watched, QWidget)
and watched.window() == self.canvas.window()):
self._lock_key_down = True
return True
return super().eventFilter(watched, event)
def set_locked(self, locked: bool) -> None:
"""Pin or release the current image region, lens position, size and zoom.
Locking needs an enabled lens over the image. Detector settings can
still refresh this region. Disabling the lens or opening another
field releases it. This transient viewing state is never saved.
"""
from ..i18n import tr
locked = bool(locked and self.enabled and self._cursor is not None)
if locked == self.locked:
return
self.locked = locked
self.locked_changed.emit(locked)
self.status.emit(tr("Magnifier locked: Ctrl+L+right-click to unlock.")
if locked else tr("Magnifier unlocked."))
self.canvas.update()
def set_enabled(self, on: bool) -> None:
"""Turn the box on or off; objects already committed are untouched.
Turning it on in whole-image scope starts a run for the field on
screen, unless its objects are already found. Turning it off leaves
a run in progress going, so its objects are ready when it comes back.
"""
self.enabled = bool(on)
if self.enabled:
QApplication.instance().installEventFilter(self)
self._image_halted = None
self.refresh()
else:
QApplication.instance().removeEventFilter(self)
self.set_locked(False)
self._lock_key_down = False
self._cursor = None
self._anchor = None
self.canvas.update()
def set_mode(self, mode: str) -> None:
"""Choose the model, and forget which models failed to run.
A mode this screen has renamed arrives under either name and is
stored under the current one (:func:`canonical_magnifier_mode`), so
a session or a script written under an old mode name still selects
a mode that exists.
"""
self.mode = canonical_magnifier_mode(mode)
self._unavailable.clear()
self.refresh()
self.canvas.update()
def set_scope(self, scope: str) -> None:
"""Choose between segmenting the box's region and the whole image.
Leaving whole-image scope drops a run still waiting and ignores one
still running. Objects already found are kept, and are offered again
on coming back if no setting the model reads has changed since.
"""
scope = str(scope or "region")
if scope not in _MAGNIFIER_SCOPES:
scope = "region"
if scope == self.scope:
return
self.scope = scope
self._image_halted = None
self._image_view = None
if scope == "region":
self._stop_image()
self.refresh()
self.canvas.update()
def set_overlap(self, rule: str) -> None:
"""Choose what a new object does where the mask already has one.
The MODEL IS NOT ASKED AGAIN, and the whole-image objects are not
discarded: the rule is applied to what was found, not by the thing
that finds it. Only the box's promise of what a click would add is
built afresh -- and it is built where the objects are, on the
worker, which is why this asks for the region again rather than
clearing a picture. The rule is not part of a request key, so the
worker answers from the model's last answer for that box.
:param rule: one of
:data:`spacr.qt.mask_engine._MAGNIFIER_OVERLAP_RULES`; anything
else leaves the rule where it is, because the box is about to
draw a promise in its name.
"""
name = str(rule or "")
if name not in engine._MAGNIFIER_OVERLAP_RULES:
return
self.overlap = name
self.refresh()
self.canvas.update()
def set_exclude_border(self, on: bool) -> None:
"""Choose whether objects the box's own edge cuts are offered.
Only the answer is filtered again: the model's answer for the region
is kept on the worker, so the model is not asked a second time.
"""
self.exclude_border = bool(on)
self.refresh()
self.canvas.update()
def set_auto_accept(self, on: bool) -> None:
"""Accept current proposals without a click while enabled.
Region mode follows Objects added; whole-image mode accepts only
the object under the pointer. Explicit drags retain one undo step.
A mask repaint or undo does not accept the same proposal again.
"""
self.auto_accept = bool(on)
self._auto_accepted = None
self._auto_hold = None
self._auto_explicit_key = None
self._accept_proposed()
def _accept_proposed(self, *, commit: bool = True) -> bool:
"""Commit a current hover proposal once, without consuming a drag.
Disabled, superseded, off-image and old-field answers never qualify.
Remember the proposal before emitting: committing refreshes the mask
synchronously and can trigger another preview delivery.
"""
if (not self.auto_accept or not self.enabled or self._cursor is None
or self._stroke is not None or self._waiting):
return False
held = self._auto_hold
if commit and held is not None:
x, y = self._cursor
if (0 <= y < held.shape[0] and 0 <= x < held.shape[1]
and held[y, x]):
return False
self._auto_hold = None
whole = self.scope == "image"
expected = self._image_key_now() if whole else self._requested_key
explicit = (expected, self.overlap, self.save_mode, self._cursor)
if not commit:
self._auto_explicit_key = explicit
result = self._image_result if whole else self._shown
if result is None or result.request.key[0] != self._field:
return False
if result.request.key != expected:
return False
label = None
if whole or self.save_mode == "touching":
x0, y0 = result.request.box[:2]
x, y = self._cursor
x, y = x - x0, y - y0
if not (0 <= y < result.labels.shape[0]
and 0 <= x < result.labels.shape[1]):
return False
label = int(result.labels[y, x])
if label <= 0:
return False
marker = (result.request.key, self.overlap, self.save_mode, label)
if marker == self._auto_accepted:
return False
self._auto_accepted = marker
if not commit or self._auto_explicit_key == explicit:
return False
if whole:
accepted = _single_object(result, label)
elif label is not None:
accepted = result._replace(
labels=np.where(result.labels == label, result.labels, 0),
count=1, overlay=None, ghost=None, extents=None)
else:
accepted = result
self.commit_ready.emit(accepted)
return True
def size_range(self) -> tuple:
"""``(smallest, largest)`` side for the field on screen; see
:func:`_magnifier_size_range`."""
image = self.canvas.image
return _magnifier_size_range(None if image is None else image.shape)
def set_size(self, size: int) -> None:
"""Set the region's side in image pixels, within its range."""
if self.locked:
return
low, high = self.size_range()
self.size = max(low, min(high, int(size)))
self.refresh()
self.canvas.update()
def wheel_size(self, up: bool) -> int:
"""Step the size one Shift + wheel notch; return the new size.
A notch moves the side by the canvas's own zoom per notch, and by at
least one pixel, so a box of 32 px and one of 4,000 px both take
about as many notches to double. The size stops at its range.
"""
speed = max(1.001, float(getattr(self.canvas, "zoom_speed", 1.15)))
step = max(1, int(round(self.size * (speed - 1.0))))
self.set_size(self.size + step if up else self.size - step)
self.size_changed.emit(self.size)
return self.size
def _sync_size_range(self) -> None:
"""Say the size's range for the field now, and keep the size inside it."""
low, high = self.size_range()
self.size_range_changed.emit(int(low), int(high))
clamped = max(low, min(high, int(self.size)))
if clamped != self.size:
self.size = clamped
self.size_changed.emit(self.size)
def set_zoom(self, zoom: float) -> None:
"""Set the magnification, within its range. The model is not asked."""
if self.locked:
return
low, high = _MAGNIFIER_ZOOM_RANGE
self.zoom = max(low, min(high, float(zoom)))
self.canvas.update()
def set_sensitivity(self, value: float) -> None:
"""Set how readily an object is accepted, within its range."""
low, high = _MAGNIFIER_SENSITIVITY_RANGE
self.sensitivity = max(low, min(high, float(value)))
self.refresh()
self.canvas.update()
def set_field(self, name: str) -> None:
"""Name the field about to be loaded, so its objects can be found again.
Called by the screen BEFORE the pair reaches the canvas, because the
canvas calls :meth:`forget` on the way in and the new name is what
the next whole-image run is kept under.
:param name: the image file's path; "" for a canvas handed an array
with no file behind it, which is then never cached.
"""
self._field_name = str(name or "")
def forget(self) -> None:
"""Drop everything tied to the field on screen; another is loading.
The whole-image objects are KEPT, in the cache rather than on the
magnifier: coming back to this field offers them again instead of
segmenting it a second time. See :meth:`_keep_image_result`.
The canvas calls this with the new field already in place, which is
what lets the size's range follow the field that has just opened.
"""
self.set_locked(False)
self._lock_key_down = False
self._cursor = None
self._anchor = None
self._field += 1
self._said_error = None
self._said_diameter_note = False
self._shown = None
self._shown_image = None
self._auto_accepted = None
self._auto_hold = None
self._auto_explicit_key = None
self._waiting.clear()
self._requested_key = None
self._requested_stamp = None
self._stop_image()
self._image_result = None
self._image_view = None
self._image_halted = None
self._sync_size_range()
def close(self) -> bool:
"""Stop both workers, waiting briefly for a model call in flight.
The kept whole-image objects go with them: the screen is closing,
and a label image per field is the largest thing this object holds.
"""
QApplication.instance().removeEventFilter(self)
if self._image_ticket is not None:
self._image_ticket.cancel()
region = self._worker.close()
image = self._image_worker.close()
self._image_cache.clear()
self._image_cache_used.clear()
return region and image
def updating(self) -> bool:
"""Whether what the box draws is not yet what it was last asked for.
The region asked for, and also the promise made about it: a rule
change and an edit to the mask under the box both leave the ghost
drawn against something that is no longer true, and the frame says
so in the way it already says it -- dashed, with "Updating…" --
until the worker answers again.
In whole-image scope: whether the objects for the field and the
settings now are not found yet.
"""
if self.scope == "image":
result = self._image_result
return (result is None
or result.request.key != self._image_key_now())
return (self._shown is None
or self._stamp(self._shown.request) != self._requested_stamp
or self._ghost_is_stale())
def hover(self, pos) -> None:
"""Follow the mouse to widget point ``pos``; None puts the box away."""
if self.locked:
return
point = (None if pos is None
else self.canvas._canvas_to_image(pos.x(), pos.y()))
if point is None:
self._cursor = None
self._anchor = None
return
self._anchor = QPointF(float(pos.x()), float(pos.y()))
if point != self._cursor:
self._cursor = point
self.refresh()
def refresh(self) -> None:
"""Ask for the region under the mouse, unless it is already shown.
In whole-image scope, ask for the whole field instead -- and only
when its objects for the settings now are neither found, on their
way, nor cancelled.
"""
if self.scope == "image":
self._refresh_image()
return
request = self.build_request()
if request is None:
return
self._requested_key = request.key
self._requested_stamp = self._stamp(request)
if (self._shown is not None
and self._stamp(self._shown.request) == self._requested_stamp):
self._accept_proposed()
return
self._worker.submit(request)
def _model_settings(self) -> tuple:
"""Everything a model reads, in :data:`_MODEL_SETTING_FIELDS` order.
As it would read it now: the screen's own settings through
``context``, and Otsu in place of a mode that has already failed
to load. Every setting is here whichever mode is chosen, because a
model that cannot run hands the request to the Otsu mode, which
must then find its own settings in it.
"""
context = {"model_name": "cpsam", "diameter": 0, "bright": True,
"min_area": 0, "flow_threshold": FLOW_THRESHOLD,
"cellprob_threshold": CELLPROB_THRESHOLD,
"normalize": True, "otsu_correction": 1.0,
"otsu_smoothing": OTSU_SMOOTHING,
"otsu_fill_holes": True, "otsu_split": True,
"invert": False,
"chain": detect_chain.NO_CHAIN,
"method_params": organelle_modes.DEFAULT_PARAMS,
"cpu_params": cpu_modes.DEFAULT_PARAMS,
"otsu_window": OTSU_LOCAL_WINDOW,
"otsu_classes": 2, "otsu_foreground_class": None}
if self._context is not None:
context.update(self._context())
model_name = str(context["model_name"])
mode = canonical_magnifier_mode(self.mode)
if (mode, model_name) in self._unavailable:
mode = "otsu"
return (mode, round(float(self.sensitivity), 4),
bool(context["bright"]), int(context["min_area"]),
model_name, int(context["diameter"]),
round(float(context["flow_threshold"]), 4),
round(float(context["cellprob_threshold"]), 4),
bool(context["normalize"]),
round(float(context["otsu_correction"]), 4),
round(float(context["otsu_smoothing"]), 4),
bool(context["otsu_fill_holes"]),
bool(context["otsu_split"]),
bool(context["invert"]),
context["chain"],
context["method_params"],
context["cpu_params"],
int(context["otsu_window"]),
max(3, int(context["otsu_classes"])) if mode == cpu_modes.MULTIOTSU
else int(context["otsu_classes"]),
(None if context["otsu_foreground_class"] is None else
int(context["otsu_foreground_class"])),
((float(self.canvas.norm_lo), float(self.canvas.norm_hi))
if self.canvas.detect_on_normalized and mode != cpu_modes.PUNCTA else None),
context.get('primary_token', ()) if mode in (cpu_modes.SECONDARY, cpu_modes.PUNCTA) else ())
def running_name(self) -> str:
"""What the box is running, as the Updating mark names it.
The Cellpose model by its own name (``cpsam``,
``toxoplasma_plaque_v1``), since that is what the user chose and what
the wait depends on; any other mode by the caption its Mode box shows
(``Otsu``, ``DINOCell``). A mode that could not load is named as the
Otsu it fell back to, because that is what is running.
:returns: the name, in the current language where it is a caption.
"""
settings = self._model_settings()
if settings[0] == "cellpose":
return str(settings[4])
return _magnifier_mode_label(settings[0])
@staticmethod
def _accent() -> tuple:
"""The theme's accent colour as ``(red, green, blue)``."""
accent = QColor(active_palette()["accent"])
return (accent.red(), accent.green(), accent.blue())
def mask_generation(self) -> int:
"""How many different masks the canvas has been handed, counted here.
The canvas REBINDS its mask for every edit rather than writing into
the one it has, so identity is what "the mask changed" means, and a
counter over it is what a request can carry to the worker and a
result can be compared against when it comes back. Reading it is
the only place the change is noticed, which is why the box's own
paint asks (:meth:`paint`).
"""
mask = self.canvas.mask
if mask is not self._mask_seen:
self._mask_seen = mask
self._mask_token += 1
return self._mask_token
def inverting(self) -> bool:
"""Whether Invert is on, as the screen's panel holds it now.
Asked by :meth:`paint`, which runs outside the request path and so
cannot read the answer off a request: the box shows the inverted
image while Invert is on, and the box repaints on moves that
never build a request at all.
"""
if self._context is None:
return False
return bool(self._context().get("invert", False))
def inverted_field(self) -> np.ndarray:
"""The WHOLE open field reflected about its own range, cached.
The inversion is what the DETECTOR sees, so the
box and the detect button have to invert the same way -- and a
region reflected about ITS OWN extremes is reflected differently
wherever the box is put. Inverting the whole field once and cutting
from that is what keeps the box a preview of the button, and it is
ONE array that both the painted picture and the request's crop come
off, so the two cannot disagree about which way up the field was.
Computed once per field and not once per mouse move, keyed on the
array's identity: a full-field pass on every hover is work on the
GUI thread for an answer that cannot have changed, and another field
is another array and asks again. The canvas caches its
display complement against its own image the same way.
"""
image = self.canvas.image
cached = self._inverted_field
if cached is not None and cached[0] is image:
return cached[1]
out = engine.invert_normalized(image)
self._inverted_field = (image, out)
return out
def detector_field(self) -> np.ndarray:
"""The field the box magnifies: enhanced, inverted, or the canvas's own.
With "Show the enhanced image" on it is the canvas's own enhanced
picture (:meth:`_MaskCanvas.enhanced_picture`), so the box and the
canvas under it show one image and a curator comparing them is
comparing the same chain. The box's DETECTION still enhances the
box's own region, on the worker; the difference between the two is
the difference between a CLAHE tile grid laid over a field and one
laid over a box, and it is why the Compare window shows the box's
own region.
"""
if self.canvas.enhance_display:
return self.canvas.enhanced_picture()
if self.canvas.detect_on_normalized:
return self.canvas.detection_base()
if not self.inverting():
return self.canvas.image
return self.inverted_field()
def refresh_view(self) -> None:
"""Repaint the box because the PICTURE under it changed.
Not :meth:`refresh`, which asks the model again: switching the
enhanced view on and off changes what is drawn and not what was
detected, and the objects on screen are still the objects the
settings ask for.
"""
self.canvas.update()
def compare_box(self) -> tuple:
"""The region a raw-versus-enhanced comparison is to show.
THE BOX UNDER THE MOUSE when there is one, because that is the
region the chain really ran on for the detection that is on screen;
the whole field otherwise, which is the region the detect buttons
run it on.
:returns: ``(x0, y0, x1, y1)`` in image pixels.
"""
image = self.canvas.image
height, width = (int(v) for v in image.shape[:2])
if self.enabled and self._cursor is not None:
return engine._magnifier_box(image.shape, self._cursor[0],
self._cursor[1], self.size)
return (0, 0, width, height)
def region_for(self, box, *, invert: bool) -> np.ndarray:
"""A copy of ``box`` of the open field, inverted if Invert is on.
The one place the box's pixels are taken, so what the model is given
and what the box paints cannot disagree about whether they were
inverted.
THE ENHANCEMENT CHAIN IS NOT APPLIED HERE. This runs on the GUI
thread, once per mouse move, and a non-local means or a wide
background radius over a region is not something to do between two
frames; :func:`_segment_region` applies it on the worker, to this
crop, which is also what makes every step live on the box. The
percentile stretch is the exception and is already in
:meth:`_MaskCanvas.detection_base`, because its levels are the
whole field's.
The inverted case is a slice of :meth:`inverted_field`, which uses
:func:`mask_engine.invert_for_detection` and NOT
:func:`mask_engine.invert_intensity`: the crop is about to be
thresholded, and the second moves the field's span in a way the
Otsu threshold correction cannot survive. The first function's
docstring carries the measurement.
:param box: ``(x0, y0, x1, y1)`` in image pixels.
:param invert: whether Invert for detection is on.
"""
x0, y0, x1, y1 = box
if self.mode == cpu_modes.PUNCTA:
source = self._context()['puncta_image']()
return np.array(source[y0:y1, x0:x1], copy=True)
if self.canvas.detect_on_normalized:
source = self.canvas.detection_base()
else:
source = self.inverted_field() if invert else self.canvas.image
return np.array(source[y0:y1, x0:x1], copy=True)
def build_request(self, *, ghost: bool = True
) -> Optional[_MagnifierRequest]:
"""The request for the region under the mouse now, or None.
:param ghost: whether the worker is to draw what the Overlap rule
would leave as well as what the model found. False for a drag's
frames, which are never drawn as a box: the mask crop would be
copied and the rule counted over it for a picture nobody sees.
"""
canvas = self.canvas
image = canvas.image
if (not self.enabled or self._cursor is None or image is None
or canvas.mask is None):
return None
settings = self._model_settings()
values = dict(zip(_MODEL_SETTING_FIELDS, settings))
box = engine._magnifier_box(image.shape, self._cursor[0],
self._cursor[1], self.size)
x0, y0, x1, y1 = box
exclude = bool(self.exclude_border)
rule = _canonical_overlap_rule(self.overlap)
occupied = None
token = 0
if ghost and rule != "replace":
token = self.mask_generation()
occupied = (np.array(canvas.mask[y0:y1, x0:x1], copy=True)
if values['mode'] == cpu_modes.SECONDARY else
np.asarray(canvas.mask)[y0:y1, x0:x1] > 0)
primary = self._primary_request_values(box, values)
if primary is None:
return None
return _MagnifierRequest(
key=(self._field, box) + settings + (exclude,),
crop=self.region_for(box, invert=values["invert"]),
box=box,
shape=tuple(int(v) for v in image.shape[:2]),
colour=self._accent(),
exclude_border=exclude,
overlap=rule if ghost else "replace",
occupied=occupied,
mask_token=token,
**primary,
**values,
)
def _primary_request_values(self, box, settings):
"""Snapshot this field's primary crop and provenance for secondary mode."""
if settings['mode'] not in (cpu_modes.SECONDARY, cpu_modes.PUNCTA):
return {}
context = self._context() if self._context is not None else {}
source = context.get('primary_source')
if source is None or source.identity != settings['primary_token']:
return None
return {'primary_labels': source.crop(box),
'primary_provenance': dict(source.provenance(),
selection=context.get('primary_selection', source.path))}
@staticmethod
def _stamp(request: _MagnifierRequest) -> tuple:
"""What makes a result the one the box wants NOW.
The request key says which region the model was asked about; the
rule and the mask say what its answer means. A click while the mask
under the box has changed must not be answered from a picture drawn
against the mask before it.
"""
return (request.key, request.overlap, request.mask_token)
def click(self) -> bool:
"""Commit the objects for the region under the mouse.
In whole-image scope, commit only the object under the mouse; see
:meth:`_pick`.
THE RESULT ON SCREEN IS MATCHED BY ITS KEY AND NOT BY :meth:`_stamp`.
What a click commits is the model's objects; the Overlap rule is
applied to them as they go in, by the screen, from the Overlap box as
it reads at that moment. A box whose ghost is one mask edit behind is
still drawn from the right objects, and making the click wait for a
picture would delay the edit itself for the sake of a promise about
it.
:returns: False when there is no region to commit -- the magnifier is
off, the mouse is off the image, or no field is open.
"""
from ..i18n import tr
if self.scope == "image":
return self._pick()
request = self.build_request()
if request is None:
return False
self._requested_key = request.key
self._requested_stamp = self._stamp(request)
if self._shown is not None and self._shown.request.key == request.key:
self._accept_proposed(commit=False)
self.commit_ready.emit(self._shown)
return True
self._waiting.add(request.key)
self._worker.submit(request, pin=True)
self.status.emit(tr(
"Magnifier: segmenting this region — its objects are added as "
"soon as the box is up to date."))
return True
def remove(self) -> bool:
"""Ask the screen to remove the mask object under the mouse.
Whole-image scope's right click. Any object in the mask qualifies,
whatever put it there; the screen owns the mask, the undo history and
the ledger, and says so when there is nothing under the mouse.
:returns: False when the magnifier is off or the mouse is off the
image.
"""
if (not self.enabled or self._cursor is None
or self.canvas.mask is None):
return False
self.remove_requested.emit(int(self._cursor[0]),
int(self._cursor[1]))
return True
def remove_at(self, point) -> bool:
"""Ask the screen to remove the mask object at image ``point``.
A right click with the magnifier on (item 685). The clicked pixel is
used, not the lens position, so a locked lens deletes what was
clicked rather than what it is pinned over.
:param point: image ``(x, y)``, or None for a click off the image.
:returns: False when the magnifier is off or nothing was asked.
"""
if not self.enabled or point is None or self.canvas.mask is None:
return False
self.remove_requested.emit(int(point[0]), int(point[1]))
return True
def hold_auto_accept(self, footprint) -> None:
"""Keep Auto accept off the pixels of an object just deleted.
Without this, the proposal under the pointer would put the deleted
object straight back. Auto accept resumes once the pointer leaves
every held footprint; a new field or toggling releases the hold.
:param footprint: boolean image-sized array of the removed pixels.
"""
footprint = np.asarray(footprint, dtype=bool)
held = self._auto_hold
if held is not None and held.shape == footprint.shape:
footprint = held | footprint
self._auto_hold = footprint
def cancel_image(self) -> bool:
"""Stop waiting for the whole-image run and throw its answer away.
A model call already running cannot be interrupted: it finishes on
its worker and its objects are discarded when they arrive. Moving the
mouse does not start the run again; a click, or a change to a setting
the model reads, does.
:returns: False when no run was on its way.
"""
from ..i18n import tr
key = self._image_key
if key is None:
return False
self._stop_image()
self._image_halted = key
self.status.emit(tr(
"Magnifier: whole-image segmentation cancelled. Click the image "
"to start it again."))
self.canvas.update()
return True
def _image_key_now(self) -> Optional[tuple]:
"""The key a whole-image run for the field and settings now has."""
canvas = self.canvas
if canvas.image is None or canvas.mask is None:
return None
return (self._field, "image") + self._model_settings()
def _cache_key(self, key: Optional[tuple]) -> Optional[tuple]:
"""The cache's name for a run key: the FIELD and what a model reads.
A run key opens with the generation counter, which counts loads
rather than fields, so the same field opened twice carries two
different keys and would never match itself.
:param key: a key from :meth:`_image_key_now`.
:returns: None when no field is named, so nothing is kept for a
canvas that was handed an array without one.
"""
if key is None or not self._field_name:
return None
return (self._field_name,) + tuple(key[2:])
def _keep_image_result(self, result) -> None:
"""Keep a finished whole-image answer for when the field comes back.
A field this session has already segmented whole is minutes of
Cellpose on a CPU; leaving it and coming back paid that again. What
is kept is the label image, which is what a click reads, and it is
valid for exactly the settings it was found under: the key carries
them, so a run under a new Sensitivity neither matches nor evicts
the old one.
"""
key = self._cache_key(result.request.key)
if key is None:
return
self._image_cache[key] = result
self._image_cache_used[key] = time.time()
self._trim_image_cache()
def _cached_image_result(self, key: tuple):
"""The kept answer for run ``key``, with its key moved to this load.
:returns: a result whose request carries ``key``, so everything that
compares the two goes on doing it, or None.
"""
name = self._cache_key(key)
if name is None:
return None
result = self._image_cache.get(name)
if result is None:
return None
self._image_cache_used[name] = time.time()
return result._replace(request=result.request._replace(key=key))
def _trim_image_cache(self) -> None:
"""Drop what the memory budget says must go, least recently used first.
The policy is :func:`spacr.qt.memory_budget.what_to_drop`, which is
what every other cache in the application is trimmed by: the user's
own idle timeout releases a field nobody has gone back to, and the
lower of the user's cache ceiling and
:data:`_MAGNIFIER_IMAGE_CACHE_MB` bounds the rest. The field on
screen is held by :attr:`_image_result` as well, so a trim can never
take the objects out from under the box.
"""
from ..memory_budget import what_to_drop
ceiling = _MAGNIFIER_IMAGE_CACHE_MB
try:
from ..preferences import get_cache_ceiling_mb
ceiling = min(ceiling, int(get_cache_ceiling_mb()))
except Exception: # noqa: BLE001
pass
names = list(self._image_cache)
entries = [(index,
self._image_cache[name].nbytes() / 1e6,
self._image_cache_used.get(name, 0.0))
for index, name in enumerate(names)]
for dropped in what_to_drop(entries, time.time(), ceiling_mb=ceiling):
name = names[int(dropped)]
self._image_cache.pop(name, None)
self._image_cache_used.pop(name, None)
def _refresh_image(self) -> None:
"""Start a whole-image run, unless one for the settings now is found,
on its way, or was cancelled.
A field segmented whole earlier in the session, under the settings
now, is taken from the cache instead of being segmented again.
"""
from ..i18n import tr
if not self.enabled:
return
key = self._image_key_now()
if key is None:
return
result = self._image_result
if result is not None and result.request.key == key:
self._accept_proposed()
return
if key in (self._image_key, self._image_halted):
return
kept = self._cached_image_result(key)
if kept is not None:
self._stop_image()
self._image_result = kept
self._image_view = None
self._accept_proposed()
self.status.emit(tr(
"Magnifier: {n} object(s) found in the whole image. Click one "
"to add it; right-click an object in the mask to remove it.",
n=kept.count))
self.canvas.update()
return
replaced = result is not None or self._image_key is not None
self._image_result = None
self._image_view = None
self._start_image(key)
if replaced:
self.status.emit(tr(
"Magnifier: the settings changed, so the whole-image objects "
"were discarded. Segmenting the whole image again…"))
else:
self.status.emit(tr("Magnifier: segmenting the whole image…"))
self.canvas.update()
def _start_image(self, key: tuple) -> None:
"""Hand a copy of the whole field to the image worker under ``key``.
Inverted first when Invert is on, like every other request: the
whole field IS the region here, so it is the same call.
"""
image = self.canvas.image
height, width = (int(v) for v in image.shape[:2])
values = dict(zip(_MODEL_SETTING_FIELDS, key[2:]))
if (_diameter_zero_estimates(_magnifier_route_model(values))
and not values.get("diameter")
and not self._said_diameter_note):
self._said_diameter_note = True
self.status.emit(_cellpose3_auto_diameter_note())
primary = self._primary_request_values((0, 0, width, height), values)
if primary is None:
from ..i18n import tr
self.status.emit(tr('Load a primary mask before growing secondary objects.'))
self._image_halted = key
return
if self._image_ticket is not None:
self._image_ticket.cancel()
self._image_ticket = _RunTicket()
self._image_key = key
self._image_halted = None
self._image_started = time.monotonic()
pace = self._image_pace.get(self._pace_key(key))
self._image_estimate = (None if pace is None
else pace * height * width / 1e6)
self._image_worker.submit(_MagnifierRequest(
key=key,
crop=self.region_for((0, 0, width, height),
invert=values["invert"]),
box=(0, 0, width, height), shape=(height, width),
colour=self._accent(), exclude_border=False, scope="image",
ticket=self._image_ticket, **primary, **values))
self._set_busy(True)
@staticmethod
def _pace_key(key: tuple) -> tuple:
"""What a run's duration is worth remembering against: mode and model.
Not the field and not its size: the whole point is to answer for a
field nothing has been measured on, and seconds per megapixel is what
carries across. Sensitivity and Min area are left out because they
move the objects found rather than the work done.
"""
return (key[2], key[6])
def remaining_seconds(self) -> Optional[float]:
"""How long the whole-image run on its way still has, or None.
A GUESS FROM A MEASUREMENT, and only ever from one: the seconds per
megapixel the last run under this mode and model took, times this
field's megapixels -- see :meth:`_note_pace` for which run that is
allowed to be. Before there is such a measurement the answer is
None and the bar stays indeterminate, which is the honest answer.
None once the estimate is spent, too, so a bar that has run out goes
back to saying only that something is happening.
:returns: seconds, never below zero, or None.
"""
if (not self._busy or self._image_started is None
or self._image_estimate is None):
return None
left = self._image_estimate - (time.monotonic() - self._image_started)
return left if left > 0 else None
def image_progress(self) -> Optional[tuple]:
"""``(tiles started, tiles in all, seconds left or None)``, or None.
What the whole-image run on its way has itself counted, which a
model that tiles -- Cellpose -- does from its first tile
(:class:`_RunTicket`). None for a run that counts nothing (Otsu, a
backend out of process), for which :meth:`remaining_seconds` is the
only estimate there is.
"""
ticket = self._image_ticket
if not self._busy or ticket is None or ticket.total <= 0 \
or ticket.done <= 0:
return None
return (min(ticket.done, ticket.total), ticket.total,
ticket.remaining_seconds())
def estimated_seconds(self) -> Optional[float]:
"""The whole run's estimate, or None when nothing was estimated.
What :meth:`remaining_seconds` is counting down FROM, which a bar
needs as well as the remainder to show a fraction. Public for the
same reason the remainder is: the screen draws the bar, and a
screen reaching into this object for the other half of one answer
was reaching past the answer it had just been given.
"""
return self._image_estimate
def _note_pace(self, key: tuple, pixels: int, seconds: float) -> None:
"""Remember what this mode and model cost per megapixel, last time.
A RUN THAT LOADED ITS MODEL IS NOT WHAT THE NEXT RUN COSTS. The
same field measures 10.3 s cold and 3.9 s warm on the
same Cellpose model: most of a first run is the load, and a bar
that counted down from it would promise two and a half times the
time the run it is drawn over actually takes, then finish while it
still said seven seconds left. A mode that loads a model therefore
spends its first measurement of a session learning that the model
is now in memory -- the bar stays indeterminate through the second
run, and counts down from the third. The Otsu mode loads nothing,
so its first run is its pace and its second run counts down.
:param key: the whole-image request key the run answered.
:param pixels: how many pixels were segmented.
:param seconds: how long it took, from handing it over to its
result arriving.
"""
if pixels <= 0 or seconds <= 0:
return
pace_key = self._pace_key(key)
first = pace_key not in self._image_paced
self._image_paced.add(pace_key)
if first and canonical_magnifier_mode(pace_key[0]) != "otsu":
return
self._image_pace[pace_key] = seconds / (pixels / 1e6)
def _stop_image(self) -> None:
"""Forget the whole-image run on its way, and stop it.
One not started yet is dropped; one running is asked to stop at the
model's next tile (:class:`_RunTicket`), which frees the processor
and the model lock within one tile rather than at the end of the
run. A model that does not tile finishes, and its answer is thrown
away when it arrives, as before.
"""
if self._image_ticket is not None:
self._image_ticket.cancel()
self._image_ticket = None
self._image_key = None
self._image_started = None
self._image_estimate = None
self._image_worker.drop_waiting()
self._set_busy(False)
def _set_busy(self, busy: bool) -> None:
"""Say that a whole-image run started or ended, once each."""
busy = bool(busy)
if busy != self._busy:
self._busy = busy
self.busy_changed.emit(busy)
def _pick(self) -> bool:
"""Commit the whole-image object under the mouse, if there is one.
Before the objects for the settings now are found nothing is
committed and nothing is pinned: a run can take minutes, and an
object going in long after the click that asked for it would be a
surprise. A click while no run is on its way starts one.
"""
from ..i18n import tr
canvas = self.canvas
if (not self.enabled or self._cursor is None or canvas.image is None
or canvas.mask is None):
return False
key = self._image_key_now()
result = self._image_result
if result is None or result.request.key != key:
if key is not None and key == self._image_key:
self.status.emit(tr(
"Magnifier: the whole image is still being segmented — "
"nothing was added."))
else:
self._image_halted = None
self._refresh_image()
return True
x, y = self._cursor
label = int(result.labels[y, x])
if label <= 0:
self.status.emit(tr(
"Magnifier: there is no object under the click — nothing was "
"added."))
return True
self._accept_proposed(commit=False)
self.commit_ready.emit(_single_object(result, label))
return True
def wheel(self, up: bool) -> float:
"""Step the zoom one wheel notch, at the canvas's own zoom per notch."""
speed = max(1.001, float(getattr(self.canvas, "zoom_speed", 1.15)))
self.set_zoom(self.zoom * speed if up else self.zoom / speed)
self.zoom_changed.emit(self.zoom)
return self.zoom
def _init_stroke(self) -> None:
"""Hold no press, and start in the mode that adds every object in the box."""
from PySide6.QtCore import QTimer
from .._magnifier_drag import _PREVIEW_MS
#: Which objects a click or a drag adds: ``zoom``, every object in the
#: box, or ``touching``, only the objects under the mouse.
self.save_mode = "zoom"
#: The press in progress -- a ``_DragStroke`` -- with where and on
#: which field it started, and whether it has moved far enough to be
#: a drag.
self._stroke = None
self._stroke_from: Optional[tuple] = None
self._stroke_moved = False
self._blocked_stroke_press = False
self._stroke_timer = QTimer(self)
self._stroke_timer.setSingleShot(True)
self._stroke_timer.setInterval(_PREVIEW_MS)
self._stroke_timer.timeout.connect(self._stroke_show)
self._delivered.connect(self._stroke_delivered, Qt.QueuedConnection)
def press(self) -> bool:
"""Start a stroke under the mouse: what a left press does while on.
Nothing is added on the press. A release that has not moved is a
click -- :meth:`click` -- except that with only
objects touching the mouse it adds just the object under the cursor.
A press that pulls is a drag; what a drag adds is
:mod:`spacr.qt._magnifier_drag`'s to say. Under Whole image a drag
reads the objects already found and asks no model; before they are
found a press starts nothing, and its release is a click that says so.
A previous stroke waiting for segmentation keeps ownership until it
commits; a press during that wait and its matching release are ignored.
:returns: False when no stroke started.
"""
from .._magnifier_drag import _DragStroke, _frame_step
from ..i18n import tr
self._blocked_stroke_press = bool(
self._stroke is not None and self._stroke_from[1] == self._field)
if self._blocked_stroke_press:
self.status.emit(tr(
"Magnifier: segmenting the last regions — the objects are "
"added as soon as they are done."))
return False
self._stroke = None
canvas = self.canvas
if (not self.enabled or self._cursor is None or canvas.image is None
or canvas.mask is None):
return False
if self.mode == cpu_modes.SECONDARY:
return True
whole = self.scope == "image"
found = self._image_result
if whole and (found is None
or found.request.key != self._image_key_now()):
return False
stroke = _DragStroke(
canvas.image.shape, self._cursor,
step=0 if whole else _frame_step(self.size),
keep_untouched=not whole and self.save_mode != "touching",
provenance={"save": self.save_mode})
self._stroke = stroke
self._stroke_from = (QPointF(self._anchor), self._field)
self._stroke_moved = False
if whole:
stroke.expect("image")
stroke.deliver("image", found.labels, found.request.box,
provenance=_magnifier_provenance(found.request, found.mode, found.note))
else:
self._stroke_frame(self._cursor)
return True
def drag(self) -> None:
"""Follow the pressed mouse: extend the stroke and ask for its frames.
Called on every move with the button down, and cheap. The press must
first travel the platform's drag distance, so a hand that shakes
during a click still clicks; after that a move adds a line of pixels
and, every quarter box, one pinned request on the region worker. The
model never runs here, and the mask is repainted at most every
``_PREVIEW_MS``.
"""
if self._blocked_stroke_press:
return
stroke = self._stroke
if stroke is None or self._cursor is None:
return
if not self._stroke_moved:
travel = (self._anchor - self._stroke_from[0]).manhattanLength()
if travel < QApplication.startDragDistance():
return
self._stroke_moved = True
for centre in stroke.extend(self._cursor):
self._stroke_frame(centre)
self._stroke_dirty()
def release(self) -> bool:
"""End a press: a click if it never moved, otherwise commit the stroke.
The commit waits for any frame still on the worker, then reaches the
screen once through :attr:`drag_ready` -- one edit, one undo step.
"""
from ..i18n import tr
if self._blocked_stroke_press:
self._blocked_stroke_press = False
return False
stroke = self._stroke
touching_click = bool(stroke is not None and stroke.step
and not stroke.keep_untouched)
if stroke is None or not (self._stroke_moved or touching_click):
self._stroke = None
return self.click()
stroke.release()
if stroke.waiting():
self.status.emit(tr(
"Magnifier: segmenting the last regions — the objects are "
"added as soon as they are done."))
self._stroke_finish()
return True
def _stroke_frame(self, centre) -> None:
"""Ask for the box centred on ``centre`` as one of the stroke's frames.
The box on screen, when it is that box, is taken at once; any other is
pinned on the region worker, so later moves cannot supersede it.
"""
cursor, self._cursor = self._cursor, centre
request = self.build_request(ghost=False)
self._cursor = cursor
self._stroke.expect(request.key)
shown = self._shown
if shown is not None and shown.request.key == request.key:
self._stroke.deliver(request.key, shown.labels, request.box,
provenance=_magnifier_provenance(shown.request, shown.mode, shown.note))
else:
self._worker.submit(request, pin=True)
def _stroke_delivered(self, payload) -> None:
"""Give the stroke a box it waits for; commit it if that was the last."""
request, result, error = payload
stroke = self._stroke
if stroke is None:
return
if error is not None:
stroke.drop(request.key)
elif stroke.deliver(request.key, result.labels, request.box,
provenance=_magnifier_provenance(result.request, result.mode, result.note)):
self._stroke_dirty()
self._stroke_finish()
def _stroke_dirty(self) -> None:
"""Show what the stroke adds soon, unless a showing is already due."""
if self._stroke.dirty and not self._stroke_timer.isActive():
self._stroke_timer.start()
def _stroke_finish(self) -> None:
"""Commit the stroke once the button is up and its last frame is in."""
if self._stroke is not None and self._stroke.ready():
self._stroke_show(final=True)
def _stroke_show(self, final: bool = False) -> None:
"""Hand what the stroke adds to the screen: to show, or to commit.
A stroke whose field has gone -- the user moved on while it waited --
is dropped without a word: its objects were for a mask no longer on
screen.
"""
stroke = self._stroke
if stroke is None:
return
gone = self._stroke_from[1] != self._field
if final or gone:
self._stroke = None
self._stroke_timer.stop()
if not gone:
if final:
self._accept_proposed(commit=False)
self.drag_ready.emit((stroke.outcome(), bool(final)))
def _run(self, request: _MagnifierRequest) -> _MagnifierResult:
"""Segment one request. Runs on a worker thread, never the GUI's.
A region asked for again with only the border option changed reuses
the model's last answer, which is kept here on the region worker.
"""
if request.scope == "image":
labels, used, note = self._segment_now(request)
return _MagnifierResult(
request, labels, used, note,
_candidate_overlay(labels, request.colour),
_object_count(labels), extents=_object_extents(labels))
model_key = request.key[:-1]
cached = self._raw
if cached is not None and cached[0] == model_key:
_key, labels, used, note = cached
else:
labels, used, note = self._segment_now(request)
self._raw = (model_key, labels, used, note)
if request.exclude_border:
labels = engine._drop_cut_objects(labels, request.box,
request.shape)
overlay = _candidate_overlay(labels, request.colour)
return _MagnifierResult(request, labels, used, note, overlay,
_object_count(labels),
_ghosted_overlay(labels, overlay, request))
def _segment_now(self, request: _MagnifierRequest) -> tuple:
"""Ask the model: ``(int32 labels shaped like the crop, mode, note)``."""
outcome = self.segment(request)
if isinstance(outcome, tuple):
labels, used, note = outcome
else:
labels, used, note = outcome, request.mode, ""
labels = np.asarray(labels)
if labels.shape != request.crop.shape[:2]:
raise ValueError(
f"{used} returned labels shaped {labels.shape} for a region "
f"shaped {request.crop.shape[:2]}")
return labels.astype(np.int32, copy=False), str(used), str(note)
def _hand_over(self, request, result, error) -> None:
"""Pass a finished request to the GUI thread. Runs on the worker."""
self._emit_safely(self._delivered, (request, result, error))
def _on_delivered(self, payload) -> None:
"""Show a finished result, and commit it if a click was waiting.
The picture the box draws is the result's ghost when it has one,
which is the whole picture and not a layer over the outlines: it IS
the outlines, with what the Overlap rule would not add faded. One
QImage is built per result either way.
A FAILURE IS SAID ONCE (item 507). While a whole-field enhancement
runs, every region fails with the same "enhancement is updating"
reason, and the magnifier asks for a region on every mouse move; the
reason used to be said, and logged, each time. It is said again only
when it changes, after a region succeeds, or on a new field.
"""
from ..i18n import tr
request, result, error = payload
if request.key[0] != self._field:
return
if request.scope == "image":
self._on_image_delivered(request, result, error)
return
if error is not None:
self._waiting.discard(request.key)
if request.key == self._requested_key:
self._shown = None
self._shown_image = None
self.canvas.update()
if str(error) != self._said_error:
self._said_error = str(error)
LOG.warning("magnifier could not segment %s: %s",
request.box, error)
self.status.emit(tr(
"Magnifier could not segment this region: {error}",
error=error))
return
self._said_error = None
self._note_fallback(request, result)
self._shown = result
self._shown_image = _rgba_qimage(
result.overlay if result.ghost is None else result.ghost)
if request.key in self._waiting:
self._waiting.discard(request.key)
self._accept_proposed(commit=False)
self.commit_ready.emit(result)
else:
self._accept_proposed()
self.canvas.update()
def _note_fallback(self, request, result) -> bool:
"""Say, once per model, that it could not run and Otsu stood in.
The mode is named the way the Mode box names it, not by its internal
key: a sentence about ``cellpose3:cyto3`` sends the reader looking for
a row that says "Cellpose 3 · cyto3".
:returns: True when this call said it.
"""
from ..i18n import tr
if result.mode == request.mode:
return False
marker = (request.mode, request.model_name)
if marker in self._unavailable:
return False
self._unavailable[marker] = result.note
self.status.emit(tr(
"Magnifier: {mode} could not run ({reason}); the Otsu mode is "
"segmenting instead.",
mode=_magnifier_mode_label(request.mode), reason=result.note))
return True
def _on_image_delivered(self, request, result, error) -> None:
"""Keep a finished whole-image run, unless it is no longer wanted."""
from ..i18n import tr
if request.key != self._image_key:
return
started, self._image_started = self._image_started, None
self._image_key = None
self._image_estimate = None
if request.ticket is self._image_ticket:
self._image_ticket = None
self._set_busy(False)
if isinstance(error, _RunCancelled):
return
if error is None and started is not None:
height, width = (int(v) for v in request.shape[:2])
self._note_pace(request.key, height * width,
time.monotonic() - started)
if error is not None:
self._image_halted = request.key
LOG.warning("magnifier could not segment the whole image: %s",
error)
self.status.emit(tr(
"Magnifier could not segment the whole image: {error}",
error=error))
return
noted = self._note_fallback(request, result)
stored = request._replace(crop=None)
key = self._image_key_now()
if (key is not None and key[2] == result.mode
and key[:2] + key[3:] == request.key[:2] + request.key[3:]):
stored = stored._replace(key=key)
self._image_result = result._replace(request=stored)
self._image_view = None
self._keep_image_result(self._image_result)
accepted = self._accept_proposed()
if not noted and not accepted:
self.status.emit(tr(
"Magnifier: {n} object(s) found in the whole image. Click one "
"to add it; right-click an object in the mask to remove it.",
n=result.count))
self.canvas.update()
def lens_geometry(self) -> Optional[tuple]:
"""``(box, lens, scale)`` for the box under the mouse, or None.
``box`` is the crop in image pixels, ``lens`` the widget rectangle it
is drawn into and ``scale`` widget pixels per image pixel inside the
lens: the canvas's own display scale times ``zoom``. The lens is
placed so the centre of the image pixel under the cursor is exactly
under the cursor, which at the image border leaves the clipped side
visibly short rather than shifting the picture.
"""
canvas = self.canvas
if (not self.enabled or self._cursor is None or self._anchor is None
or canvas.image is None or canvas.mask is None):
return None
rendered = canvas.pixmap()
if rendered is None or rendered.isNull():
return None
shown = logical_size(rendered)
vx0, _vy0, vx1, _vy1 = canvas._viewport_bounds()
scale = shown.width() / max(1, vx1 - vx0) * float(self.zoom)
cx, cy = self._cursor
box = engine._magnifier_box(canvas.image.shape, cx, cy, self.size)
x0, y0, x1, y1 = box
lens = QRectF(self._anchor.x() - (cx + 0.5 - x0) * scale,
self._anchor.y() - (cy + 0.5 - y0) * scale,
(x1 - x0) * scale, (y1 - y0) * scale)
return box, lens, scale
def paint(self, painter: QPainter) -> None:
"""Draw the box: the region magnified, its objects, and its state.
The region is contrast-stretched on its own, which is what makes the
box an enhanced view rather than a bigger copy of the canvas. The
last completed result is drawn where ITS region lies, so a result
that is behind the mouse is offset rather than wrong, and the frame
turns dashed with an "Updating <model>…" mark until the result for this
region arrives -- the box never blanks while it waits.
In whole-image scope the box draws its slice of the whole-image
objects instead, with the object a click would add filled more
strongly and what the Overlap rule would take from it ghosted, and
the frame is dashed while a run is on its way.
NOTHING IS COMPUTED FROM PIXELS HERE beyond the magnified region
itself: the picture the box draws over it was built on the worker,
ghosted and all (:func:`_ghosted_overlay`). What this does notice
is a mask it has not been drawn against -- the canvas rebinds its
mask for every edit -- and it asks for the region again rather than
redrawing a promise made about a mask that is gone.
WITH INVERT ON THE BOX SHOWS THE INVERTED REGION,
off the same :meth:`inverted_field` the
request's crop is cut from, so what the user is looking at inside
the box is what the model was given. The canvas under it is
untouched.
"""
geometry = self.lens_geometry()
if geometry is None:
return
box, lens, scale = geometry
x0, y0, x1, y1 = box
canvas = self.canvas
part, area = self._visible_part(box, lens, scale)
if part is None:
return
vx0, vy0, vx1, vy1 = part
step = self._picture_step(scale)
rgb = _box_picture(self.detector_field(), box, part,
canvas.mask[vy0:vy1, vx0:vx1],
canvas.norm_lo, canvas.norm_hi, step)
height, width = rgb.shape[:2]
picture = QImage(rgb.data, width, height, 4 * width,
QImage.Format_RGB32)
if step > 1:
area = QRectF(area.left(), area.top(), width * step * scale,
height * step * scale)
palette = active_palette()
painter.save()
painter.setRenderHint(QPainter.SmoothPixmapTransform, False)
painter.setClipRect(lens)
painter.drawImage(area, picture)
if self.scope == "image":
self.mask_generation()
view = self._image_slice(part)
if view is not None:
painter.drawImage(area, view)
updating = self._image_key is not None
else:
self.mask_generation()
if self._ghost_is_stale() and not self._asking:
self._asking = True
QTimer.singleShot(0, self, self._ask_again)
shown = self._shown
if shown is not None and self._shown_image is not None:
sx0, sy0, sx1, sy1 = shown.request.box
painter.drawImage(
QRectF(lens.left() + (sx0 - x0) * scale,
lens.top() + (sy0 - y0) * scale,
(sx1 - sx0) * scale, (sy1 - sy0) * scale),
self._shown_image)
updating = self.updating()
painter.setClipping(False)
pen = QPen(QColor(palette["accent"]))
pen.setWidth(2)
if updating:
pen.setStyle(Qt.DashLine)
painter.setPen(pen)
painter.setBrush(Qt.NoBrush)
painter.drawRect(lens)
if updating or self.locked:
from ..i18n import tr
caption = _updating_caption(self.running_name()) if updating else ""
if self.locked:
caption = tr("Locked") + (" · " + caption if caption else "")
metrics = painter.fontMetrics()
badge = QRectF(lens.left() + 4, lens.top() + 4,
metrics.horizontalAdvance(caption) + 10,
metrics.height() + 4)
backing = QColor(palette["bg"])
backing.setAlpha(200)
painter.fillRect(badge, backing)
painter.setPen(QPen(QColor(palette["fg"])))
painter.drawText(badge, Qt.AlignCenter, caption)
painter.restore()
def _picture_step(self, scale: float) -> int:
"""How many image pixels one pixel of the box's picture stands for.
One whenever the lens magnifies -- a device pixel or more per image
pixel -- which is every zoom at which the box is used to look
closely. Below that, Qt drawing the picture unsmoothed would keep
one image pixel in every ``1 / (scale x device ratio)`` anyway, so
:func:`_box_picture` does not make the others.
:param scale: widget pixels per image pixel inside the lens.
"""
ratio_of = getattr(self.canvas, "devicePixelRatioF", None)
ratio = float(ratio_of()) if callable(ratio_of) else 1.0
device = float(scale) * (ratio or 1.0)
if device <= 0 or device >= 1.0:
return 1
return max(1, int(1.0 / device))
def _visible_part(self, box, lens, scale):
"""The part of ``box`` that is on the canvas, and where it is drawn.
THE BOX IS NOT BOUNDED BY THE WINDOW. Its side is the Size box's
value in image pixels and may be as large as the field's own
longer side, and the lens then draws it ``zoom`` times larger
again: on a 2,048 px field most of the lens is off the widget, and
every pixel of it was being stretched, coloured and handed to Qt on
the GUI thread for every move. Measured by the responsiveness
harness at 2,048 px: a median move of 183.9 ms, 23 frames dropped.
:param box: ``(x0, y0, x1, y1)`` in image pixels.
:param lens: where the whole box would be drawn.
:param scale: widget pixels per image pixel inside the lens.
:returns: ``(part, rect)``, or ``(None, None)`` when none of the
box is on the canvas.
"""
x0, y0, x1, y1 = (int(v) for v in box)
area = lens.intersected(QRectF(self.canvas.rect()))
if area.isEmpty() or scale <= 0:
return None, None
left = max(x0, x0 + int(math.floor((area.left() - lens.left()) / scale)))
top = max(y0, y0 + int(math.floor((area.top() - lens.top()) / scale)))
right = min(x1, x0 + int(math.ceil((area.right() - lens.left()) / scale)))
bottom = min(y1, y0 + int(math.ceil((area.bottom() - lens.top()) / scale)))
if right <= left or bottom <= top:
return None, None
rect = QRectF(lens.left() + (left - x0) * scale,
lens.top() + (top - y0) * scale,
(right - left) * scale, (bottom - top) * scale)
return (left, top, right, bottom), rect
def _ghost_is_stale(self) -> bool:
"""Whether the box promises against a mask the canvas no longer has.
Asks nothing of the canvas: it compares what the shown result was
built against with the number :meth:`mask_generation` has reached,
and it is :meth:`paint` that brings that number up to date -- which
is where a mask edit is first seen, every edit rebinding the mask
and repainting, and no single place on the screen owning all of
them. Under Replace nothing is ghosted, so nothing goes stale.
"""
shown = self._shown
return (shown is not None and shown.request.overlap != "replace"
and shown.request.mask_token != self._mask_token)
def _ask_again(self) -> None:
"""Ask for the region under the mouse and redraw when it lands."""
self._asking = False
if self.enabled:
self.refresh()
self.canvas.update()
def _image_slice(self, box) -> Optional[QImage]:
"""The whole-image objects inside ``box``, outlined, as a picture.
``box`` is the part of the lens that is on the canvas
(:meth:`_visible_part`) and not necessarily the whole region the box
magnifies, for the reason given there: what is off the widget costs
the same to build and shows nobody anything.
The outlines are CUT FROM THE WORKER'S PICTURE of the whole field
(:attr:`_MagnifierResult.overlay`), not drawn again here: at the
largest box, outlining the part of the field on the canvas was a
boundary pass over a megapixel and more per move.
The object under the mouse -- the one a click would add -- is filled
more strongly than the rest, and THE OVERLAP RULE'S ANSWER FOR IT IS
DRAWN THE WAY THE REGION MODE DRAWS ITS OWN: what a click would add
is solid, what the rule would take away keeps a quarter of its
alpha. See :meth:`_image_promise`. The picture is kept until the
box, that object, the rule, the mask or the objects themselves
change, so repainting without moving recomputes nothing -- and
nothing here calls a model.
"""
result = self._image_result
if result is None or self._cursor is None:
return None
x0, y0, x1, y1 = (int(v) for v in box)
cx, cy = self._cursor
labels = result.labels
under = int(labels[cy, cx])
rule = _canonical_overlap_rule(self.overlap)
where = ((x0, y0, x1, y1), under, rule,
self._mask_token if rule != "replace" else 0)
cached = self._image_view
if cached is not None and cached[0] is result and cached[1] == where:
return cached[2]
if result.overlay is not None:
rgba = np.array(result.overlay[y0:y1, x0:x1], copy=True)
else:
rgba = _candidate_overlay(labels[y0:y1, x0:x1],
result.request.colour)
window = _object_window(result, under) if under > 0 else None
if window is not None:
wx0, wy0, wx1, wy1 = window
ix0, iy0 = max(x0, wx0), max(y0, wy0)
ix1, iy1 = min(x1, wx1), min(y1, wy1)
if ix1 > ix0 and iy1 > iy0:
body = labels[iy0:iy1, ix0:ix1] == under
alpha = rgba[iy0 - y0:iy1 - y0, ix0 - x0:ix1 - x0, 3]
alpha[body & (alpha < 140)] = 140
lost = self._image_promise(result, under)
if lost is not None:
gone = lost[iy0 - wy0:iy1 - wy0, ix0 - wx0:ix1 - wx0]
alpha[gone] = alpha[gone] // 4
rgba = np.ascontiguousarray(rgba)
height, width = rgba.shape[:2]
picture = QImage(rgba.data, width, height, 4 * width,
QImage.Format_RGBA8888)
self._image_view = (result, where, picture, rgba)
return picture
def _image_promise(self, result: _MagnifierResult,
label: int) -> Optional[np.ndarray]:
"""What the Overlap rule would take from whole-image object ``label``.
A whole-image click adds ONE WHOLE OBJECT, and most of it can lie
outside the box, so the rule's answer cannot be read off the box's
slice: Skip asks whether the object touches the mask ANYWHERE, and
Clip keeps its largest surviving piece, which may be off the box.
The answer is therefore computed over the object's own bounding box
(:func:`_object_window`, from the worker's one ``find_objects``
pass), against the mask there, by
:func:`spacr.qt.mask_engine._surviving_region_objects` -- the same
function, over the same window, that the click's paste runs
(:func:`_single_object`, then
:func:`spacr.qt.mask_engine._paste_region_objects`), so the promise
and the edit cannot disagree.
Its cost is the object's bounding box, once per object, rule and
mask: kept in :attr:`_image_promised`.
:param result: the whole-image result on screen.
:param label: the object under the mouse.
:returns: a bool array over the object's window, True where a click
would NOT add the pixel, or None when the rule takes nothing --
under Replace, over an empty window, or when all of it survives.
"""
rule = _canonical_overlap_rule(self.overlap)
mask = self.canvas.mask
if rule == "replace" or mask is None \
or tuple(mask.shape[:2]) != tuple(result.labels.shape[:2]):
return None
key = (label, rule, self._mask_token)
cached = self._image_promised
if cached is not None and cached[0] is result and cached[1] == key:
return cached[2]
lost = None
window = _object_window(result, label)
if window is not None:
x0, y0, x1, y1 = window
occupied = np.asarray(mask)[y0:y1, x0:x1] > 0
if occupied.any():
single = _single_object(result, label).labels
exact_ids = result.mode == cpu_modes.SECONDARY
if exact_ids:
occupied &= np.asarray(mask)[y0:y1, x0:x1] != single
kept = engine._surviving_region_objects(
single, occupied, overlap=rule,
min_area=int(result.request.min_area), preserve_ids=exact_ids)
gone = (single > 0) & (kept == 0)
lost = gone if gone.any() else None
self._image_promised = (result, key, lost)
return lost
class _FlowPane(QLabel):
"""Read-only pane for one Cellpose intermediate, scaled to fit its tab.
Ported from the standalone curation tool's ``FlowView``, which solved
the same problem: an intermediate is a picture to *look* at while
deciding where a threshold goes, and it has to stay legible when the
tab is resized. It keeps the full-resolution pixmap and rescales a
copy, so repeated resizing never compounds interpolation error the
way rescaling the displayed pixmap would.
It is deliberately not editable. The mask lives on the canvas next
door, and a second surface that could also be painted would mean two
places to look for the same object.
:param parent: parent widget; ownership only.
"""
def __init__(self, parent: Optional[QWidget] = None):
"""Build an empty flow pane, centred and with a floor on its size."""
super().__init__(parent)
self.setAlignment(Qt.AlignCenter)
self.setMinimumSize(400, 300)
self.setStyleSheet(f"background: {active_palette()['bg']};")
self.setWordWrap(True)
self._pixmap: Optional[QPixmap] = None
follow_device_ratio(self, self._rescale)
self.clear_view()
def show_rgb(self, rgb: np.ndarray) -> None:
"""Display one ``(H, W, 3)`` uint8 array."""
data = np.ascontiguousarray(np.asarray(rgb, dtype=np.uint8))
height, width = data.shape[:2]
image = QImage(data.data, width, height, 3 * width,
QImage.Format_RGB888).copy()
self._pixmap = QPixmap.fromImage(image)
self.setText("")
self._rescale()
def clear_view(self) -> None:
"""Drop the picture and say why the pane is empty."""
self._pixmap = None
self.setPixmap(QPixmap())
self.setText(FLOW_RESTING_TEXT)
def has_image(self) -> bool:
"""Whether a Cellpose run has filled this pane."""
return self._pixmap is not None
def _rescale(self) -> None:
"""Redraw the pixmap at the pane's current size.
A no-op before a pixmap is set, so a resize during construction is
harmless.
"""
if self._pixmap is None:
return
self.setPixmap(scaled_for(self._pixmap, self, self.size()))
def resizeEvent(self, event):
"""Refit the picture whenever the tab changes size."""
super().resizeEvent(event)
self._rescale()
class _ThresholdHistogramRequest(NamedTuple):
"""A whole-field histogram snapshot, independent of later panel edits.
``image`` is a private copy of the loaded pixels; ``invert`` and optional
``normalization`` (low/high percentiles) precede ``chain``. ``settings``
copies the effective Otsu-category settings, including smoothing and
correction. ``mode`` names the selected threshold and ``bright`` its
polarity. ``ticket`` cancels between preparation and threshold stages;
an active NumPy/scikit-image call finishes before cancellation is read.
"""
image: np.ndarray
mode: str
settings: dict
bright: bool
invert: bool
normalization: Optional[tuple]
chain: Any
ticket: Any
def _threshold_histogram(request: _ThresholdHistogramRequest) -> tuple:
"""Compute detector-input counts and global levels on a worker thread.
Uses the canvas's inversion, normalization and enhancement order, then
the threshold engine's smoothing and level calculation. Local methods
return no global marker: their per-pixel thresholds cannot be represented
by one vertical line. Their histogram is the smoothed input before local
thresholding (and before Local Otsu's internal 8-bit rank-filter scaling).
:returns: ``(counts, edges, levels, local)``. Algorithm errors propagate;
the preview must never silently replace the selected method by Otsu.
"""
request.ticket.check()
image = request.image
if request.invert:
image = engine.invert_normalized(image)
if request.normalization is not None:
image = engine.normalize_for_detection(image, *request.normalization)
image = detect_chain.prepare(image, request.chain, cancel=request.ticket.cancelled)
request.ticket.check()
settings = request.settings
values = engine._otsu_values(image, settings["smoothing"])
counts, edges = engine._otsu_histogram(values, bins=OTSU_HISTOGRAM_BINS)
local = settings["local"] or request.mode in engine.LOCAL_THRESHOLDS
levels = [] if local else engine._otsu_levels(
values, bright=request.bright, correction=settings["correction"],
classes=settings["classes"],
algorithm=cpu_modes.engine_algorithm(request.mode))
request.ticket.check()
return counts, edges, levels, local
class _OtsuHistogramPlot(QWidget):
"""The field's intensity histogram with the chosen level drawn on it.
A preview of the histogram with the chosen level marked, so a curator
can see where the cut falls. It is painted rather than plotted: the whole figure is a few
hundred bars and two or three vertical lines, and a chart library on this
screen would mean importing one on the path that opens Make Masks.
THE MARKER IS NOT COMPUTED HERE. It is handed in, already read from
:func:`spacr.qt.mask_engine._otsu_levels` -- the same function the detect
button's cut comes from -- because a preview that found its own level
would be a second opinion and could be right while the button was wrong.
:param counts: bar heights, as :func:`numpy.histogram` returns them.
:param edges: bin edges, one longer than ``counts``.
:param levels: the intensities the field is cut at.
:param parent: parent widget; ownership only.
"""
def __init__(self, counts, edges, levels, parent=None):
"""Keep the figure and set a size a reader can tell two humps apart in."""
super().__init__(parent)
self.counts = np.asarray(counts, dtype=np.float64)
self.edges = np.asarray(edges, dtype=np.float64)
self.levels = [float(level) for level in levels]
self.setMinimumSize(420, 220)
def level_x(self, level: float) -> float:
"""Where ``level`` falls across the plot, in widget pixels.
The same mapping the bars are drawn with, so a test can ask the
picture where it put the marker instead of trusting that it did.
Its range includes corrected levels outside the histogram's bin
edges, keeping those markers distinct from the maximum intensity.
:param level: an intensity.
:returns: the x coordinate, clamped to the plot's own width.
"""
low = min(float(self.edges[0]), *self.levels) if self.levels else float(self.edges[0])
high = max(float(self.edges[-1]), *self.levels) if self.levels else float(self.edges[-1])
width = max(1, self.width() - 1)
if high <= low:
return 0.0
fraction = (float(level) - low) / (high - low)
return max(0.0, min(1.0, fraction)) * width
def paintEvent(self, event):
"""Draw bars and threshold lines on the same intensity axis."""
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing, False)
palette = active_palette()
painter.fillRect(self.rect(), QColor(palette["bg"]))
height = max(1, self.height())
tallest = float(self.counts.max()) if self.counts.size else 0.0
if tallest > 0.0:
bar_colour = QColor(palette.get("fg", "#c8c8c8"))
bar_colour.setAlpha(160)
painter.setPen(Qt.NoPen)
painter.setBrush(QBrush(bar_colour))
for index, value in enumerate(self.counts):
tall = int(round(height * float(value) / tallest))
if tall <= 0:
continue
left = int(self.level_x(self.edges[index]))
right = int(self.level_x(self.edges[index + 1]))
painter.drawRect(QRect(left, height - tall,
max(1, right - left), tall))
pen = QPen(QColor(palette["accent"]))
pen.setWidth(2)
painter.setPen(pen)
for level in self.levels:
x = int(round(self.level_x(level)))
painter.drawLine(x, 0, x, height)
painter.end()
class _LevelsPlot(_OtsuHistogramPlot):
"""Drag the nearest black/white marker along the intensity histogram."""
cutoff_changed = Signal(int, float)
def mousePressEvent(self, event):
"""Choose the nearest cutoff; right and middle clicks do nothing."""
if event.button() != Qt.LeftButton or len(self.levels) != 2:
return
self._drag_cutoff = min(range(2), key=lambda i:
abs(self.level_x(self.levels[i]) - event.position().x()))
self._move_cutoff(event.position().x())
def mouseMoveEvent(self, event):
"""Move a held cutoff without recomputing the histogram."""
if event.buttons() & Qt.LeftButton and hasattr(self, '_drag_cutoff'):
self._move_cutoff(event.position().x())
def mouseReleaseEvent(self, event):
"""Finish a cutoff drag at the released position."""
if event.button() == Qt.LeftButton and hasattr(self, '_drag_cutoff'):
self._move_cutoff(event.position().x())
del self._drag_cutoff
def _move_cutoff(self, x):
"""Convert widget x into an intensity on the histogram's axis."""
fraction = max(0.0, min(1.0, x / max(1, self.width() - 1)))
value = float(self.edges[0] + fraction * (self.edges[-1] - self.edges[0]))
self.cutoff_changed.emit(self._drag_cutoff, value)
def _levels_histogram(image):
"""Sort finite field intensities and count bins off the GUI thread.
The sorted values provide exact percentile positions for dragged levels;
no downsampling or histogram-bin approximation changes the chosen cut.
"""
values = np.sort(image[np.isfinite(image)], axis=None)
if not values.size:
from ..i18n import tr
raise ValueError(tr('The image has no finite intensities.'))
counts, edges = np.histogram(values, bins=256)
return counts, edges, values
class _LevelsDialog(QDialog):
"""Edit black/white percentile cutoffs by histogram or intensity value.
The histogram describes the full displayed source before enhancement,
including inversion when enabled. It is computed on a worker. Changes
emit percentiles used by the existing display/detection normalization;
source pixels and masks are never edited. Closing releases the sorted
field; a late worker result cannot reopen the dialog.
"""
levels_changed = Signal(float, float)
_delivered = Signal(object)
def __init__(self, image, percentiles, parent=None):
"""Build linked percentile controls and submit the source histogram for background computation."""
super().__init__(parent)
from ..i18n import tr
self.setWindowTitle(tr('Levels'))
self.closed = False
self.ready = False
self.values = None
self.percentiles = tuple(percentiles)
layout = QVBoxLayout(self)
self.caption = QLabel(tr('Calculating image histogram…'))
self.caption.setWordWrap(True)
layout.addWidget(self.caption)
self.plot = _LevelsPlot(np.zeros(2), np.arange(3), [], self)
self.plot.setMinimumHeight(100)
self.plot.setEnabled(False)
layout.addWidget(self.plot, 1)
form = QFormLayout()
self.black, self.white = QDoubleSpinBox(), QDoubleSpinBox()
for index, control in enumerate((self.black, self.white)):
control.setDecimals(6)
control.setEnabled(False)
control.valueChanged.connect(lambda value, i=index: self._choose(i, value))
form.addRow(tr('Black cutoff'), self.black)
form.addRow(tr('White cutoff'), self.white)
layout.addLayout(form)
self.plot.cutoff_changed.connect(self._choose)
self.detect = Toggle(tr('Detect on the normalized image'))
layout.addWidget(self.detect)
note = QLabel(tr('Drag a marker or enter an intensity. Values below the black '
'cutoff become black; values above the white cutoff become white. '
'The range between them is stretched. Enable detection here to '
'use these levels before any applied enhancement. Original image '
'values and existing masks are preserved.'))
note.setWordWrap(True)
layout.addWidget(note)
buttons = QDialogButtonBox(QDialogButtonBox.Close)
self.reset = buttons.addButton(tr('Reset levels'), QDialogButtonBox.ResetRole)
self.reset.setEnabled(False)
self.reset.clicked.connect(lambda: self._publish(0.0, 100.0))
buttons.rejected.connect(self.close)
layout.addWidget(buttons)
self._delivered.connect(self._take, Qt.QueuedConnection)
self._worker = _NewestRequestWorker(_levels_histogram, self._deliver,
name='spacr-levels-histogram')
self._worker.submit(image)
self.resize(560, 520)
def _deliver(self, request, result, error):
"""Carry computation back to Qt, tolerating destruction while busy."""
try:
self._delivered.emit((result, error))
except RuntimeError:
pass
def _take(self, payload):
"""Install a completed histogram only while this editor is open."""
from ..i18n import tr
if self.closed:
return
result, error = payload
if error is not None:
self.caption.setText(tr('Could not calculate levels: {error}', error=str(error)))
return
counts, edges, self.values = result
self.ready = True
self.plot.counts, self.plot.edges = counts, edges
varying = self.values[0] < self.values[-1]
for control in (self.black, self.white):
blocked = control.blockSignals(True)
control.setRange(float(self.values[0]), float(self.values[-1]))
control.blockSignals(blocked)
control.setEnabled(bool(varying))
self.plot.setEnabled(bool(varying))
self.reset.setEnabled(True)
self.set_percentiles(*self.percentiles)
if not varying:
self.caption.setText(tr('This image has one intensity; there is no range to stretch.'))
def set_percentiles(self, low, high):
"""Follow changes from the screen without emitting another edit."""
from ..i18n import tr
self.percentiles = (float(low), float(high))
if self.values is None:
return
positions = np.asarray(self.percentiles) * (len(self.values) - 1) / 100.0
left = np.floor(positions).astype(int)
right = np.ceil(positions).astype(int)
levels = (self.values[left].astype(float) * (1 - positions + left)
+ self.values[right].astype(float) * (positions - left))
self.plot.levels = list(levels)
self.plot.update()
for control, value in zip((self.black, self.white), levels):
blocked = control.blockSignals(True)
control.setValue(float(value))
control.blockSignals(blocked)
self.caption.setText(tr('Full-field intensity histogram · black {low:.4g}, white {high:.4g}',
low=float(levels[0]), high=float(levels[1])))
def _choose(self, index, value):
"""Map an absolute intensity to its interpolated percentile rank."""
if self.values is None or self.values[-1] <= self.values[0]:
return
values = self.values
upper = int(np.searchsorted(values, value, side='left'))
if upper == 0:
percentile = 0.0
elif upper >= values.size:
percentile = 100.0
else:
a, b = float(values[upper - 1]), float(values[upper])
fraction = (float(value) - a) / (b - a) if b > a else 0.0
percentile = (upper - 1 + fraction) * 100.0 / (values.size - 1)
low, high = self.percentiles
if index == 0:
low = min(percentile, high - 0.000001)
else:
high = max(percentile, low + 0.000001)
self._publish(max(0.0, low), min(100.0, high))
def _publish(self, low, high):
"""Apply an ordered pair of percentiles to the screen and markers."""
self.set_percentiles(low, high)
self.levels_changed.emit(float(low), float(high))
def closeEvent(self, event):
"""Discard pending work and release the field held for this histogram."""
self.closed = True
self.values = None
self._worker.close(timeout=0)
super().closeEvent(event)
class _OtsuHistogramDialog(QDialog):
"""A window holding :class:`_OtsuHistogramPlot` and what it is showing.
Modeless on purpose: the point of the preview is to change a setting and
look again, and a modal window would make that a close, a change and a
reopen each time. It is also what keeps it testable -- a static modal
runs its event loop in C++ and hangs a headless run.
:param counts: histogram bar heights.
:param edges: histogram bin edges.
:param levels: the intensities the field is cut at.
:param description: how the cut was taken, for the caption.
:param local: local thresholds have no single marker to draw.
:param parent: parent widget.
:param method: selected threshold key, named in the window title.
:param pending: show indeterminate progress until the snapshot arrives.
"""
def __init__(self, counts, edges, levels, description: str,
local: bool = False, parent=None, *, method="otsu",
pending: bool = False):
"""Build the plot, the caption above it and the Close button."""
super().__init__(parent)
from ..i18n import tr
self.setWindowTitle(tr("{method} histogram", method=_magnifier_mode_label(method)))
self.description = description
self.request = None
self.ready = not pending
self.closed = False
self.error = None
layout = QVBoxLayout(self)
layout.setSpacing(SPACING["sm"])
self.caption = QLabel()
self.caption.setTextFormat(Qt.PlainText)
self.caption.setWordWrap(True)
layout.addWidget(self.caption)
self.progress = QProgressBar()
self.progress.setRange(0, 0)
self.progress.setTextVisible(False)
self.progress.setVisible(pending)
layout.addWidget(self.progress)
self.plot = _OtsuHistogramPlot(counts, edges, levels, self)
layout.addWidget(self.plot, 1)
self.plot.setVisible(not pending)
if pending:
self.caption.setText(tr("Calculating threshold histogram…"))
else:
self.show_result((counts, edges, levels, local))
note = QLabel(tr("Snapshot of the full field and settings when opened. "
"Open the histogram again after changing the image or settings."))
note.setWordWrap(True)
layout.addWidget(note)
buttons = QDialogButtonBox(QDialogButtonBox.Close)
buttons.rejected.connect(self.close)
layout.addWidget(buttons)
self.resize(520, 340)
def show_result(self, result, error=None) -> None:
"""Display a completed snapshot or its error, without a fallback marker."""
from ..i18n import tr
self.ready = True
self.error = error
self.progress.hide()
if error is not None:
self.plot.hide()
self.caption.setText(tr("Threshold histogram failed: {error}", error=str(error)))
self._fit_height()
return
counts, edges, levels, local = result
self.plot.counts = np.asarray(counts, dtype=np.float64)
self.plot.edges = np.asarray(edges, dtype=np.float64)
self.plot.levels = [float(level) for level in levels]
self.plot.show()
self.plot.update()
if local:
text = tr("Local threshold varies by pixel ({description}); no single "
"level is drawn. The histogram shows the smoothed detector "
"input before local thresholding.", description=self.description)
else:
text = tr("Level: {levels} ({description}). These are the thresholds "
"the detect button uses on the smoothed detector input.",
levels=", ".join(f"{level:.4g}" for level in levels),
description=self.description)
self.caption.setText(text)
self._fit_height()
def _fit_height(self) -> None:
"""Reserve the wrapped captions' height so they cannot overlap the plot."""
layout = self.layout()
if layout is not None:
layout.invalidate()
needed = layout.totalHeightForWidth(self.width())
if needed > 0 and self.minimumHeight() != needed:
self.setMinimumHeight(needed)
def resizeEvent(self, event):
"""Recompute the text's height as the histogram window is widened."""
super().resizeEvent(event)
self._fit_height()
def closeEvent(self, event):
"""Retire this snapshot; a running library call may finish in the background."""
self.closed = True
if self.request is not None:
self.request.ticket.cancel()
super().closeEvent(event)
def _parsed_sigmas(text: str) -> tuple:
"""The filament widths a text box holds, as numbers the engine can read.
A LIST OF SCALES IS A TEXT BOX and not a row of spin boxes, because how
many scales the ridge filter is given is part of the answer: one width
for a uniform bundle, four to cover fine tubules and thick ones at
once. So the box is parsed rather than validated -- anything that is
not a positive number is dropped, and a box that holds nothing usable
falls back to the engine's own default rather than raising on the
worker thread, where the only way to show the error would be an empty
magnifier box.
:param text: what the user typed, numbers separated by commas or spaces.
:returns: the scales, in the order typed, never empty.
"""
out = []
for piece in str(text or "").replace(",", " ").split():
try:
value = float(piece)
except ValueError:
continue
if value > 0:
out.append(value)
return tuple(out) or organelle_modes.DEFAULT_PARAMS.ridge_sigmas
def _grey_pixmap(image: np.ndarray, lower_pct: float,
upper_pct: float) -> QPixmap:
"""``image`` stretched between two percentiles, as a grey pixmap.
One conversion for both halves of the Compare window, so the raw
picture and the enhanced one are drawn by the same arithmetic and a
difference between them is a difference the chain made.
:param image: any 2-D array; float and integer fields alike.
:param lower_pct: the percentile drawn black.
:param upper_pct: the percentile drawn white.
"""
data = np.asarray(image, dtype=np.float64)
if not data.size:
return QPixmap()
low = float(np.percentile(data, lower_pct))
high = float(np.percentile(data, upper_pct))
if high <= low:
high = low + 1.0
grey = np.ascontiguousarray(
(np.clip(data, low, high) - low) / (high - low) * 255.0
).astype(np.uint8)
height, width = grey.shape[:2]
picture = QImage(grey.data, width, height, width,
QImage.Format_Grayscale8).copy()
return QPixmap.fromImage(picture)
#: Saved-layout key and initial window size for the side-by-side comparison.
#: The initial size leaves enough room to inspect both images.
COMPARE_LAYOUT_KEY = "make_masks::compare"
COMPARE_DEFAULT_SIZE = (1100, 700)
class _ComparePreview(QDialog):
"""The image as loaded beside the image the detector reads.
ONE CLICK IS THE WHOLE FEATURE. A chain of eight optional steps is a
chain a curator cannot judge from the objects alone -- a threshold that
found nothing may have been given an image with nothing left in it --
and the cheapest way to say which it was is to put the two pictures
next to each other under the list of what ran.
IT IS A WINDOW TO LOOK IN, so it is resizable, it remembers the size it
was left at (:data:`COMPARE_LAYOUT_KEY`), the two pictures grow with it
rather than sitting at a fixed size, and both can be zoomed into. THE
TWO ZOOMS ARE ONE ZOOM (:meth:`spacr.qt.widgets.zoom_view.
ZoomableImageView.link_to`): a difference between the pictures is what
the window is for, and a difference in where they are pointing is the
one difference that is not information.
Modeless, for :class:`_OtsuHistogramDialog`'s reason: the point is to
change a step and look again.
:param raw: the region as loaded (or inverted, as it is drawn).
:param enhanced: the same region after the chain, or None while its
worker runs. Cancel closes a pending comparison without blocking.
:param steps: the steps that ran, in words.
:param parent: parent widget.
"""
def __init__(self, raw: np.ndarray, enhanced: Optional[np.ndarray],
steps: str, parent=None):
"""Build the two linked views, the caption over them and the tools."""
from ..i18n import tr
from ..widgets.zoom_view import ZoomableImageView
super().__init__(parent)
self.setWindowTitle(tr("Raw and enhanced"))
self.setSizeGripEnabled(True)
layout = QVBoxLayout(self)
layout.setSpacing(SPACING["sm"])
self.caption = QLabel(steps)
self.caption.setWordWrap(True)
layout.addWidget(self.caption)
row = QHBoxLayout()
self.views: List[ZoomableImageView] = []
for title, array in ((tr("As loaded"), raw),
(tr("As the detector reads it"), enhanced)):
column = QVBoxLayout()
heading = QLabel(title)
heading.setObjectName("Muted")
column.addWidget(heading)
view = ZoomableImageView(self)
view.setMinimumSize(200, 200)
if array is not None:
view.set_pixmap(_grey_pixmap(array, 1.0, 99.9))
view.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.views.append(view)
column.addWidget(view, 1)
row.addLayout(column, 1)
layout.addLayout(row, 1)
self.views[0].link_to(self.views[1])
tools = QHBoxLayout()
self.hint = QLabel(tr(
"Scroll to zoom, drag to pan. Both pictures move together."))
self.hint.setObjectName("Muted")
self.hint.setWordWrap(True)
tools.addWidget(self.hint, 1)
for caption, action in ((tr("Zoom out"), lambda: self.zoom(1 / 1.4)),
(tr("Zoom in"), lambda: self.zoom(1.4)),
(tr("Fit"), self.fit)):
button = QPushButton(caption)
button.clicked.connect(action)
tools.addWidget(button)
layout.addLayout(tools)
self._buttons = QDialogButtonBox(
QDialogButtonBox.Cancel if enhanced is None else QDialogButtonBox.Close)
self._buttons.rejected.connect(self.close)
layout.addWidget(self._buttons)
self.resize(*_remembered_compare_size())
def _show_result(self, enhanced, caption):
"""Adopt a finished comparison, or explain why it was discarded."""
if enhanced is not None:
self.views[1].set_pixmap(_grey_pixmap(enhanced, 1.0, 99.9))
self.fit()
self.caption.setText(caption)
self._buttons.setStandardButtons(QDialogButtonBox.Close)
def zoom(self, factor: float) -> None:
"""Zoom both pictures by ``factor``; the link does the second one."""
if self.views:
self.views[0].zoom_by(factor)
def fit(self) -> None:
"""Put both pictures back to fitting their pane."""
if self.views:
self.views[0].fit()
def closeEvent(self, event): # noqa: N802
"""Remember the size the window was left at, then close.
On the way out rather than on every resize: a drag is a hundred
resize events and this writes to the preference store.
"""
_remember_compare_size(self.width(), self.height())
super().closeEvent(event)
def _remembered_compare_size() -> tuple:
"""``(width, height)`` the Compare window was last left at."""
try:
from ..preferences import get_section_layout
sizes = get_section_layout(COMPARE_LAYOUT_KEY).get("sizes") or ()
except Exception: # noqa: BLE001
LOG.debug("could not read the Compare window size", exc_info=True)
sizes = ()
if len(sizes) == 2 and all(int(value) > 200 for value in sizes):
return (int(sizes[0]), int(sizes[1]))
return COMPARE_DEFAULT_SIZE
def _remember_compare_size(width: int, height: int) -> None:
"""Keep the Compare window's size for the next time it is opened."""
try:
from ..preferences import set_section_layout
set_section_layout(COMPARE_LAYOUT_KEY,
sizes=(int(width), int(height)))
except Exception: # noqa: BLE001
LOG.debug("could not keep the Compare window size", exc_info=True)
[docs]
def fold_description(key: str) -> tuple:
"""``(name, description, stage)`` for a folded module.
The app registry answers while it still holds the module's row; once the
row has been dropped — which is what folding a module ends in — the
answer comes from :data:`FOLD_FALLBACK`, so the button goes on carrying
the name, the sentence and the maturity colour its tile had.
:param key: the app registry key of the folded module; an unknown key gives
empty strings.
"""
from .. import app as app_module
name = description = stage = ""
for row in getattr(app_module, "APPS", ()):
if row and row[0] == key:
name, description = row[1] or "", row[2] or ""
stage = app_module.app_stage(key)
break
fallback = FOLD_FALLBACK.get(key, ("", "", ""))
return (name or fallback[0], description or fallback[1],
stage or fallback[2])
[docs]
class FoldedModulePanel(QWidget):
"""One folded module, as the whole screen it was, plus what the host adds.
A fold that reimplemented the module it replaced would keep whatever the
person doing the folding happened to think of and quietly drop the rest.
So the button opens the module's OWN widget: every control, every worker,
every drop target it had as a tile is what arrives, and the only thing
that changed is where it is opened from.
IT IS A PAGE ON THIS SCREEN, not a window over it. A window is the last
resort for a fold, and it is what this becomes only when the host has no
body to make pages out of — see
:func:`spacr.qt.screens.map_barcodes.show_as_page`. As a page it is
closed by the tab's own close button, so the standard Close button
below belongs to the window shape alone and is added with it.
:param key: the folded module's registry key.
:param screen: the module's own widget, already built.
:param title: the page's caption and the window title — the module's
name.
:param actions: extra buttons for the button row, each
``(label, tooltip, callback)``. This is where a capability the folded
module lacks and its host has arrives.
:param parent: parent widget; ownership only.
"""
def __init__(self, key: str, screen: QWidget, title: str,
parent: Optional[QWidget] = None, actions=()):
"""Wrap one folded module's screen with a title and its actions.
:param key: the module's registry key.
:param screen: the screen to wrap.
:param title: the caption over it.
:param parent: parent widget.
:param actions: extra buttons for the panel's own row.
"""
super().__init__(parent)
self.app_key = key
self.screen = screen
self.setObjectName("FoldedModulePanel")
self.setWindowTitle(title)
column = QVBoxLayout(self)
column.setContentsMargins(0, 0, 0, SPACING["sm"])
column.setSpacing(SPACING["sm"])
column.addWidget(screen, 1)
self.buttons = QDialogButtonBox(QDialogButtonBox.NoButton, self)
#: Label -> button, for the extra actions.
self.actions: dict = {}
for label, tooltip, callback in actions:
button = self.buttons.addButton(label,
QDialogButtonBox.ActionRole)
button.setToolTip(tooltip)
button.clicked.connect(
lambda _checked=False, cb=callback: cb())
self.actions[label] = button
self.buttons.rejected.connect(self.close)
self.buttons.setVisible(bool(self.actions))
column.addWidget(self.buttons)
#: The file dialogs the bridge opens. A mask is a label image and an image is
#: whatever the microscope wrote, so the two filters are not the same one.
_MASK_FILTER = "Masks (*.tif *.tiff *.npy *.png);;All files (*)"
_IMAGE_FILTER = "Images (*.tif *.tiff *.npy *.png *.jpg);;All files (*)"
[docs]
class NapariBridgeScreen(QWidget):
"""Exchange an image and label mask with an interactive napari viewer.
napari is imported only when the viewer is opened, and spaCR's existing
Qt event loop remains active. Corrected labels are validated and recorded
in the same curation ledger used by the Curate screen. The corresponding
headless operations are available from :mod:`spacr.napari_bridge`.
:param parent: parent widget.
"""
#: A field was opened in napari. Carries the mask path.
opened = Signal(str)
#: A correction came back and was written. Carries the mask path.
corrected = Signal(str)
def __init__(self, parent: Optional[QWidget] = None) -> None:
"""Build the bridge's two path rows and its launch button.
:param parent: parent widget.
"""
super().__init__(parent)
self.setObjectName("NapariBridge")
self._viewer: Any = None
self._handoff: Any = None
outer = QVBoxLayout(self)
outer.setContentsMargins(SPACING["lg"], SPACING["lg"],
SPACING["lg"], SPACING["lg"])
outer.setSpacing(SPACING["sm"])
title = QLabel("Napari Bridge", self)
title.setObjectName("DisplayHeading")
outer.addWidget(title)
intro = QLabel(
"Open an image and label mask in napari for manual correction. "
"When you import the corrected labels, spaCR validates the mask "
"and records the change in the curation ledger.", self)
intro.setObjectName("Muted")
intro.setWordWrap(True)
outer.addWidget(intro)
self._mask_edit = QLineEdit(self)
self._mask_edit.setPlaceholderText("Label mask (.tif, .npy)")
outer.addLayout(self._path_row("Mask", self._mask_edit,
self._choose_mask))
self._image_edit = QLineEdit(self)
self._image_edit.setPlaceholderText(
"Image to show underneath (optional)")
outer.addLayout(self._path_row("Image", self._image_edit,
self._choose_image))
buttons = QHBoxLayout()
buttons.setSpacing(SPACING["sm"])
self.open_button = QPushButton("Open in napari", self)
self.open_button.setToolTip(
"Open the field in a napari window. spaCR stays running.")
self.open_button.clicked.connect(self.open_in_napari)
buttons.addWidget(self.open_button)
self.take_button = QPushButton("Take the mask back", self)
self.take_button.setToolTip(
"Read the corrected labels out of napari, write them back and "
"record the correction")
self.take_button.setEnabled(False)
self.take_button.clicked.connect(self.take_mask_back)
buttons.addWidget(self.take_button)
self.close_button = QPushButton("Close viewer", self)
self.close_button.setEnabled(False)
self.close_button.clicked.connect(self.close_viewer)
buttons.addWidget(self.close_button)
buttons.addStretch(1)
outer.addLayout(buttons)
self.status = QPlainTextEdit(self)
self.status.setObjectName("NapariBridgeStatus")
self.status.setReadOnly(True)
self.status.setPlaceholderText(
"Choose a mask and press Open in napari.")
mark_surface(self.status)
outer.addWidget(self.status, 1)
from ..dnd import install_for
install_for(self, "napari_bridge")
def _path_row(self, label: str, edit: QLineEdit, chooser) -> QHBoxLayout:
"""One labelled path field with a browse button beside it.
:param label: the caption.
:param edit: the field itself.
:param chooser: what the browse button runs.
:returns: the assembled row.
"""
row = QHBoxLayout()
row.setSpacing(SPACING["sm"])
caption = QLabel(label, self)
caption.setMinimumWidth(56)
row.addWidget(caption)
row.addWidget(edit, 1)
browse = QPushButton("Browse…", self)
browse.clicked.connect(chooser)
row.addWidget(browse)
return row
def _choose_mask(self) -> None:
"""Ask for a label mask and put it in the field."""
path, _ = QFileDialog.getOpenFileName(
self, "Open a label mask", self._mask_edit.text().strip(),
_MASK_FILTER)
if path:
self._mask_edit.setText(path)
self.describe_mask(path)
def _choose_image(self) -> None:
"""Ask for an image and put it in the field."""
path, _ = QFileDialog.getOpenFileName(
self, "Open the image underneath",
self._image_edit.text().strip(), _IMAGE_FILTER)
if path:
self._image_edit.setText(path)
[docs]
def set_paths(self, mask: str = "", image: str = "") -> None:
"""Set the mask and optional source-image paths."""
if mask:
self._mask_edit.setText(str(mask))
if image:
self._image_edit.setText(str(image))
[docs]
def mask_path(self) -> str:
"""Return the mask path entered in the form."""
return self._mask_edit.text().strip()
[docs]
def image_path(self) -> str:
"""Return the optional source-image path entered in the form."""
return self._image_edit.text().strip()
[docs]
def say(self, text: str, *, append: bool = False) -> str:
"""Display a status message and return the complete displayed text.
:param text: the message; converted to ``str``.
:param append: add the message below the current text, after a blank
line, instead of replacing it.
"""
text = str(text)
if append and self.status.toPlainText():
self.status.setPlainText(
f"{self.status.toPlainText()}\n\n{text}")
else:
self.status.setPlainText(text)
return self.status.toPlainText()
[docs]
def describe_mask(self, path: str = "") -> str:
"""Describe a mask file and its recorded curation state."""
path = path or self.mask_path()
if not path or not os.path.isfile(path):
return self.say("Choose a mask file first.")
try:
from ...napari_bridge import load_handoff
handoff = load_handoff(path, self.image_path())
except Exception as exc:
return self.say(f"Could not read {os.path.basename(path)}: {exc}")
return self.say(handoff.describe())
[docs]
def open_in_napari(self) -> Any:
"""Open the selected image and mask in napari.
:returns: The napari viewer, or ``None`` if validation or startup
fails. Missing optional dependencies are reported in the status
pane.
"""
path = self.mask_path()
if not path or not os.path.isfile(path):
self.say("Choose a mask file first.")
return None
try:
from ...napari_bridge import (NapariExtraMissing, load_handoff,
open_in_napari)
except ImportError as exc: # pragma: no cover - broken tree
self.say(f"Could not load the napari bridge: {exc}")
return None
try:
handoff = load_handoff(path, self.image_path())
except Exception as exc:
self.say(f"Could not read {os.path.basename(path)}: {exc}")
return None
try:
viewer = open_in_napari(handoff)
except NapariExtraMissing as exc:
self.say(str(exc))
return None
except Exception as exc:
LOG.exception("could not open napari")
self.say(f"napari could not open this field: {exc}")
return None
self._viewer = viewer
self._handoff = handoff
self.take_button.setEnabled(True)
self.close_button.setEnabled(True)
self.say(f"{handoff.describe()}\n\nThe field is open in napari. "
f"After correcting the labels, return to spaCR and select "
f"Take the mask back; nothing is written until you do.")
self.opened.emit(handoff.mask_path)
return viewer
[docs]
def take_mask_back(self):
"""Import corrected labels and record the mask correction.
:returns: :class:`spacr.napari_bridge.CorrectionResult`, or ``None``
if no active handoff exists or validation fails.
"""
if self._viewer is None or self._handoff is None:
self.say("Open a field in napari first.")
return None
from ...napari_bridge import labels_from_viewer, write_back
try:
corrected = labels_from_viewer(self._viewer,
name=self._handoff.name)
except Exception as exc:
self.say(str(exc))
return None
try:
result = write_back(self._handoff.mask_path, corrected,
original=self._handoff.mask)
except Exception as exc:
self.say(str(exc))
return None
self.say(result.describe(), append=False)
if result.written:
self._handoff = self._reloaded(result)
self.corrected.emit(result.mask_path)
return result
def _reloaded(self, result) -> Any:
"""The handoff, with the mask that was just written."""
import dataclasses
return dataclasses.replace(self._handoff, mask=result.mask)
[docs]
def close_viewer(self) -> None:
"""Close the active napari viewer, if present."""
viewer = self._viewer
self._viewer = None
self._handoff = None
self.take_button.setEnabled(False)
self.close_button.setEnabled(False)
if viewer is not None:
try:
viewer.close()
except Exception:
LOG.debug("napari viewer would not close", exc_info=True)
[docs]
def viewer(self) -> Any:
"""Return the active napari viewer, or ``None``."""
return self._viewer
[docs]
def closeEvent(self, event): # noqa: N802 - Qt name
"""Stop background work and unlink before going away.
:param event: the Qt close event.
"""
self.close_viewer()
super().closeEvent(event)
[docs]
class ObjectFilterList(QWidget):
"""Make Masks' object filters: one row per regionprop the user added.
The Filter category used to hold four fixed boxes -- minimum
and maximum area, minimum and maximum mean intensity. It now starts
EMPTY, and "Add a filter" offers every scalar property
:func:`skimage.measure.regionprops` computes (see
:func:`mask_engine.filter_properties`); each added row carries the
property, a minimum, a maximum and a Remove button. A blank bound is
off. The old four are two of the rows a user can add: ``area`` and
``intensity_mean``.
The intensity statistics are offered only while an intensity image is
open (:meth:`set_intensity_available`), so a property that cannot be
measured is never offered, rather than offered and refused later.
:meth:`filters` is the serialised list -- the same one Mask generation
reads from ``object_filters`` -- and :attr:`changed` fires whenever it
may have changed, which is what applies the list live.
"""
changed = Signal()
row_added = Signal(object)
def __init__(self, parent=None):
"""Build the Add control and the empty row list."""
from ..i18n import tr
super().__init__(parent)
self._rows: List[dict] = []
self._intensity = False
column = QVBoxLayout(self)
column.setContentsMargins(0, 0, 0, 0)
column.setSpacing(SPACING["xs"])
self._rows_layout = QVBoxLayout()
self._rows_layout.setSpacing(SPACING["xs"])
column.addLayout(self._rows_layout)
adder = QHBoxLayout()
self.property_box = QComboBox()
self.property_box.setToolTip(tr(
"The scikit-image regionprop the next filter row judges objects "
"by. Intensity statistics are listed only while an image is "
"open, since they read the raw pixel values."))
self.add_button = QPushButton(tr("Add a filter"))
self.add_button.setCursor(Qt.PointingHandCursor)
self.add_button.setToolTip(tr(
"Add a row for the property on the left, with a minimum and a "
"maximum. A blank bound is off; an object outside a bound is "
"hidden, and removing the row brings it back."))
self.add_button.clicked.connect(self._on_add)
adder.addWidget(self.property_box, 1)
adder.addWidget(self.add_button)
column.addLayout(adder)
self._offer()
[docs]
def set_intensity_available(self, available: bool) -> None:
"""Offer the intensity properties only when an image is open.
:param available: whether an intensity image is open.
"""
self._intensity = bool(available)
self._offer()
def _offer(self) -> None:
"""Fill the property box with what can be measured right now."""
chosen = self.property_box.currentText()
names = engine.filter_properties(intensity=self._intensity)
self.property_box.blockSignals(True)
self.property_box.clear()
self.property_box.addItems(list(names))
if chosen in names:
self.property_box.setCurrentText(chosen)
self.property_box.blockSignals(False)
self.add_button.setEnabled(bool(names))
[docs]
def offered(self) -> List[str]:
"""The properties the Add control offers now."""
return [self.property_box.itemText(i)
for i in range(self.property_box.count())]
def _on_add(self) -> None:
"""Add a row for the property the box shows."""
name = self.property_box.currentText()
if name:
self.add_filter(name)
def _bound_edit(self, value, placeholder: str) -> QLineEdit:
"""One bound's box: a number or blank, with blank meaning off."""
from PySide6.QtCore import QLocale
from PySide6.QtGui import QDoubleValidator
edit = QLineEdit()
validator = QDoubleValidator(edit)
validator.setLocale(QLocale.c())
validator.setNotation(QDoubleValidator.StandardNotation)
edit.setValidator(validator)
edit.setPlaceholderText(placeholder)
edit.setText("" if value is None else format(float(value), ".12g"))
edit.editingFinished.connect(self.changed.emit)
return edit
[docs]
def add_filter(self, name, minimum=None, maximum=None, *,
notify: bool = True) -> dict:
"""Add one row; return it as ``{widget, property, min, max, remove}``.
:param name: the regionprop the row filters on.
:param minimum: the lower bound, or ``None`` for none.
:param maximum: the upper bound, or ``None`` for none.
:param notify: emit ``changed`` once the row is added.
:raises ValueError: when ``name`` is not a scalar regionprop, or is an
intensity property while no intensity image is open.
"""
from ..i18n import tr
name = engine.canonical_property(name)
if name not in self.offered():
raise ValueError(tr(
"{name} measures pixel values, and no intensity image is "
"open, so it cannot filter this mask.", name=name))
widget = QWidget()
line = QHBoxLayout(widget)
line.setContentsMargins(0, 0, 0, 0)
line.setSpacing(SPACING["xs"])
label = QLabel(name)
label.setToolTip(tr(
"The regionprop this row judges each object by, measured once "
"per mask together with every other row."))
low = self._bound_edit(minimum, tr("no minimum"))
low.setToolTip(tr(
"Hide objects whose value is below this. Blank is no minimum; "
"an object equal to the bound is kept."))
high = self._bound_edit(maximum, tr("no maximum"))
high.setToolTip(tr(
"Hide objects whose value is above this. Blank is no maximum; "
"an object equal to the bound is kept."))
remove = QPushButton(tr("Remove"))
remove.setCursor(Qt.PointingHandCursor)
remove.setToolTip(tr(
"Remove this filter. The objects only it was hiding come back."))
line.addWidget(label, 1)
line.addWidget(low)
line.addWidget(high)
line.addWidget(remove)
row = {"widget": widget, "property": name, "min": low, "max": high,
"remove": remove}
remove.clicked.connect(lambda: self.remove_filter(self._rows.index(row)))
self._rows.append(row)
self._rows_layout.addWidget(widget)
self.row_added.emit(widget)
if notify:
self.changed.emit()
return row
[docs]
def set_bounds(self, index: int, minimum=None, maximum=None) -> None:
"""Set row ``index``'s bounds as if typed, and apply them.
:param index: the row, in the order rows were added.
:param minimum: the lower bound, or ``None`` to clear it.
:param maximum: the upper bound, or ``None`` to clear it.
"""
row = self._rows[index]
row["min"].setText("" if minimum is None else format(float(minimum), ".12g"))
row["max"].setText("" if maximum is None else format(float(maximum), ".12g"))
self.changed.emit()
[docs]
def set_filter(self, name, minimum=None, maximum=None) -> None:
"""Set the bounds of the first row for ``name``, adding it if absent.
:param name: the regionprop, current or legacy spelling.
:param minimum: the lower bound, or ``None`` to clear it.
:param maximum: the upper bound, or ``None`` to clear it.
"""
name = engine.canonical_property(name)
index = next((i for i, row in enumerate(self._rows)
if row["property"] == name), None)
if index is None:
self.add_filter(name, notify=False)
index = len(self._rows) - 1
self.set_bounds(index, minimum, maximum)
[docs]
def remove_filter(self, index: int) -> None:
"""Remove row ``index``; the objects only it hid come back.
:param index: the row, in the order rows were added.
"""
row = self._rows.pop(index)
row["widget"].hide()
row["widget"].setParent(None)
row["widget"].deleteLater()
self.changed.emit()
[docs]
def rows(self) -> List[dict]:
"""The rows, in the order they were added."""
return list(self._rows)
[docs]
def filters(self) -> List[dict]:
"""The rows as the serialised filter list the engine and a run read.
:raises ValueError: when a row's minimum is above its maximum.
"""
return engine.normalise_filters([
{"property": row["property"], "min": row["min"].text(),
"max": row["max"].text()} for row in self._rows])
[docs]
def set_filters(self, filters) -> None:
"""Replace every row with ``filters``, a list or a legacy bounds dict.
A dict of the old four bounds (``min_area`` and the rest) is migrated
by :func:`mask_engine.legacy_filters`, so a saved state from before
the filter list opens as the rows it meant.
:param filters: a filter list in any form
:func:`mask_engine.normalise_filters` accepts, or the legacy dict.
"""
if isinstance(filters, dict) and set(filters) <= set(engine.FILTER_BOUNDS):
filters = engine.legacy_filters(**filters)
entries = engine.normalise_filters(filters)
while self._rows:
row = self._rows.pop()
row["widget"].setParent(None)
row["widget"].deleteLater()
for entry in entries:
self.add_filter(entry["property"], entry["min"], entry["max"],
notify=False)
self.changed.emit()
[docs]
class MakeMasksScreen(QWidget):
"""Qt widget for the Make Masks app — the successor to Tk ModifyMaskApp.
Owns the canvas, the tools panel, and the file-navigation state; see
the module docstring for the full feature list.
:param parent: parent widget.
"""
_histogram_delivered = Signal(object)
_detection_delivered = Signal(object)
_comparison_delivered = Signal(object)
_uncertainty_delivered = Signal(object)
def __init__(self, parent: Optional[QWidget] = None):
"""Build the editor, its canvas and its tool panel.
:param parent: parent widget.
"""
super().__init__(parent)
self._folder: str = ""
self._image_files: List[str] = []
self._field_folders: Optional[List[str]] = None
#: The terminal-built session this screen is working through, or
#: ``None`` when the folder was opened from the file dialog. Set by
#: :meth:`open_queue`; what makes a save reach
#: ``curate_status.csv``.
self._queue = None
self._blind: Optional[dict] = None
#: A copy of the mask the current field opened with, and whether it
#: was read from the file a save would write. Together they are what
#: lets a save that changed nothing leave that file alone.
self._loaded_mask: Optional[np.ndarray] = None
self._loaded_from_save_path = False
self._last_saved_mask: Optional[np.ndarray] = None
#: The masks folder of a sibling-layout session, which is beside the
#: images rather than beneath them; ``None`` means ``<folder>/masks``.
#: Set with the folder by :meth:`_open_folder`, so no field of one
#: set is ever read or saved against another set's masks.
self._masks_dir: Optional[str] = None
#: What a terminal-built session had to say when it opened, still
#: waiting for its first field to land. A field large enough to load
#: off the GUI thread arrives after :meth:`open_queue` has returned,
#: and its status line would otherwise replace the notice before
#: anybody could read it. Emptied once shown, and by any other
#: folder being opened.
self._session_notice: str = ""
self._current_index: int = 0
self._history = engine.MaskHistory(capacity=25)
self._box_history = engine.MaskHistory(capacity=25)
self._box_classes = ['object']
self._box_field = None
self._box_source_sha256 = None
self._boxes_dirty = False
self._box_load_error = None
#: The ledger for the field on screen, seeded from any sidecar
#: already beside its mask so a second editing session appends to
#: the first one's record instead of replacing it.
self._log: Optional[CurationLog] = None
self._load_token = 0
#: Fields cut out of the one on screen this visit, in the order
#: they were cut. Non-empty means the field on screen is a parent:
#: it is retired the moment the user leaves it — see
#: :meth:`finish_recrop`.
self._recrop_children: List[str] = []
self._load_worker: Optional[_MaskLoadWorker] = None
self._pending_load = None
self._loading = False
self._detection_worker = None
self._detection_request = None
self._detection_delivered.connect(self._take_detection)
self._uncertainty_worker = None
self._uncertainty_request = None
self._uncertainty_pane = None
self._uncertainty_result = None
self._uncertainty_delivered.connect(self._take_uncertainty)
self._comparison_worker = None
self._comparison_request = None
self._comparison_serial = 0
self._comparison_delivered.connect(self._take_comparison)
#: Folded module key -> the module's own screen, built the first time
#: its button is pressed and kept afterwards so a second press finds
#: the paths, models and results the first one left.
self._fold_screens: dict[str, QWidget] = {}
#: Folded module key -> the window that screen lives in.
#: Folded module key -> the panel that screen lives in,
#: which is a page on this screen wherever it can be one.
self._fold_dialogs: dict[str, FoldedModulePanel] = {}
#: What this screen's own page is called once a folded module
#: puts a page beside it. Named here because this screen is not
#: the generic settings form and carries no registry key to be
#: looked up by.
self._fold_page_title = HEADER_TITLE
set_a_sheeted_widgets_own_rule(self, "")
ensure_widget_qss_applied(MAKE_MASKS_QSS_NAME, root=self)
self._build_ui()
self._install_shortcuts()
self._sync_button_states()
try:
from ..dnd import install_dropzone
from ..dnd_handlers import MakeMasksDropHandler
install_dropzone(self, MakeMasksDropHandler(), self)
except Exception:
pass
from ..widgets.make_masks_help import install_make_masks_help
install_make_masks_help(self)
self._take_any_terminal_queue()
def _take_any_terminal_queue(self) -> bool:
"""Open the queue ``spacr-make-masks`` handed over, if there is one.
The terminal entry point hands over here. ``spacr-make-masks``
reads the folder, builds the session and leaves it in
:mod:`spacr.cli_make_masks`; the first screen built in that process
takes it. The import is of a CLI module that pulls argparse and
:mod:`spacr.curation_queue` and no Qt, and it is done here rather
than at module scope so that a screen opened the ordinary way pays
for nothing.
:returns: whether a handed-over queue was opened.
"""
try:
from ...cli_make_masks import take_handover
except Exception: # noqa: BLE001
LOG.debug("no terminal queue handover available", exc_info=True)
return False
queue = take_handover()
if queue is None:
return False
return self.open_queue(queue)
[docs]
def open_queue(self, queue) -> bool:
"""Open the fields a curation session offers, in the order it offers.
The session decides WHICH fields and in WHAT ORDER -- reviewed ones
already dropped, ``--limit`` already applied -- and this screen shows
them. All three layouts are edited where they lie:
* ``nested`` opens the queue folder, masks in ``<folder>/masks``;
* ``sibling`` opens ``<folder>/images`` and reads and saves the
masks in ``<folder>/masks`` beside it, never ``images/masks``;
* ``seg`` opens the queue folder with the ``_seg.npy`` bundles as
its fields, each saved back into itself.
What the session had to say -- a ``prob`` or ``easy`` order that
fell back for want of scores, fields the scores do not name, a
resume record beside the folder rather than in it -- is put on
screen with it, not only in the terminal that started it. It stays
on the status line after the first field loads, including a field
large enough to load in the background, which lands after this
returns.
:param queue: a :class:`spacr.curation_queue.CurationQueue`.
:returns: whether the editor is now on that session.
"""
from ...curation_queue import LAYOUT_NESTED, LAYOUT_SEG, LAYOUT_SIBLING
layout = queue.layout
kind = getattr(layout, "kind", None)
masks_dir: Optional[str] = None
if kind == LAYOUT_SEG:
folder = str(layout.folder)
files = [item.bundle.name for item in queue.items
if item.bundle is not None]
elif kind in (LAYOUT_NESTED, LAYOUT_SIBLING):
folder = str(layout.images_dir)
files = [item.image.name for item in queue.items
if item.image is not None]
if kind == LAYOUT_SIBLING:
masks_dir = str(layout.masks_dir)
else:
LOG.warning("Make Masks cannot edit the %s layout of %s",
kind, queue.folder)
return False
if not files:
LOG.info("%s has nothing left to curate", queue.folder)
return False
if not self._open_folder(folder, files=files, masks_dir=masks_dir):
return False
self._queue = queue
self._sync_button_states()
notices = tuple(getattr(queue, "notices", ()) or ())
self._src_label.setText(
f"{queue.folder} -- {len(files)} to curate this session, "
f"{queue.order_phrase}")
self._session_notice = " ".join((queue.describe(),) + notices)
self._show_session_notice(keep=self._loading)
return True
def _show_session_notice(self, *, keep: bool = False) -> None:
"""Put the session's opening notice after what the status line says.
:param keep: hold the notice for the field still loading in the
background, so it is shown again when that field lands.
"""
notice = self._session_notice
if not notice:
return
if not keep:
self._session_notice = ""
current = self._status_label.text()
self._status_label.setText(f"{current} {notice}" if current
else notice)
def _layout_kwargs(self) -> dict:
"""What every mask read and write passes for this folder's layout.
Empty for the nested layout, so those calls are exactly what they
were before a sibling set could be opened.
:returns: ``{"masks_dir": ...}`` for a sibling session, else ``{}``.
"""
return {"masks_dir": self._masks_dir} if self._masks_dir else {}
def _note_curated(self, filename: str,
n_objects: Optional[int] = None) -> None:
"""Record a saved field as done in the session's resume record.
Only when the folder came from :meth:`open_queue`: a folder opened
from the file dialog is not a queue and must not grow a status file
it was never asked for. A record that cannot be written is logged
and swallowed, because the mask itself is already safely on disk and
losing the session's place is the smaller failure of the two.
:param filename: the image file that was just saved.
:param n_objects: how many objects the saved mask had, if known.
"""
if self._queue is None:
return
from ...curation_queue import mark_state
stem = engine.field_stem(filename)
folder = self._queue.folder
try:
mark_state(folder, stem, "done", n_objects=n_objects)
except Exception: # noqa: BLE001
LOG.warning("could not record %s as done in the curation queue",
stem, exc_info=True)
def _build_ui(self):
"""Lay out the canvas, the tool panel and the navigation row.
The bottom row holds the toolbar on the left (Open folder, Organize for
Measure, Load test data, Uncertainty) and the curation buttons on the
right, under the console (Discard, Keep, Skip, Blind, ROIs, Upload
data). Save mask, Prev and Next share the scrolling editor action
toolbar above with detection, undo and redo. The status line under the
image never widens the pane, since a long status would otherwise push
the splitter and resize the canvas mid-edit.
"""
from .. import screens as _screens_package
outer = QVBoxLayout(self)
outer.setContentsMargins(SPACING["lg"], SPACING["lg"],
SPACING["lg"], SPACING["lg"])
outer.setSpacing(SPACING["md"])
self._header = ModuleHeader(
HEADER_TITLE,
instruction=HEADER_INSTRUCTION,
app_key=APP_KEY,
)
self._src_label = QLabel("No folder selected — click Open folder…")
self._src_label.setObjectName("SubtitleSmall")
self._src_label.setSizePolicy(QSizePolicy.Maximum,
QSizePolicy.Preferred)
self._src_label.setMinimumWidth(0)
self._header.add_trailing(self._src_label)
self._folds = self._build_fold_strip()
_screens_package._breathe_while_a_window_opens()
self._header.add_trailing(self._folds)
outer.addWidget(self._header)
outer.addWidget(Divider())
self._tool_row = self._build_tool_row()
_screens_package._breathe_while_a_window_opens()
outer.addWidget(self._tool_row)
from ..i18n import tr
self._invert_warning = QLabel(tr(INVERT_WARNING_TEXT))
self._invert_warning.setObjectName(INVERT_WARNING_NAME)
self._invert_warning.setWordWrap(True)
self._invert_warning.hide()
outer.addWidget(self._invert_warning)
self._body_stack = QStackedWidget()
self._empty_state = EmptyState(
title="Open a folder of images to edit masks",
subtitle=(
"Pick a folder that contains microscopy images "
"(.tif / .png / .jpg). Any existing masks in a `masks/` "
"subfolder are loaded; new masks save back there as "
"labeled uint16 TIFFs."
),
icon=iconset.accent_icon("brush"),
cta_label="Open folder…",
on_action=self._on_pick_folder,
)
self._body_stack.addWidget(self._empty_state)
from ..widgets.collapsible_splitter import CollapsibleSplitter, EDGE
self._body_splitter = CollapsibleSplitter(
Qt.Horizontal, persist_key="make_masks::body")
self._canvas = _MaskCanvas()
self._canvas.stroke_started.connect(self._on_stroke_started)
self._canvas.stroke_finished.connect(self._on_stroke_finished)
self._canvas.boxes_changed.connect(self._on_boxes_changed)
self._canvas.box_selected.connect(self._on_box_selected)
self._canvas.zoom_changed.connect(self._on_zoom_changed)
self._canvas.recrop_requested.connect(self._on_recrop_requested)
self._magnifier = _LiveMagnifier(
self._canvas, self, load_model=self._cellpose_model,
context=self._magnifier_context)
self._canvas.magnifier = self._magnifier
self._magnifier.commit_ready.connect(self._commit_magnifier_result)
self._magnifier.remove_requested.connect(self._remove_magnifier_object)
self._magnifier.drag_ready.connect(self._apply_magnifier_drag)
#: The mask a magnifier drag pastes onto, and the last one it showed.
self._drag_base = self._drag_shown = None
self._manual_id_history = {}
#: The Otsu histogram preview while it is open, kept so pressing
#: the button twice reuses one window rather than stacking them and
#: so the screen can take it down with itself.
self._otsu_histogram_dialog: Optional[QDialog] = None
self._levels_dialog = None
self._histogram_worker = None
self._histogram_delivered.connect(self._take_histogram)
self._masks_console = _MasksConsole()
self._magnifier.status.connect(
lambda text: self._status_label.setText(text))
self._canvas.status.connect(
lambda text: self._status_label.setText(text))
self._prompt_card = None
self._prompter = _PromptSession(
self._canvas, gate=self._prompt_card_shown,
field_name=self._current_field_name, parent=self)
self._canvas.prompter = self._prompter
self._prompter.changed.connect(self._canvas.update)
self._prompter.changed.connect(self._sync_prompt_buttons)
self._prompter.said.connect(self._on_prompt_said)
self._prompter.accept_requested.connect(self._accept_prompt)
self._view_tabs = self._build_view_tabs()
_screens_package._breathe_while_a_window_opens()
self._settings_scroll = QScrollArea()
self._settings_scroll.setWidgetResizable(True)
self._settings_scroll.setFrameShape(QScrollArea.NoFrame)
self._settings_scroll.setWidget(self._build_tools_panel())
for changed in (self._cp_model.currentIndexChanged,
self._cp_diameter.valueChanged,
self._cp_flow.valueChanged,
self._cp_cellprob.valueChanged,
self._cp_normalize.toggled,
self._otsu_correction.valueChanged,
self._otsu_smoothing.valueChanged,
self._otsu_fill_holes.toggled,
self._otsu_split.toggled,
self._otsu_bright.toggled,
self._cp_invert.toggled,
self._min_area.valueChanged):
changed.connect(self._on_magnifier_context_changed)
self._min_area.valueChanged.connect(self._on_min_area_changed)
self._on_min_area_changed(self._min_area.value())
self._body_splitter.add_pane(
self._settings_scroll, "Settings", mode=EDGE, stretch=1,
extent=SETTINGS_WIDTH, fold_key="make_masks/Settings",
hint="or drag to make the settings wider or narrower")
from .. import screens as _screens_package
_screens_package._breathe_while_a_window_opens()
self._body_splitter.add_pane(self._build_view_pane(), "Masks",
stretch=3, extent=900)
self._body_stack.addWidget(self._body_splitter)
self._body_stack.setCurrentWidget(self._empty_state)
self._body_stack.currentChanged.connect(self._sync_tool_row_visibility)
self._sync_tool_row_visibility()
outer.addWidget(self._body_stack, 1)
from ..i18n import tr
_screens_package._breathe_while_a_window_opens()
nav = QWidget()
outer_row = QHBoxLayout(nav)
outer_row.setContentsMargins(0, 0, 0, 0)
outer_row.setSpacing(SPACING["sm"])
from .app_screen import _WrappingButtonStrip
nav_row = _WrappingButtonStrip(SPACING["sm"])
outer_row.addLayout(nav_row, 1)
self._btn_open = QPushButton(tr("Open folder…"))
self._btn_open.setObjectName("PrimaryButton")
self._btn_open.setIcon(iconset.contrast_icon("open"))
self._btn_open.setCursor(Qt.PointingHandCursor)
self._btn_open.clicked.connect(self._on_pick_folder)
nav_row.addWidget(self._btn_open)
nav_row.addWidget(self._build_organize_button())
nav_row.addWidget(self._build_test_data_button())
nav_row.addWidget(self._build_uncertainty_button())
self._nav_curate_group, curate_row = self._nav_button_group()
self._btn_prev = QPushButton(tr("Prev image"))
self._btn_prev.setIcon(iconset.icon("prev"))
self._btn_prev.setCursor(Qt.PointingHandCursor)
self._btn_prev.clicked.connect(self._on_prev)
self._btn_next = QPushButton(tr("Next image"))
self._btn_next.setIcon(iconset.icon("next"))
self._btn_next.setLayoutDirection(Qt.RightToLeft)
self._btn_next.setCursor(Qt.PointingHandCursor)
self._btn_next.clicked.connect(self._on_next)
from ..prefs import _s
self._btn_save_on_navigation = QPushButton(tr("Save on navigation"))
self._btn_save_on_navigation.setObjectName("MakeMasksSaveOnNavigate")
self._btn_save_on_navigation.setCheckable(True)
self._btn_save_on_navigation.setCursor(Qt.PointingHandCursor)
self._btn_save_on_navigation.setToolTip(tr(
"Save an edited mask before Keep, Discard, Next or Previous. "
"If saving fails, stay on this image without recording a verdict. "
"Off by default; Skip still writes no mask."))
self._btn_save_on_navigation.setChecked(bool(_s().value(
"make_masks/save_on_navigation", False, type=bool)))
self._btn_save_on_navigation.toggled.connect(
lambda enabled: _s().setValue(
"make_masks/save_on_navigation", bool(enabled)))
self._btn_clear = QPushButton(tr("Clear all objects"))
self._btn_clear.setObjectName("DangerButton")
self._btn_clear.setCursor(Qt.PointingHandCursor)
self._btn_clear.setToolTip(tr(
"Remove every object from this field. It asks first, and it is "
"one undo step, so a press by accident costs one Ctrl+Z."))
self._btn_clear.clicked.connect(self._on_clear_mask)
curate_row.addWidget(self._btn_clear)
self._btn_first_unreviewed = QPushButton(tr("First unreviewed"))
self._btn_first_unreviewed.setObjectName("MakeMasksFirstUnreviewed")
self._btn_first_unreviewed.setCursor(Qt.PointingHandCursor)
self._btn_first_unreviewed.setToolTip(tr(
"Open the first image in the queue that has neither Keep nor "
"Discard. Verdicts saved in earlier sessions count. Unsaved "
"edits are handled as when moving to any other image."))
self._btn_first_unreviewed.clicked.connect(self._on_first_unreviewed)
curate_row.insertWidget(curate_row.indexOf(self._btn_clear),
self._btn_first_unreviewed)
self._btn_discard = QPushButton(tr("Discard"))
self._btn_discard.setIcon(iconset.icon("trash"))
self._btn_discard.setCheckable(True)
self._btn_discard.setCursor(Qt.PointingHandCursor)
self._btn_discard.setToolTip(tr(
"Mark this field as one to discard and move to the next. "
"Nothing is deleted: the verdict goes to csv/keep_discard.csv "
"beside the images, and the field, its mask and its objects "
"stay as they are."))
self._btn_discard.clicked.connect(lambda: self._on_curate(False))
curate_row.addWidget(self._btn_discard)
self._btn_keep = QPushButton(tr("Keep"))
self._btn_keep.setIcon(iconset.icon("check"))
self._btn_keep.setCheckable(True)
self._btn_keep.setCursor(Qt.PointingHandCursor)
self._btn_keep.setToolTip(tr(
"Mark this field as one to keep and move to the next. The "
"verdict is written to csv/keep_discard.csv beside the images, "
"with the image, its mask and the number of objects the mask "
"holds right now."))
self._btn_keep.clicked.connect(lambda: self._on_curate(True))
curate_row.addWidget(self._btn_keep)
self._btn_save = QPushButton(tr("Save mask"))
self._btn_save.setObjectName("PrimaryButton")
self._btn_save.setIcon(iconset.contrast_icon("save"))
self._btn_save.setCursor(Qt.PointingHandCursor)
self._btn_save.clicked.connect(self._on_save)
for button in (self._btn_save, self._btn_prev, self._btn_next,
self._btn_save_on_navigation):
button.setMinimumHeight(32)
self.add_toolbar_action(button)
self._btn_skip = QPushButton(tr("Skip"))
self._btn_skip.setIcon(iconset.icon("next"))
self._btn_skip.setCursor(Qt.PointingHandCursor)
self._btn_skip.setToolTip(tr(
"Record this field as one that cannot be curated and move to the "
"next. It is written to the session's curate_status.csv as skip, "
"so the next session does not offer it again. No mask is written."))
self._btn_skip.clicked.connect(self._on_skip)
curate_row.addWidget(self._btn_skip)
curate_row.addWidget(self._build_blind_toggle())
curate_row.addWidget(self._build_roi_button())
curate_row.addWidget(self._build_sam2_button())
from ..preferences import _apply_alpha_widgets
from .train_cellpose import _VirtualStainApply
self._virtual_stain = _VirtualStainApply(lambda: self._folder, self)
self._virtual_stain.button.setObjectName("MakeMasksVirtualStainApply")
_apply_alpha_widgets(self._virtual_stain.button)
curate_row.addWidget(self._virtual_stain)
for button in self._build_vvvv_export_buttons():
curate_row.addWidget(button)
_apply_alpha_widgets(button)
curate_row.addWidget(self._build_contribute_button())
outer_row.addWidget(self._nav_curate_group, 0, Qt.AlignBottom)
self._status_label = _StatusLabel("Ready.")
self._status_label._blind_owner = weakref.ref(self)
self._status_label.setObjectName("SubtitleSmall")
self._status_label.said.connect(self._report_status)
self._status_label.setSizePolicy(QSizePolicy.Ignored,
QSizePolicy.Preferred)
self._status_label.setMinimumWidth(0)
self._image_nav_row.addWidget(self._status_label, 1)
outer.addWidget(nav)
def _nav_button_group(self, separated: bool = False):
"""A widget holding a row of navigation buttons that never wraps apart.
The navigation strip wraps widget by widget, so buttons that must stay
on one line are put in one of these and the group is added instead.
With ``separated`` the group opens with a gap and a vertical line.
:param separated: lead the group with a visible gap and separator.
:returns: ``(widget, layout)``; add the buttons to ``layout``.
"""
from PySide6.QtWidgets import QFrame
group = QWidget()
row = QHBoxLayout(group)
row.setContentsMargins(0, 0, 0, 0)
row.setSpacing(SPACING["sm"])
if separated:
row.addSpacing(SPACING["lg"])
line = QFrame(group)
line.setObjectName("NavGroupSeparator")
line.setFrameShape(QFrame.Shape.VLine)
line.setFrameShadow(QFrame.Shadow.Sunken)
row.addWidget(line)
row.addSpacing(SPACING["lg"])
return group, row
def _build_fold_strip(self) -> FoldStrip:
"""The masthead's strip of folded modules.
Built through :class:`~spacr.qt.widgets.fold_strip.FoldStrip` so each
button is the module's own icon, tooltipped with its own sentence and
lit on hover in its own maturity colour, read from the tables the
tiles read rather than from a second one here.
"""
entries = []
for key in FOLD_ORDER:
if key == MASK_FOLDER_KEY:
entries.append((key, self.mask_whole_folder))
else:
entries.append((key, partial(self.open_folded, key)))
strip = FoldStrip(entries, parent=self)
for key in FOLD_ORDER:
self._restate_fold_button(strip.button_for(key), key)
return strip
@staticmethod
def _restate_fold_button(button, key: str) -> None:
"""Give ``button`` the name, sentence and stage its tile carried.
A no-op while the registry still holds the row — the strip has
already read the same three things from the same place. It is what
keeps the button honest afterwards, when the row is gone and the
registry would report no description and a stable-blue hover for a
module that is neither.
"""
if button is None:
return
name, description, stage = fold_description(key)
button.setToolTip(f"{name}\n{description}".strip())
button.setAccessibleName(name)
if button.property("stage") != stage:
button.setProperty("stage", stage)
button.style().unpolish(button)
button.style().polish(button)
[docs]
def folded_screen(self, key: str) -> Optional[QWidget]:
"""The folded module's own screen, built on first use and kept.
:param key: one of :data:`FOLD_ORDER`. Keys that share a screen —
see :data:`FOLD_HOSTS` — resolve to the one widget that hosts
them both.
:returns: the screen, or ``None`` for a key this screen does not
fold.
"""
host = FOLD_HOSTS.get(key, key)
if host not in FOLD_ORDER:
return None
screen = self._fold_screens.get(host)
if screen is None:
screen = self._build_folded_screen(host)
self._fold_screens[host] = screen
return screen
def _build_folded_screen(self, key: str) -> QWidget:
"""Construct one folded module's widget.
Each branch builds the module's real screen class, and the generic
settings page is what a module with no screen of its own gets — the
same page its tile opened.
"""
if key == "train_cellpose":
from .train_cellpose import CellposeWorkbenchScreen
return CellposeWorkbenchScreen()
if key == "model_compare":
from .model_compare import ModelCompareScreen
return ModelCompareScreen()
if key == "model_zoo":
from .model_zoo import ModelZooScreen
screen = ModelZooScreen()
screen.compare_requested.connect(self._on_zoo_compare_requested)
return screen
if key == "curate":
from .curate import CurateScreen
return CurateScreen()
if key == "napari_bridge":
return NapariBridgeScreen()
from .app_screen import AppScreen
return AppScreen(app_key=key)
def _fold_actions(self, key: str) -> tuple:
"""Extra buttons for a folded module's window.
Where a capability the folded module never had and this screen does
arrives with the fold.
"""
if key == "curate":
return (("Save mask",
"Write the corrected labels back to the mask file, with "
"the correction ledger beside them",
self.save_curated_mask),)
return ()
[docs]
def open_folded(self, key: str) -> Optional[FoldedModulePanel]:
"""Open a folded module on this screen, pointed at the open field.
The module arrives as a PAGE beside the editor, which is where a
fold belongs; it becomes a window only if this screen has no body
to make pages out of.
:param key: one of :data:`FOLD_ORDER`.
:returns: the module's panel, or ``None`` for a key this screen does
not fold. Pressing the same button again raises the page that is
already there rather than building a second one.
"""
from .map_barcodes import show_as_page, show_as_window
host = FOLD_HOSTS.get(key, key)
screen = self.folded_screen(host)
if screen is None:
return None
title = fold_description(host)[0]
panel = self._fold_dialogs.get(host)
if panel is None:
panel = FoldedModulePanel(
host, screen, title, parent=self,
actions=self._fold_actions(host))
self._fold_dialogs[host] = panel
self.seed_folded(key)
if show_as_page(panel, self, title) is None:
panel.add_close_button()
show_as_window(panel, self, title)
panel.show()
panel.raise_()
return panel
[docs]
def seed_folded(self, key: str) -> dict:
"""Point a folded module at the field this screen has open.
The whole reason these are buttons on this masthead rather than rows
of their own is that the folder is already chosen here; a folded
module that opened on an empty path would have folded the file dialog
in with it.
:param key: one of :data:`FOLD_ORDER`. Note that this is the button's
key, not its host's: the two Cellpose halves share a screen and
seed different halves of it.
:returns: what was seeded, as ``{name: value}``. Empty when no folder
is open, which is not a failure — the module opens on its own
file picker exactly as its tile did.
"""
if not self._folder:
return {}
screen = self.folded_screen(key)
if screen is None:
return {}
if key in ("train_cellpose", MASK_FOLDER_KEY):
return self._seed_cellpose(screen, key)
if key == "model_compare":
screen.set_source(self._folder)
return {"folder": self._folder}
if key == "model_zoo":
screen.set_fields_source(self._folder)
return {"folder": self._folder}
if key in ("curate", "napari_bridge"):
return self._seed_mask_editor(screen, key)
screen.apply_settings_dict({"src": self._folder})
return {"src": self._folder}
def _seed_cellpose(self, workbench: QWidget, key: str) -> dict:
"""Open the Cellpose workbench on the half the button names.
Training and applying read ``src`` differently — training wants the
parent of ``train/images``, applying wants the folder of fields — so
only the applying half is given the folder this screen has open.
Training is opened on its own tab with its own path untouched, which
is the one thing that must not be guessed at.
"""
target = (workbench.train_screen if key == "train_cellpose"
else workbench.apply_screen)
tabs = workbench.findChild(QTabWidget)
if tabs is not None:
tabs.setCurrentWidget(target)
if key == "train_cellpose":
return {}
target.apply_settings_dict({"src": self._folder})
return {"src": self._folder}
def _seed_mask_editor(self, screen: QWidget, key: str) -> dict:
"""Hand a mask editor the field on screen, image and mask both.
Nothing is opened for the user: both screens report on whether the
file they were given has been curated before, and reading a mask off
disk while the folder is being edited here would answer that question
about the wrong copy.
"""
filename = self._image_files[self._current_index]
if engine.is_seg_bundle(filename):
self._status_label.setText(
f"{filename} holds its image and mask inside one Cellpose "
f"bundle; this module opens image and mask files, so it was "
f"not pointed at it.")
return {}
mask_path = engine.mask_save_path(self._folder, filename,
**self._layout_kwargs())
seeded = {"mask": mask_path}
screen._mask_edit.setText(mask_path)
if key == "napari_bridge":
image_path = os.path.join(self._folder, filename)
screen._image_edit.setText(image_path)
seeded["image"] = image_path
return seeded
def _on_zoo_compare_requested(self, request: dict) -> None:
"""Open the folded Model Compare on the two models the zoo picked."""
dialog = self.open_folded("model_compare")
if dialog is None:
return
dialog.screen.configure(
model_a=request.get("model_a", ""),
model_b=request.get("model_b", ""),
folder=request.get("folder", ""),
n_fields=int(request.get("n_fields", 0) or 0),
)
[docs]
def mask_whole_folder(self) -> bool:
"""Segment every image in the open folder with the current model.
The one folded button that does something rather than opening
something: it points the applying half of the Cellpose workbench at
the folder already open here and starts it. "The current model" is
whatever that tab holds — the checkpoint the Train tab produced if
there is one, and the stock model otherwise.
The Apply half writes one mask per image into ``<src>/masks``, which
is the nested layout's masks folder and nobody else's: in a sibling
session it would be ``images/masks``, beside the set's real masks
rather than in them, and a folder of ``_seg.npy`` bundles has no
images for it to read. Both are refused, in the status line.
:returns: whether a run was started. A folder that is not open, a
session whose masks are not in ``<folder>/masks``, and a
confirmation that is declined, all answer ``False``.
"""
if not self._folder or not self._image_files:
self._status_label.setText(
"Open a folder of images before masking it.")
return False
if self._field_folders:
from ..i18n import tr
self._status_label.setText(tr(
"Mask the whole folder works on one folder, and this queue "
"was dropped from several, so it was not started."))
return False
if self._masks_dir or any(engine.is_seg_bundle(name)
for name in self._image_files):
self._status_label.setText(
f"Mask the whole folder writes its masks into "
f"{os.path.join(self._folder, 'masks')}, and this set keeps "
f"its masks somewhere else, so it was not started.")
return False
count = len(self._image_files)
if not self._confirm(
"Mask the whole folder",
f"Segment all {count} images in {self._folder}?\n\n"
f"Images that already have a mask are left alone."):
return False
dialog = self.open_folded(MASK_FOLDER_KEY)
if dialog is None:
return False
self._status_label.setText(
f"Masking {count} images in {self._folder}…")
self._start_folded_run(dialog.screen.apply_screen)
return True
@staticmethod
def _start_folded_run(screen) -> None:
"""Press a folded module page's Run.
One line, named, because it is the seam between this screen and a job
that wants a GPU: a test drives everything up to it without starting
Cellpose.
"""
screen._on_run()
def _build_test_data_button(self):
"""The "Load test data…" button, with the training datasets on its menu.
One split button (:class:`_SplitMenuButton`): pressing it loads the
Toxoplasma test data, as it always did
(:func:`spacr.qt.make_masks_demo.load_the_test_data`), and its arrow
opens a menu offering the test data again and a sample of
each training dataset a published model was trained on
(:func:`spacr.qt.make_masks_datasets.open_a_training_dataset`). It
replaces the separate "Training datasets…" button, so the screen
keeps it as both ``_btn_test_data`` and ``_btn_training_datasets``
and either download disables it while it runs.
:returns: the button, with its menu.
"""
from ..i18n import tr
from .. import make_masks_datasets as datasets
from ..make_masks_demo import load_the_test_data
button = _SplitMenuButton(tr("Load test data…"), self)
button.setObjectName("MakeMasksTestDataButton")
button.setAccessibleName(tr("Load test data"))
button.setCursor(Qt.PointingHandCursor)
button.setToolTip(tr(
"Download ten example images of Toxoplasma vacuoles (about 40 MB) and "
"open the first one. Their curated masks come too, in the "
"ground_truth_masks folder, which the editor does not read. Cached "
"after the first download.") + " " + tr(
"The arrow offers a sample of each training dataset a published "
"model was trained on, with its masks, to edit here."))
button.clicked.connect(
lambda _checked=False: load_the_test_data(self))
menu = QMenu(button)
self._test_data_actions = {}
action = menu.addAction(tr("Toxoplasma vacuoles (test data)"))
action.triggered.connect(
lambda _checked=False: load_the_test_data(self))
self._test_data_actions["test_data"] = action
menu.addSeparator()
for dataset in datasets.datasets_for("mask"):
action = menu.addAction(tr("Training dataset: {name}",
name=dataset.title))
action.setToolTip(dataset.title)
action.triggered.connect(
lambda _checked=False, chosen=dataset:
datasets.open_a_training_dataset(
self, pick=lambda _screen: chosen))
self._test_data_actions[dataset.key] = action
button.set_split_menu(menu)
self._btn_test_data = button
self._btn_training_datasets = button
return button
def _build_contribute_button(self) -> QPushButton:
"""The "Upload data…" button: send images and masks to a community set.
It was "Contribute images and masks…"; the object name and the
dialog it opens are unchanged.
"""
from ..i18n import tr
from ..widgets.model_share_dialog import contribute_masks_tooltip
button = QPushButton(tr("Upload data…"), self)
button.setObjectName("MakeMasksUploadDataButton")
button.setAccessibleName(tr("Upload data"))
button.setCursor(Qt.PointingHandCursor)
button.setToolTip(tr(
"Upload data: send images and their masks to a community "
"training dataset.") + "\n\n" + contribute_masks_tooltip())
button.clicked.connect(
lambda _checked=False: self.contribute_images_and_masks())
self._btn_contribute = button
return button
def _contribution_sources(self) -> tuple:
"""The image on screen and the folder's curated images, as items.
:returns: ``(current, curated)`` for
:class:`~spacr.qt.widgets.model_share_dialog.ContributeMasksDialog`:
``current`` is the field on screen with the mask being edited,
``curated`` every field with a saved mask (the one on screen
with the mask on screen). Both ``None`` when no folder is open;
Cellpose ``_seg.npy`` bundles are left out, having no image file
of their own to send.
"""
files = list(getattr(self, "_image_files", None) or [])
if not files:
return None, None
index = getattr(self, "_current_index", 0)
current = None
curated = []
for i, filename in enumerate(files):
if engine.is_seg_bundle(filename):
continue
folder = (self._field_folders[i] if self._field_folders
else self._folder)
source = os.path.join(folder, filename)
if i == index:
labels = getattr(self._canvas, "mask", None)
current = {"name": filename, "source": source,
"labels": None if labels is None
else np.array(labels, copy=True)}
if labels is not None and np.any(labels):
curated.append(dict(current))
continue
mask = engine.mask_save_path(folder, filename,
**self._layout_kwargs())
if os.path.isfile(mask):
curated.append({"name": filename, "source": source,
"mask": mask})
return current, curated
[docs]
def contribute_images_and_masks(self, *, show: bool = True,
upload=None, threaded: bool = True):
"""Open the dialog that sends images and masks to a community dataset.
One click from curation: the dialog opens on the image on screen
and its mask, with every curated image of the folder one choice
away, and an images folder plus a masks folder as the third way in.
:param show: show the dialog; False only builds it (tests).
:param upload: ``fn(folder, target) -> url`` in place of the real
upload (tests).
:param threaded: send on a worker thread.
:returns: the dialog.
"""
from ..widgets.model_share_dialog import ContributeMasksDialog
current, curated = self._contribution_sources()
dialog = ContributeMasksDialog(
current=current, curated=curated, upload=upload,
threaded=threaded, parent=self)
dialog.setAttribute(Qt.WA_DeleteOnClose, show)
self._contribute_dialog = dialog
if show:
dialog.show()
return dialog
def _build_roi_button(self) -> QPushButton:
"""The "ROIs" button: masks out to and in from QuPath, Fiji and COCO.
An ALPHA feature (item 545): the button is registered as
``MakeMasksRoisButton`` in :data:`spacr.settings.ALPHA_FEATURES`, so
it is shown only while Preferences -> "Show alpha features" is on.
Its menu exports the
field on screen or every field of the folder or queue, and imports a
file back into the field on screen or into every field, through
:func:`spacr.mask_io.export_rois` and
:func:`spacr.mask_io.import_rois`.
:returns: the button, with its menu.
"""
from ..i18n import tr
from ..preferences import _apply_alpha_widgets
button = QPushButton(tr("ROIs…"), self)
button.setCursor(Qt.PointingHandCursor)
button.setToolTip(tr(
"Export the masks as QuPath GeoJSON, an ImageJ RoiSet or COCO "
"JSON, one outline per object with its id and class, or import "
"one of those files back as masks."))
menu = QMenu(button)
self._roi_actions = {}
entries = (
("export_field_geojson", tr("Export this field as QuPath GeoJSON…"),
lambda: self._on_export_field_rois("geojson")),
("export_field_imagej", tr("Export this field as an ImageJ RoiSet…"),
lambda: self._on_export_field_rois("imagej")),
("export_field_coco", tr("Export this field as COCO JSON…"),
lambda: self._on_export_field_rois("coco")),
None,
("export_all_geojson",
tr("Export every field as QuPath GeoJSON…"),
lambda: self._on_export_all_rois("geojson")),
("export_all_imagej", tr("Export every field as ImageJ RoiSets…"),
lambda: self._on_export_all_rois("imagej")),
("export_all_coco", tr("Export every field as one COCO JSON…"),
lambda: self._on_export_all_rois("coco")),
None,
("import_field", tr("Import ROIs into this field…"),
self._on_import_field_rois),
("import_all_geojson", tr("Import QuPath GeoJSON for every field…"),
lambda: self._on_import_all_rois("geojson")),
("import_all_imagej", tr("Import ImageJ RoiSets for every field…"),
lambda: self._on_import_all_rois("imagej")),
("import_all_coco", tr("Import COCO JSON for every field…"),
lambda: self._on_import_all_rois("coco")),
)
for entry in entries:
if entry is None:
menu.addSeparator()
continue
key, text, slot = entry
action = menu.addAction(text)
action.triggered.connect(
lambda _checked=False, run=slot: run())
self._roi_actions[key] = action
button.setMenu(menu)
button.setObjectName("MakeMasksRoisButton")
_apply_alpha_widgets(button)
self._btn_rois = button
return button
def _build_sam2_button(self) -> QPushButton:
"""The SAM2 tracking button: seed objects in a movie by clicking and
follow them through it with :class:`_Sam2SeedDialog`.
An alpha feature, registered as ``MakeMasksSam2Button`` in
:data:`spacr.settings.ALPHA_FEATURES`.
:returns: the button.
"""
from ..i18n import tr
from ..preferences import _apply_alpha_widgets
button = QPushButton(tr("SAM2 tracking…"), self)
button.setCursor(Qt.PointingHandCursor)
button.setToolTip(tr(
"Open a timelapse movie, click each object on any frame to seed "
"it, and let SAM2 follow every object through the movie. Click "
"again on a frame where it went wrong and propagate once more."))
button.clicked.connect(self._on_sam2_tracking)
button.setObjectName("MakeMasksSam2Button")
_apply_alpha_widgets(button)
self._btn_sam2 = button
return button
def _on_sam2_tracking(self) -> None:
"""Ask for a movie and open the SAM2 seeding dialog on it."""
from ..i18n import tr
path, _filter = QFileDialog.getOpenFileName(
self, tr("Choose a timelapse movie"), self._folder or "",
tr("TIFF stacks (*.tif *.tiff)"))
if not path:
return
from tifffile import imread
from ..._segmentation_backends import _sam2_frames
dialog = _Sam2SeedDialog(_sam2_frames(imread(path)), parent=self,
source_path=path)
dialog.exec()
def _build_blind_toggle(self) -> QPushButton:
"""The Blind switch: curate fields without knowing where they are from.
On, the open fields are shuffled under a blinding key
(:func:`spacr.run_journal.start_blinding`) and every field is named
on screen by its code, never by its file name or folder. Off asks
first, then unblinds through :func:`spacr.run_journal.unblind`,
which records who did it and when, and puts the fields back in
their own order. An alpha feature, registered as
``MakeMasksBlindToggle`` in :data:`spacr.settings.ALPHA_FEATURES`.
:returns: the checkable button.
"""
from ..i18n import tr
from ..preferences import _apply_alpha_widgets
button = QPushButton(tr("Blind"), self)
button.setObjectName("MakeMasksBlindToggle")
button.setCheckable(True)
button.setCursor(Qt.PointingHandCursor)
button.setToolTip(tr(
"Curate blind: name every field by a code instead of its file "
"name and folder, and show the fields in a shuffled order. The "
"key is kept beside the run journal, outside the data folder. "
"Turning it off unblinds, and the journal records who unblinded "
"and when. Default off."))
button.toggled.connect(self._on_blind_toggled)
_apply_alpha_widgets(button)
self._btn_blind = button
return button
def _build_uncertainty_button(self) -> QPushButton:
"""The "Uncertainty" button: where the segmentation is least sure.
Its menu maps the field on screen, or ranks every open field and
offers the most uncertain first. Both segment each field four
times, as it is and flipped two ways and turned a quarter, with the
Object detection settings, and measure how much the four disagree.
An alpha feature, registered as ``MakeMasksUncertaintyButton`` in
:data:`spacr.settings.ALPHA_FEATURES`.
:returns: the button, with its menu.
"""
from ..i18n import tr
from ..preferences import _apply_alpha_widgets
button = QPushButton(tr("Uncertainty…"), self)
button.setObjectName("MakeMasksUncertaintyButton")
button.setCursor(Qt.PointingHandCursor)
button.setToolTip(tr(
"Segment each field four times, as it is, flipped two ways and "
"turned a quarter, with the Object detection settings, and "
"measure where the four disagree. Map this field shows the "
"disagreement as a heat map on its own tab; Rank the fields puts "
"the most uncertain fields first and saves the scores as "
"curate_uncertainty.csv for spacr-make-masks --order uncertain. "
"Takes four detection runs per field, or eight when an optional "
"ensemble model is selected in Object detection."))
button.setMenu(self._uncertainty_menu())
_apply_alpha_widgets(button)
self._btn_uncertainty = button
return button
def _uncertainty_menu(self) -> QMenu:
"""The one Map / Rank menu both Uncertainty buttons open.
Built on first use and shared, so the toolbar button and the one in
the Object operations settings run the same two actions.
"""
menu = getattr(self, "_uncertainty_menu_widget", None)
if menu is not None:
return menu
from ..i18n import tr
menu = QMenu(self)
self._uncertainty_actions = {}
for key, text, slot in (
("map", tr("Map this field's uncertainty"),
self._on_map_uncertainty),
("rank", tr("Rank the fields, most uncertain first"),
self._on_rank_uncertainty),
("save", tr("Save uncertainty map…"),
self._on_save_uncertainty)):
action = menu.addAction(text)
action.triggered.connect(lambda _checked=False, run=slot: run())
self._uncertainty_actions[key] = action
menu.aboutToShow.connect(self._sync_uncertainty_export)
self._uncertainty_menu_widget = menu
return menu
def _build_uncertainty_setting(self) -> QPushButton:
"""Uncertainty in the Object operations settings, after Swap.
The same control as the toolbar's Uncertainty button: it opens the
same Map / Rank menu, and :meth:`_set_uncertainty_enabled` disables
and re-enables both together while a run is under way. Alpha-gated
with it, as ``MakeMasksUncertaintySetting`` under item 568 in
:data:`spacr.settings.ALPHA_FEATURES`.
:returns: the button, with the shared menu.
"""
from ..i18n import tr
from ..preferences import _apply_alpha_widgets
button = QPushButton(tr("Uncertainty…"), self)
button.setObjectName("MakeMasksUncertaintySetting")
button.setAccessibleName(tr("Uncertainty"))
button.setCursor(Qt.PointingHandCursor)
button.setToolTip(tr(
"Map where the segmentation of this field is least sure, or rank "
"the open fields with the most uncertain first. The same menu as "
"the Uncertainty button on the bottom row; four detection "
"runs per field, or eight with the optional ensemble model."))
button.setMenu(self._uncertainty_menu())
_apply_alpha_widgets(button)
self._btn_uncertainty_setting = button
return button
def _set_uncertainty_enabled(self, enabled: bool) -> None:
"""Enable or disable both Uncertainty buttons together."""
for button in (getattr(self, "_btn_uncertainty", None),
getattr(self, "_btn_uncertainty_setting", None)):
if button is not None:
button.setEnabled(enabled)
def _build_uncertainty_ensemble_setting(self) -> QWidget:
"""Offer an optional second model for alpha uncertainty scoring.
This display-side choice is persisted independently of segmentation
settings and never changes the model used to create or save masks.
"""
from ..i18n import tr
from ..preferences import _apply_alpha_widgets
from ..prefs import _s
row = QWidget(self)
row.setObjectName("MakeMasksUncertaintyEnsembleSetting")
layout = QVBoxLayout(row)
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(QLabel(tr("Uncertainty ensemble model (optional)")))
controls = QHBoxLayout()
self._uncertainty_ensemble = QComboBox(row)
self._uncertainty_ensemble.setEditable(True)
self._uncertainty_ensemble.addItem(tr("None"), "")
for index in range(self._cp_model.count()):
value = self._cp_model.itemData(index)
if value:
self._uncertainty_ensemble.addItem(self._cp_model.itemText(index), value)
value = str(_s().value("make_masks/uncertainty_second_model", "") or "")
index = self._uncertainty_ensemble.findData(value)
if index >= 0:
self._uncertainty_ensemble.setCurrentIndex(index)
else:
self._uncertainty_ensemble.setEditText(value)
self._uncertainty_ensemble.setToolTip(tr(
"Optional second model for uncertainty only. Blank or None uses "
"four primary-model passes. A different model adds four passes "
"and can reveal errors both flips and rotations repeat. "
"Saved masks and ordinary detection are unchanged."))
self._uncertainty_ensemble.currentTextChanged.connect(self._remember_uncertainty_ensemble)
controls.addWidget(self._uncertainty_ensemble, 1)
browse = QPushButton(tr("Browse…"), row)
browse.clicked.connect(self._pick_uncertainty_ensemble)
controls.addWidget(browse)
layout.addLayout(controls)
_apply_alpha_widgets(row)
return row
def _uncertainty_ensemble_model(self) -> str:
"""Return the optional second model's stable name or checkpoint path."""
combo = self._uncertainty_ensemble
index = combo.currentIndex()
value = (combo.currentData() if index >= 0
and combo.currentText() == combo.itemText(index) else None)
return str(value if value is not None else combo.currentText()).strip()
def _remember_uncertainty_ensemble(self, _text):
"""Persist the model choice without starting inference.
:param _text: changed combo caption; stable item data is read instead.
"""
from ..prefs import _s
_s().setValue("make_masks/uncertainty_second_model", self._uncertainty_ensemble_model())
def _pick_uncertainty_ensemble(self):
"""Select a local checkpoint for the second uncertainty model."""
from ..i18n import tr
path, _filter = QFileDialog.getOpenFileName(self, tr("Choose ensemble model"))
if path:
self._uncertainty_ensemble.setEditText(path)
def _sync_uncertainty_export(self):
"""Enable export only for a completed map of the field still in view."""
current = self._uncertainty_result
valid = (current is not None and self._blind is None
and current[0]['token'] == self._load_token
and current[0]['image_reference'] is self._canvas.image)
self._uncertainty_actions['save'].setEnabled(valid)
def _on_save_uncertainty(self, path=None) -> bool:
"""Save the current lossless map, with captured model/source provenance.
:param path: optional destination for scripted use; None opens a picker.
:returns: whether a map was saved successfully.
"""
from ..i18n import tr
from ...segmentation_uncertainty import save_uncertainty_map
self._sync_uncertainty_export()
if not self._uncertainty_actions['save'].isEnabled():
self._status_label.setText(tr("Map the current field before saving uncertainty, and leave blind mode first."))
return False
request, result = self._uncertainty_result
source = request.get('source') or 'field'
if path is None:
default = os.path.join(os.path.dirname(source), 'uncertainty',
engine.field_stem(os.path.basename(source)) + '_uncertainty.tif')
path, _filter = QFileDialog.getSaveFileName(
self, tr("Save uncertainty map"), default, tr("TIFF image (*.tif *.tiff)"))
if not path:
return False
detect = request['detect']
provenance = dict(source=source, primary_model=detect['model'],
second_model=request.get('second_model') or None,
parameters=detect['parameters'], invert=detect['invert'],
percentiles=detect['percentiles'], preprocessing=str(detect['chain']),
transforms=['identity', 'flip_lr', 'flip_ud', 'rot90'])
try:
target = save_uncertainty_map(path, result, provenance=provenance,
protected_paths=(source, request.get('mask_path')))
except (OSError, ValueError) as exc:
self._warn(tr("Uncertainty map not saved"), str(exc))
return False
self._status_label.setText(tr("Uncertainty map saved to {path}.", path=str(target)))
return True
def _uncertainty_detect_request(self) -> dict:
"""The Object detection settings, captured for a worker thread.
:returns: what :func:`_detect_cellpose_snapshot` reads, without the
image, which each test-time pass supplies.
"""
parameters = dict(diameter=int(self._cp_diameter.value()),
normalize=bool(self._cp_normalize.isChecked()),
flow_threshold=float(self._cp_flow.value()),
cellprob_threshold=float(self._cp_cellprob.value()),
min_size=self._detect_min_area())
return dict(model=self._cp_model.currentData() or 'cpsam',
parameters=parameters, chain=self._detect_chain(),
invert=bool(self._cp_invert.isChecked()),
percentiles=(float(self._canvas.norm_lo),
float(self._canvas.norm_hi))
if self._canvas.detect_on_normalized else None)
def _start_uncertainty(self, request: dict, threaded: bool) -> bool:
"""Run one uncertainty request, on the worker unless told not to.
:param request: what :func:`_uncertainty_snapshot` reads.
:param threaded: False runs it here and delivers at once (tests).
:returns: whether it started; not while another one runs.
"""
if self._uncertainty_request is not None:
return False
self._uncertainty_request = request
self._set_uncertainty_enabled(False)
work = partial(_uncertainty_snapshot, models=self._cp_loaded)
if not threaded:
try:
result, error = work(request), None
except Exception as exc: # noqa: BLE001
result, error = None, exc
self._take_uncertainty((request, result, error))
return True
if self._uncertainty_worker is None:
self._uncertainty_worker = _NewestRequestWorker(
work, self._uncertainty_done, name='spacr-uncertainty')
self._uncertainty_worker.submit(request)
return True
def _uncertainty_done(self, request, result, error) -> None:
"""Hand a finished uncertainty run to Qt's thread."""
try:
self._uncertainty_delivered.emit((request, result, error))
except RuntimeError:
pass
def _on_map_uncertainty(self, *, segment=None,
threaded: bool = True) -> bool:
"""Map where the segmentation of the field on screen is unsure.
:param segment: ``image -> labels`` in place of Object detection
(tests).
:param threaded: run on a worker thread.
:returns: whether a run started.
"""
from ..i18n import tr
if self._canvas.image is None:
self._status_label.setText(tr(
"Open a folder before mapping segmentation uncertainty."))
return False
request = dict(kind='field', segment=segment,
image=np.array(self._canvas.image, copy=True),
image_reference=self._canvas.image,
token=self._load_token,
detect=self._uncertainty_detect_request(),
second_model=self._uncertainty_ensemble_model(),
source=self._curation_paths()[0], mask_path=self._curation_paths()[1])
if not self._start_uncertainty(request, threaded):
return False
self._status_label.setText(tr(
"Mapping segmentation uncertainty: {passes} detection runs…",
passes=8 if request.get("second_model") else 4))
return True
def _on_rank_uncertainty(self, *, segment=None,
threaded: bool = True) -> bool:
"""Score every open field's uncertainty and offer the worst first.
Refused while curating blind, whose shuffled order is the point.
:param segment: ``image -> labels`` in place of Object detection
(tests).
:param threaded: run on a worker thread.
:returns: whether a run started.
"""
from ..i18n import tr
if not self._image_files:
self._status_label.setText(tr(
"Open a folder before ranking fields by uncertainty."))
return False
if self._blind is not None:
self._status_label.setText(tr(
"Ranking by uncertainty would undo the blind order. Unblind "
"first."))
return False
pairs = self._field_pairs()
request = dict(kind='rank', segment=segment, fields=pairs,
layout=self._layout_kwargs(),
detect=self._uncertainty_detect_request(),
second_model=self._uncertainty_ensemble_model())
if not self._start_uncertainty(request, threaded):
return False
self._status_label.setText(tr(
"Ranking {count} fields by segmentation uncertainty: {passes} "
"detection runs each…", count=len(pairs),
passes=8 if request.get("second_model") else 4))
return True
def _take_uncertainty(self, payload) -> None:
"""Show a finished map, or reorder the fields by a finished ranking."""
from ..i18n import tr
request, result, error = payload
if request is not self._uncertainty_request:
return
self._uncertainty_request = None
self._set_uncertainty_enabled(True)
if error is not None:
self._warn(tr("Uncertainty failed"), str(error))
return
if request['kind'] == 'field':
if (request['token'] != self._load_token
or request['image_reference'] is not self._canvas.image):
self._status_label.setText(tr(
"Uncertainty map discarded because the field changed."))
return
self._uncertainty_result = (request, result)
self._show_uncertainty_map(result, request['image'])
return
model = ' + '.join(filter(None, (request['detect']['model'],
request.get('second_model'))))
self._apply_uncertainty_ranking(request['fields'], result, model=model)
def _show_uncertainty_map(self, result: dict, image) -> None:
"""Put the map on an Uncertainty tab, made the first time it is used.
:param result: :func:`spacr.active_learning._segmentation_uncertainty`
output for the field on screen.
:param image: the field it was computed on.
"""
from ..i18n import tr
if self._uncertainty_pane is None:
self._uncertainty_pane = _FlowPane()
self._view_tabs.addTab(self._uncertainty_pane, tr("Uncertainty"))
self._uncertainty_pane.show_rgb(
_uncertainty_heatmap(result['map'], image))
self._view_tabs.setCurrentWidget(self._uncertainty_pane)
objects = result['objects']
worst = max(objects, key=objects.get) if objects else None
text = tr("Segmentation uncertainty {value} over {count} objects.",
value=f"{result['field']:.2f}", count=result['n_objects'])
if worst is not None:
text += " " + tr("The least certain is object {label} ({value}).",
label=worst, value=f"{objects[worst]:.2f}")
self._status_label.setText(text)
def _apply_uncertainty_ranking(self, pairs, scores, *, model=None) -> None:
"""Save the scores and offer the most uncertain field first.
The scores are merged into ``curate_uncertainty.csv`` in the queue's
folder, or in the open folder, where ``spacr-make-masks --order
uncertain`` reads them. A field that could not be scored is offered
after every scored one.
:param pairs: the ``(folder, file name)`` pairs that were ranked.
:param scores: ``{pair: score dictionary}``.
:param model: captured model identity; defaults to the current selection.
"""
from ..i18n import tr
from ...curation_queue import SEG_SUFFIX, _write_uncertainty
def stem(name: str) -> str:
""":param name: a field's file name.
:returns: its stem, as the curation queue names it.
"""
if name.endswith(SEG_SUFFIX):
return name[:-len(SEG_SUFFIX)]
return os.path.splitext(name)[0]
model = model or self._cp_model.currentData() or 'cpsam'
rows = {stem(name): dict(uncertainty=round(score['field'], 4),
n_objects=score['n_objects'],
passes=score['n_passes'], model=model)
for (_folder, name), score in scores.items()}
target = (str(self._queue.folder) if self._queue is not None
else self._folder)
try:
if rows and target:
_write_uncertainty(target, rows)
except OSError as exc:
self._report(tr("Uncertainty scores not saved: {error}",
error=str(exc)), "warning")
if self._blind is not None:
self._status_label.setText(tr(
"Uncertainty saved; blind order was preserved."))
return
if self._field_pairs() != list(pairs):
self._status_label.setText(tr(
"Uncertainty saved; the open fields changed while ranking, "
"so their order was left alone."))
return
ordered = sorted(pairs, key=lambda pair: -scores[tuple(pair)]['field']
if tuple(pair) in scores else 1.0)
if not self._save_boxes_if_needed():
return
self._set_field_pairs(ordered)
self._current_index = 0
self._load_current()
self._sync_button_states()
self._status_label.setText(tr(
"Ranked {count} fields by segmentation uncertainty, the most "
"uncertain first.", count=len(scores)))
def _set_blind_checked(self, on: bool) -> None:
"""Move the Blind switch without asking it to act."""
button = getattr(self, "_btn_blind", None)
if button is None:
return
button.blockSignals(True)
button.setChecked(bool(on))
button.blockSignals(False)
def _on_blind_toggled(self, checked: bool) -> None:
"""Start blinding, or ask to unblind; undo the click if refused."""
if checked and self._blind is None:
if not self._start_blind():
self._set_blind_checked(False)
elif not checked and self._blind is not None:
if not self._end_blind():
self._set_blind_checked(True)
def _field_pairs(self) -> list:
"""``(folder, file name)`` of every open field, in the order offered."""
folders = self._field_folders or [self._folder] * len(
self._image_files)
return list(zip(folders, self._image_files))
def _set_field_pairs(self, pairs) -> None:
"""Offer ``pairs`` of ``(folder, file name)`` as the open fields."""
self._image_files = [name for _folder, name in pairs]
if self._field_folders is not None:
self._field_folders = [folder for folder, _name in pairs]
def _blind_label(self, path: str) -> str:
"""How a field is named on screen: its code while blinded.
:param path: the field's image path.
:returns: the code, a placeholder for a field made after blinding
started, or the file name when not blinded.
"""
if self._blind is None:
return os.path.basename(str(path))
from ..i18n import tr
code = self._blind["codes"].get(os.path.abspath(str(path)))
return code or tr("uncoded field")
def _blind_text(self, text: str) -> str:
"""Replace known source identifiers in displayed messages while blinded.
:param text: status, warning or confirmation text; stored paths are untouched.
:returns: text with field paths/names replaced by their codes and source
folders hidden. Identical names in different folders use a placeholder.
"""
if self._blind is None:
return str(text)
from ..i18n import tr
replacements = {}
folders = set()
for path, code in self._blind["codes"].items():
folders.add(os.path.dirname(path))
for identifier in (path, os.path.basename(path),
os.path.splitext(os.path.basename(path))[0]):
previous = replacements.get(identifier, code)
replacements[identifier] = (code if previous == code
else tr("uncoded field"))
for folder in folders:
for identifier in (folder, os.path.basename(folder)):
if identifier and identifier != os.path.sep:
replacements[identifier] = tr("Blind")
if not replacements:
return str(text)
pattern = "|".join(re.escape(value) for value in
sorted(replacements, key=len, reverse=True) if value)
return re.sub(pattern, lambda match: replacements[match.group()], str(text))
def _start_blind(self) -> bool:
"""Shuffle the open fields under a new blinding key.
:returns: whether blinding started; not without open fields.
"""
from ..i18n import tr
if not self._image_files:
self._status_label.setText(tr(
"Open a folder of images before curating it blind."))
return False
if not self._save_boxes_if_needed():
return False
self.finish_recrop()
pairs = self._field_pairs()
paths = [os.path.abspath(os.path.join(folder, name))
for folder, name in pairs]
from ...run_journal import start_blinding
try:
src = os.path.commonpath([os.path.dirname(p) for p in paths])
except ValueError:
src = self._folder
key = start_blinding(paths, scope="make_masks", src=src)
rank = {item: index for index, item in enumerate(key["order"])}
order = sorted(range(len(pairs)),
key=lambda i: rank.get(paths[i], len(rank)))
self._blind = {"key_id": key["key_id"], "codes": key["codes"],
"original": paths}
self._console_section.setProperty(
"_spacr_blind_visible", not self._console_section.isHidden())
self._console_section.hide()
self._blind_lock_rois(True)
self._set_field_pairs([pairs[i] for i in order])
self._current_index = 0
self._set_blind_checked(True)
self._src_label.setText(tr(
"Blinded: {count} fields, named by code and in a shuffled order.",
count=len(pairs)))
self._load_current()
self._sync_button_states()
return True
def _end_blind(self, *, ask=None) -> bool:
"""Unblind, after asking; record who did it and when; restore the order.
:param ask: returns whether to go ahead; a Yes/No question when
omitted.
:returns: whether the session was unblinded.
"""
if self._blind is None:
return True
from ..i18n import tr
if ask is None:
def ask():
"""Confirm revealing field paths and recording the unblind event."""
return self._confirm(
tr("Unblind?"),
tr("Unblinding shows every field's file name and folder "
"again, and the run journal records who unblinded and "
"when. An analysis lock on this folder treats any "
"later change as post-hoc. Unblind now?"))
if not ask():
return False
if not self._save_boxes_if_needed():
return False
from ...run_journal import unblind
unblind(self._blind["key_id"], reason="make_masks")
self._restore_blind_order()
self._load_current()
return True
def _leave_blind_unopened(self, reason: str) -> None:
"""End a blinded session without unblinding it, and log that it ended.
:param reason: why it ended, kept in the key's log.
"""
if self._blind is None:
return
from ...run_journal import _close_blinding
_close_blinding(self._blind["key_id"], reason=reason)
self._restore_blind_order()
def _blind_lock_rois(self, on: bool) -> None:
"""Disable the ROIs menu while blinded, and give it back afterwards.
Its exports are named after the field (``<stem>.geojson``,
``<stem>_RoiSet.zip``) and carry the image's file name inside, and
its file pickers open in the source folder, so each would show the
name blinding hides.
:param on: true when blinding starts.
"""
button = getattr(self, "_btn_rois", None)
if button is None:
return
if on:
button.setProperty("_spacr_blind_was", button.isEnabled())
button.setEnabled(False)
elif button.property("_spacr_blind_was") is not None:
button.setEnabled(bool(button.property("_spacr_blind_was")))
button.setProperty("_spacr_blind_was", None)
def _restore_blind_order(self) -> None:
"""Put the fields back in their own order and show their names again.
A field made while blinded (a recrop) follows the field it came
after. The field on screen stays on screen.
"""
original = {path: index for index, path
in enumerate(self._blind["original"])}
self._blind = None
self._console_section.setVisible(bool(
self._console_section.property("_spacr_blind_visible")))
self._console_section.setProperty("_spacr_blind_visible", None)
self._blind_lock_rois(False)
self._set_blind_checked(False)
pairs = self._field_pairs()
current = (pairs[self._current_index]
if 0 <= self._current_index < len(pairs) else None)
keyed = []
last = -1
for seq, (folder, name) in enumerate(pairs):
at = original.get(os.path.abspath(os.path.join(folder, name)))
if at is not None:
last = at
keyed.append(((at, 0, seq), (folder, name)))
else:
keyed.append(((last, 1, seq), (folder, name)))
restored = [pair for _key, pair in sorted(keyed)]
self._set_field_pairs(restored)
if current is not None:
self._current_index = restored.index(current)
if self._queue is not None:
self._src_label.setText(
f"{self._queue.folder} -- {len(restored)} to curate this "
f"session, {self._queue.order_phrase}")
elif self._folder:
self._src_label.setText(
f"{self._folder} — {len(restored)} images")
def _roi_object_type(self) -> str:
"""The object type the masks of this folder are exported as.
A masks folder named for its objects -- ``cell_mask_stack``,
``nucleus_masks`` -- gives that name; anything else gives
:data:`spacr.mask_io.DEFAULT_OBJECT_TYPE`.
:returns: the object type.
"""
from ...mask_io import DEFAULT_OBJECT_TYPE
folder = engine.masks_folder(self._folder or "", self._masks_dir)
name = os.path.basename(os.path.normpath(folder))
match = re.match(r"^(.+?)_masks?(?:_stack)?$", name)
return match.group(1) if match else DEFAULT_OBJECT_TYPE
def _roi_fields(self) -> List[tuple]:
"""Every field of the folder or queue, as ``(index, folder, name)``.
:returns: the fields, in queue order.
"""
return [(i, self._field_folders[i] if self._field_folders
else self._folder, name)
for i, name in enumerate(self._image_files or [])]
def _roi_field_labels(self, index: int, folder: str, name: str):
"""The labels of one field: on screen for the current one, else saved.
:param index: the field's place in the queue.
:param folder: the folder the field lies in.
:param name: the field's image file name.
:returns: the label mask; zeros for a field with no saved mask.
"""
if index == self._current_index and self._canvas.mask is not None:
return np.array(self._canvas.mask, copy=True)
_image, mask = engine.load_image_and_mask(folder, name,
**self._layout_kwargs())
return mask
[docs]
def export_field_rois(self, path: str, fmt: Optional[str] = None) -> str:
"""Write the mask on screen as QuPath GeoJSON, a RoiSet or COCO JSON.
:param path: the file to write.
:param fmt: ``"geojson"``, ``"imagej"`` or ``"coco"``; default from
the suffix of ``path``.
:returns: the path written, or ``""`` when no field is open.
"""
from ...mask_io import export_rois
if not self._image_files or self._canvas.mask is None:
return ""
name = self._image_files[self._current_index]
written = export_rois(self._canvas.mask, path, fmt,
object_type=self._roi_object_type(),
file_name=name)
return str(written)
[docs]
def export_all_rois(self, target: str, fmt: str) -> List[str]:
"""Write every field's mask as ROIs.
GeoJSON and ImageJ are one file per field in the folder ``target``
(``<stem>.geojson``, ``<stem>_RoiSet.zip``), the way QuPath and Fiji
open them beside the image; COCO is one dataset file, ``target``,
holding every field. The field on screen is exported as it is on
screen; the others as saved. Fields without objects are left out.
:param target: the folder (GeoJSON, ImageJ) or file (COCO).
:param fmt: ``"geojson"``, ``"imagej"`` or ``"coco"``.
:returns: the files written.
"""
from ... import mask_io
fmt = mask_io.roi_format(target, fmt)
kind = self._roi_object_type()
written: List[str] = []
dataset = None
for index, folder, name in self._roi_fields():
labels = self._roi_field_labels(index, folder, name)
if labels is None or not np.any(labels):
continue
stem = os.path.splitext(os.path.basename(name))[0]
if fmt == "coco":
dataset = mask_io.masks_to_coco(labels, file_name=name,
object_type=kind,
dataset=dataset)
continue
suffix = ".geojson" if fmt == "geojson" else "_RoiSet.zip"
out = mask_io.export_rois(labels, os.path.join(target, stem + suffix),
fmt, object_type=kind, file_name=name)
written.append(str(out))
if fmt == "coco" and dataset is not None:
import json
os.makedirs(os.path.dirname(os.path.abspath(target)), exist_ok=True)
with open(target, "w", encoding="utf-8") as handle:
json.dump(dataset, handle)
written.append(str(target))
return written
def _pick_roi_type(self, masks: dict, object_type: Optional[str]):
"""The one object type an import puts on the single-layer canvas.
:param masks: the imported masks by object type.
:param object_type: the type asked for, or ``None``.
:returns: the chosen type, or ``None`` when the user cancelled.
"""
from ..i18n import tr
if object_type in masks:
return object_type
names = list(masks)
if self._roi_object_type() in masks:
names.remove(self._roi_object_type())
names.insert(0, self._roi_object_type())
if len(names) == 1 or is_headless() or object_type is not None:
return names[0]
chosen, accepted = QInputDialog.getItem(
self, tr("Import ROIs"),
tr("The file holds several object types. Which one goes on "
"this field?"), names, 0, False)
return chosen if accepted else None
[docs]
def import_field_rois(self, path: str, fmt: Optional[str] = None,
object_type: Optional[str] = None) -> int:
"""Replace the mask on screen with the objects of a ROI file.
One edit on the undo stack and in the curation ledger; nothing is
written until the mask is saved. A file of several object types puts
``object_type`` on the canvas, else the folder's own type, else the
user's choice.
:param path: the GeoJSON, RoiSet or COCO file.
:param fmt: its format; default from the file.
:param object_type: the object type to take.
:returns: the number of objects on screen afterwards, or ``-1`` when
nothing was imported.
"""
from ...mask_io import import_rois
if not self._image_files or self._canvas.mask is None:
return -1
current = self._canvas.mask
name = self._image_files[self._current_index]
masks = import_rois(path, current.shape, fmt, file_name=name)
if not masks:
return -1
kind = self._pick_roi_type(masks, object_type)
if kind is None:
return -1
labels = masks[kind]
dtype = current.dtype
if labels.size and int(labels.max()) > np.iinfo(dtype).max:
dtype = labels.dtype
new = labels.astype(dtype, copy=False)
self._apply_op(lambda _mask: new, "import_rois",
source=os.path.basename(str(path)), object_type=kind)
return int(np.count_nonzero(np.unique(new)))
def _roi_file_for(self, source: str, fmt: str, name: str) -> str:
"""The file in ``source`` that holds one field's ROIs, or ``""``.
:param source: the folder the ROI files are in.
:param fmt: ``"geojson"`` or ``"imagej"``.
:param name: the field's image file name.
:returns: the first of the usual names that exists.
"""
stem = os.path.splitext(os.path.basename(name))[0]
suffixes = ((".geojson", ".json") if fmt == "geojson"
else ("_RoiSet.zip", ".zip", ".roi"))
for suffix in suffixes:
candidate = os.path.join(source, stem + suffix)
if os.path.isfile(candidate):
return candidate
return ""
[docs]
def import_all_rois(self, source: str, fmt: str,
object_type: Optional[str] = None) -> List[str]:
"""Import ROIs for every field that has them and save them as masks.
GeoJSON and ImageJ files are found in the folder ``source`` by the
field's stem (``<stem>.geojson``, ``<stem>_RoiSet.zip`` ...); a COCO
``source`` is one file matched on ``file_name``. Each matched field's
mask is written as :meth:`_on_save` writes it; the field on screen is
replaced on the canvas instead, as one undoable edit, and saved with
the rest when the user saves it.
:param source: the folder (GeoJSON, ImageJ) or file (COCO).
:param fmt: ``"geojson"``, ``"imagej"`` or ``"coco"``.
:param object_type: the object type to take from files of several;
default the folder's own type, else the first in the file.
:returns: the names of the fields that were given masks.
"""
from ... import mask_io
fmt = mask_io.roi_format(source, fmt)
coco_names = mask_io.coco_image_names(source) if fmt == "coco" else []
done: List[str] = []
for index, folder, name in self._roi_fields():
if fmt == "coco":
base = os.path.basename(name)
stems = {os.path.splitext(os.path.basename(n))[0]
for n in coco_names}
if (base not in {os.path.basename(n) for n in coco_names}
and os.path.splitext(base)[0] not in stems):
continue
path = source
else:
path = self._roi_file_for(source, fmt, name)
if not path:
continue
if index == self._current_index and self._canvas.mask is not None:
if self.import_field_rois(path, fmt, object_type
or self._roi_object_type()) >= 0:
done.append(name)
continue
_image, mask = engine.load_image_and_mask(folder, name,
**self._layout_kwargs())
masks = mask_io.import_rois(path, mask.shape, fmt, file_name=name)
if not masks:
continue
wanted = object_type or self._roi_object_type()
labels = masks.get(wanted, next(iter(masks.values())))
engine.save_mask(folder, name, labels, **self._layout_kwargs())
done.append(name)
return done
def _roi_filter(self, fmt: str) -> str:
"""The file-dialog filter for one ROI format.
:param fmt: ``"geojson"``, ``"imagej"`` or ``"coco"``.
:returns: the filter text.
"""
from ..i18n import tr
return {"geojson": tr("QuPath GeoJSON (*.geojson)"),
"imagej": tr("ImageJ RoiSet (*.zip)"),
"coco": tr("COCO JSON (*.json)")}[fmt]
def _on_export_field_rois(self, fmt: str) -> None:
"""Ask where, then export the field on screen."""
from ...mask_io import roi_suffix
from ..i18n import tr
if not self._image_files:
return
name = self._image_files[self._current_index]
stem = os.path.splitext(os.path.basename(name))[0]
default = stem + ("_RoiSet.zip" if fmt == "imagej" else roi_suffix(fmt))
path, _filter = QFileDialog.getSaveFileName(
self, tr("Export ROIs"),
os.path.join(self._folder or os.getcwd(), default),
self._roi_filter(fmt))
if not path:
return
try:
written = self.export_field_rois(path, fmt)
except (ImportError, ValueError, OSError) as exc:
self._warn(tr("Export failed"), str(exc))
return
self._status_label.setText(tr("ROIs exported → {path}", path=written))
def _on_export_all_rois(self, fmt: str) -> None:
"""Ask where, then export every field."""
from ..i18n import tr
if not self._image_files:
return
start = self._folder or os.getcwd()
if fmt == "coco":
target, _filter = QFileDialog.getSaveFileName(
self, tr("Export ROIs"),
os.path.join(start, "annotations_coco.json"),
self._roi_filter(fmt))
else:
target = QFileDialog.getExistingDirectory(
self, tr("Folder for the ROI files"), start)
if not target:
return
try:
written = self.export_all_rois(target, fmt)
except (ImportError, ValueError, OSError) as exc:
self._warn(tr("Export failed"), str(exc))
return
self._status_label.setText(tr(
"{n} ROI file(s) written → {path}", n=len(written), path=target))
def _on_import_field_rois(self) -> None:
"""Ask for a ROI file, then import it into the field on screen."""
from ..i18n import tr
if not self._image_files:
return
filters = ";;".join([
tr("ROI files (*.geojson *.json *.zip *.roi)"),
self._roi_filter("geojson"), self._roi_filter("imagej"),
self._roi_filter("coco")])
path, _filter = QFileDialog.getOpenFileName(
self, tr("Import ROIs"), self._folder or os.getcwd(), filters)
if not path:
return
try:
count = self.import_field_rois(path)
except (ImportError, ValueError, OSError, KeyError) as exc:
self._warn(tr("Import failed"), str(exc))
return
if count >= 0:
self._status_label.setText(tr(
"{n} object(s) imported from {path}; save to keep them",
n=count, path=os.path.basename(path)))
def _on_import_all_rois(self, fmt: str) -> None:
"""Ask for the ROI files, confirm, then import them for every field."""
from ..i18n import tr
if not self._image_files:
return
start = self._folder or os.getcwd()
if fmt == "coco":
source, _filter = QFileDialog.getOpenFileName(
self, tr("Import ROIs"), start, self._roi_filter(fmt))
else:
source = QFileDialog.getExistingDirectory(
self, tr("Folder of ROI files"), start)
if not source or not self._confirm(
tr("Import ROIs"),
tr("Replace the saved mask of every field that has ROIs in "
"{path}?", path=os.path.basename(source))):
return
try:
done = self.import_all_rois(source, fmt)
except (ImportError, ValueError, OSError, KeyError) as exc:
self._warn(tr("Import failed"), str(exc))
return
self._status_label.setText(tr(
"ROIs imported for {n} field(s)", n=len(done)))
[docs]
def save_curated_mask(self) -> str:
"""Write the labels Curate corrected back to the mask file.
The Curate page retains corrected labels in its curation session. This
method calls :meth:`spacr.curation.MaskCuration.save_mask`, which
writes the labels and, when edits exist, writes the corresponding
curation ledger beside the output file.
:returns: the path written, or ``""`` when there is nothing to write.
"""
screen = self._fold_screens.get("curate")
brush = getattr(screen, "brush", None)
if brush is None:
self._status_label.setText(
"Open a mask in Curate before saving it.")
return ""
try:
written = brush.session.save_mask()
except Exception as exc:
self._warn("Save failed", str(exc))
return ""
self._status_label.setText(f"Saved → {written}")
return written
[docs]
def close_folded(self) -> None:
"""Close every folded module, and everything it started.
Closing the panel is not enough. A module polls the machine's RAM
and GPU on a worker thread, and Qt answers a running QThread being
destroyed by aborting the process — so the module's own close
handler, which drains that worker, has to run. A page nested
inside another module's tabs never gets one from Qt, which is why
they are closed by hand here.
EVERY MODULE THAT WAS BUILT, not every module that was opened. A
module is built the moment something asks this screen to point it
at the open folder — :meth:`seed_folded` does, and so does any
test or caller reaching for :meth:`folded_screen` — and pointing
Model Compare at a folder starts a load thread before its panel
has ever been on screen. Walking the panels alone left those
threads running with nothing holding them, and the process died
of it several actions later, in whatever happened to be running
when the memory behind them was touched.
"""
from .app_screen import AppScreen
for screen in list(self._fold_screens.values()):
for page in screen.findChildren(AppScreen):
page.close()
screen.close()
for panel in list(self._fold_dialogs.values()):
panel.close()
if self._otsu_histogram_dialog is not None:
self._otsu_histogram_dialog.close()
self._otsu_histogram_dialog = None
self._close_levels()
if self._histogram_worker is not None:
self._histogram_worker.close(timeout=0)
self._histogram_worker = None
if self._detection_worker is not None:
self._detection_worker.close(timeout=0)
self._detection_worker = None
self._detection_request = None
self._btn_cellpose.setEnabled(True)
def _build_tool_row(self) -> QWidget:
"""The one row that holds every tool, along the top of the screen.
THE WHOLE SET IS VISIBLE AT ONCE. The tools used to be a 2x3 grid
inside a card in the side panel, where finding a tool meant
reading a block; in one row they are read left to right and the
one you want is where you last saw it.
The row is built from :func:`tool_row_entries`, so a tool added to
:data:`TOOL_MODES` — or a ``MODE_*`` constant added with no table
entry at all — appears here without its author editing this
method. Actions that are not modes come in through
:meth:`add_toolbar_action` and land in the same row.
The Magnifier and, directly right of it, the settings toggle are
PINNED at the right end, outside the part that scrolls. The
toggle is checkable because it reports a state rather than firing
an action: it stays lit for as long as the settings are on
screen. The tools wider than the window scroll; the pair does
not, so the way back to the settings is never scrolled out of
sight. A
stretch after the last tool keeps the tools against the left
edge, above the settings they sit over.
:returns: The strip that holds the scrolling tools and the pinned
pair, kept as ``self._tool_row``.
"""
bar = QWidget()
bar.setObjectName("MakeMasksToolTools")
row = QHBoxLayout(bar)
row.setContentsMargins(0, 0, 0, 0)
row.setSpacing(SPACING["sm"])
#: The row itself, kept so a tool added after this screen was built
#: has somewhere to go.
self._tool_row_layout = row
#: Width the settings pane goes back to when it is shown again.
self._settings_width = SETTINGS_WIDTH
self._mode_buttons: dict[str, QPushButton] = {}
for mode, label, icon_key in tool_row_entries():
from ..i18n import tr
btn = QPushButton(tr(label))
if mode == MODE_WAND_ADD:
btn.setToolTip(tr("Click to add an intensity region. Hold Ctrl and click to remove it."))
if mode == MODE_DIVIDE:
btn.setToolTip(tr("Left-drag to divide an object. Right-drag across objects to merge them."))
btn.setIcon(iconset.icon(icon_key))
if mode == MODE_BOX:
btn.setObjectName("MakeMasksBoxTool")
btn.setToolTip(tr("Draw YOLO bounding boxes (X). Drag inside to move, "
"drag a corner to resize, or right-click to delete. "
"Ctrl-drag draws an overlapping box."))
btn.setCheckable(True)
btn.setMinimumHeight(32)
btn.setCursor(Qt.PointingHandCursor)
btn.clicked.connect(lambda _c=False, key=mode: self._set_mode(key))
row.addWidget(btn)
self._mode_buttons[mode] = btn
self._btn_brush = self._mode_buttons[MODE_BRUSH]
self._btn_erase = self._mode_buttons[MODE_ERASE]
self._btn_del_obj = self._mode_buttons[MODE_ERASE_OBJECT]
self._btn_wand_add = self._mode_buttons[MODE_WAND_ADD]
self._btn_wand = self._btn_wand_add
self._btn_wand_erase = self._btn_wand
self._btn_zoom = self._mode_buttons[MODE_ZOOM]
self._btn_recrop = self._mode_buttons[MODE_RECROP]
self._btn_recrop.setToolTip(RECROP_TOOLTIP)
self._mode_buttons[MODE_RULER].setToolTip(tr(
"Drag a line to measure its length in image pixels. "
"Right-click with Ruler selected to clear it. Zoom and pan preserve the measurement."))
self._box_controls = QWidget()
box_row = QHBoxLayout(self._box_controls)
box_row.setContentsMargins(0, 0, 0, 0)
self._box_class_combo = QComboBox()
self._box_class_combo.setMinimumWidth(100)
self._box_class_combo.setMaximumWidth(180)
self._box_class_combo.setToolTip(tr("Class for a selected box or the next box"))
self._box_class_combo.addItem('object', 0)
self._box_class_combo.currentIndexChanged.connect(self._on_box_class_changed)
box_row.addWidget(self._box_class_combo)
self._btn_add_box_class = QPushButton(tr("Add class"))
self._btn_add_box_class.clicked.connect(lambda: self._on_add_box_class())
box_row.addWidget(self._btn_add_box_class)
self._btn_save_boxes = QPushButton(tr("Save boxes"))
self._btn_save_boxes.clicked.connect(self._on_save_boxes)
box_row.addWidget(self._btn_save_boxes)
self._btn_export_yolo = QPushButton(tr("Export YOLO labels"))
self._btn_export_yolo.setToolTip(tr("Export this image's boxes as a YOLO .txt "
"label file with a class-name companion."))
self._btn_export_yolo.clicked.connect(lambda: self._on_export_yolo_boxes())
box_row.addWidget(self._btn_export_yolo)
self._box_controls.hide()
row.insertWidget(tuple(self._mode_buttons).index(MODE_BOX) + 1, self._box_controls)
row.addWidget(Divider(Qt.Vertical))
self._btn_reset_zoom = QPushButton("Reset zoom")
self._btn_reset_zoom.setIcon(iconset.icon("zoom_reset"))
self._btn_reset_zoom.setCursor(Qt.PointingHandCursor)
self._btn_reset_zoom.setEnabled(False)
self._btn_reset_zoom.clicked.connect(self._on_reset_zoom)
row.addWidget(self._btn_reset_zoom)
self._btn_undo = QPushButton("Undo")
self._btn_undo.setIcon(iconset.icon("undo"))
self._btn_undo.setCursor(Qt.PointingHandCursor)
self._btn_undo.setEnabled(False)
self._btn_undo.clicked.connect(self._on_undo)
row.addWidget(self._btn_undo)
self._btn_redo = QPushButton("Redo")
self._btn_redo.setIcon(iconset.icon("redo"))
self._btn_redo.setCursor(Qt.PointingHandCursor)
self._btn_redo.setEnabled(False)
self._btn_redo.clicked.connect(self._on_redo)
row.addWidget(self._btn_redo)
self._btn_settings = QPushButton("Settings")
self._btn_settings.setIcon(iconset.icon("settings"))
self._btn_settings.setCheckable(True)
self._btn_settings.setMinimumHeight(32)
self._btn_settings.setCursor(Qt.PointingHandCursor)
self._btn_settings.setToolTip(
"Show or hide the settings — brush, wand, display, auto-filter, "
"object operations, Otsu, object detection and the live "
"magnifier, as one group. The canvas takes the width they give "
"up.")
self._btn_settings.setChecked(True)
self._btn_settings.toggled.connect(self._on_toggle_settings)
row.addStretch(1)
pinned = QWidget()
pinned.setObjectName("MakeMasksToolPin")
pin = QHBoxLayout(pinned)
pin.setContentsMargins(SPACING["sm"], 0, 0, 0)
pin.setSpacing(SPACING["sm"])
pin.addWidget(self._btn_settings)
self._tool_pin_layout = pin
self._tool_pin = pinned
scroller = QScrollArea()
scroller.setObjectName("MakeMasksToolScroll")
scroller.setWidgetResizable(True)
scroller.setFrameShape(QScrollArea.NoFrame)
scroller.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
scroller.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
scroller.setWidget(bar)
scroller.setFixedHeight(
bar.sizeHint().height()
+ scroller.horizontalScrollBar().sizeHint().height())
scroller.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
self._tool_scroll = scroller
strip = QWidget()
strip.setObjectName("MakeMasksToolRow")
line = QHBoxLayout(strip)
line.setContentsMargins(0, 0, 0, 0)
line.setSpacing(0)
line.addWidget(scroller, 1)
line.addWidget(pinned, 0, Qt.AlignTop)
strip.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
return strip
def _curation_paths(self):
"""The field being judged and the mask it is judged with.
:returns: ``(image path, mask path)``, or ``(None, None)`` when no
field is open.
"""
if not self._folder or not self._image_files:
return None, None
try:
filename = self._image_files[self._current_index]
except (IndexError, TypeError):
return None, None
image_path = os.path.join(self._folder, filename)
mask_path = engine.mask_save_path(self._folder, filename,
**self._layout_kwargs())
return image_path, mask_path
def _on_curate(self, keep: bool):
"""Record a keep or discard verdict for the field on screen.
THE COUNT IS READ NOW, not when the field was loaded, so a field
curated after hand editing records the objects the user was looking
at when they pressed the button.
:param keep: True for Keep, False for Discard.
:returns: the CSV's path, or None when there was nothing to record.
"""
if not self._save_mask_if_needed() or not self._save_boxes_if_needed():
return None
image_path, mask_path = self._curation_paths()
if image_path is None:
self._show_curation_verdict(None)
return None
try:
written = engine.record_curation(
self._folder, image_path, mask_path, self._objects_now(),
keep)
except OSError as exc:
LOG.warning("Could not record the curation verdict: %s", exc)
self._warn("Verdict not recorded",
f"{self._blind_label(image_path)} could not be "
f"marked: {exc}")
self._show_curation_verdict(
engine.curation_verdict(self._folder, image_path))
return None
self._show_curation_verdict(keep)
self._advance_after_verdict(keep, self._blind_label(image_path))
return written
def _advance_after_verdict(self, keep: bool, judged: str) -> bool:
"""Move to the next field, and say what the verdict was on the way.
Pressing Keep or Discard moves to the next image. Curation is a walk,
and a verdict is the thing that ends a field -- a curator who has to
press Keep and then Next presses twice per field for a thousand
fields.
:meth:`_on_next` is called rather than the index being moved here,
so there is ONE definition of what next means -- it retires a recrop
first, and it stops at the end of the folder instead of wrapping.
THE MESSAGE IS BUILT HERE AND NOT IN A HELPER THAT APPENDS TO THE
STATUS LINE. Loading the next field rewrites that line, so a verdict
appended before the move is gone a moment later, and one appended
after the move reads as a verdict on the field that just opened --
which is the opposite of what happened. The name of the field that
was judged is therefore carried in and spelled out.
:param keep: True for Keep, False for Discard.
:param judged: the basename of the field the verdict was about.
:returns: whether the screen moved to another field.
"""
said = "kept" if keep else "discarded — nothing was deleted"
was = self._current_index
self._on_next()
moved = self._current_index != was
if moved:
now = self._blind_label(os.path.join(
self._folder or "", self._image_files[self._current_index]))
self._status_label.setText(f"{judged} {said} — now on {now}")
elif (self._image_files
and self._current_index >= len(self._image_files) - 1):
self._status_label.setText(
f"{judged} {said} — that was the last field in this folder")
else:
self._status_label.setText(f"{judged} {said}")
return moved
def _show_curation_verdict(self, verdict):
"""Put the field's current verdict on the two buttons.
A field already marked shows it rather than looking unpressed,
which is the difference between a record and a button that does
something invisible.
:param verdict: True, False, or None for no verdict.
"""
for button, state in ((getattr(self, "_btn_keep", None),
verdict is True),
(getattr(self, "_btn_discard", None),
verdict is False)):
if button is None:
continue
blocked = button.blockSignals(True)
button.setChecked(bool(state))
button.blockSignals(blocked)
def _refresh_curation_buttons(self):
"""Show the verdict of whichever field is open now."""
image_path, _mask_path = self._curation_paths()
if image_path is None:
self._show_curation_verdict(None)
return
try:
self._show_curation_verdict(
engine.curation_verdict(self._folder, image_path))
except OSError:
self._show_curation_verdict(None)
def _sync_tool_row_visibility(self, *_args) -> None:
"""Show the tool row only while the editor is the body.
There is nothing to brush, undo or configure until a folder is
open, and a row of dead buttons over the empty state reads as a
broken screen rather than an empty one.
"""
self._tool_row.setVisible(
self._body_stack.currentWidget() is self._body_splitter)
[docs]
def settings_shown(self) -> bool:
"""Whether the settings group is on screen."""
return self._btn_settings.isChecked()
def _on_toggle_settings(self, shown: bool) -> None:
"""Hide or show the settings as one group.
THE CANVAS KEEPS THE SPACE. Hiding a splitter child gives its
width to the sibling, so the image grows into the panel's place
rather than leaving a gap where the panel was. The width the
panel had is remembered while it is away, so a second press puts
it back where the user last dragged it instead of at the default.
The settings are the splitter's first pane, left of the image.
"""
splitter = self._body_splitter
if not shown:
sizes = splitter.sizes()
if len(sizes) > 1 and sizes[0] > 0:
self._settings_width = sizes[0]
self._settings_scroll.setVisible(shown)
if shown:
sizes = splitter.sizes()
total = sum(sizes) or (900 + SETTINGS_WIDTH)
side = max(min(self._settings_width, total - 1), 1)
splitter.setSizes([side, total - side])
def _build_tools_panel(self) -> QWidget:
"""Build the tool column: mode, brush, wand and mask operations.
:returns: the assembled panel.
"""
from .. import screens as _screens_package
from ..i18n import tr
wrap = QWidget()
col = QVBoxLayout(wrap)
col.setContentsMargins(0, 0, 0, 0)
col.setSpacing(SPACING["md"])
self._settings_categories: List[tuple] = []
try:
from ..preferences import get_section_layout
folded = get_section_layout(_SETTINGS_LAYOUT_KEY).get("folded")
except Exception: # noqa: BLE001
LOG.debug("could not read the folded categories", exc_info=True)
folded = None
self._folded_categories = {
_RENAMED_CATEGORIES.get(str(t), str(t)) for t in (folded or ())}
brush_card = self._settings_category("Brush")
brush_form = QFormLayout()
self._brush_slider = QSlider(Qt.Horizontal)
self._brush_slider.setRange(1, 100)
self._brush_slider.setValue(10)
self._brush_slider.setToolTip(
"Radius of the brush and the eraser, in pixels on screen. It is "
"scaled into image pixels at the current zoom, so the disk under "
"the cursor stays the same size and zooming in paints a finer "
"stroke on the data."
)
self._brush_slider.valueChanged.connect(self._on_brush_size_changed)
self._brush_size_label = QLabel("10 px")
self._brush_size_label.setObjectName("Muted")
brush_row = QHBoxLayout()
brush_row.addWidget(self._brush_slider, 1)
brush_row.addWidget(self._brush_size_label)
brush_wrap = QWidget(); brush_wrap.setLayout(brush_row)
brush_form.addRow("Radius", brush_wrap)
brush_card.body_layout.addLayout(brush_form)
col.addWidget(brush_card)
wand_card = self._settings_category("Magic wand")
wand_form = QFormLayout()
self._wand_relative = Toggle("Tolerance is % of image range")
self._wand_relative.setChecked(True)
self._wand_relative.setToolTip(
"On: the tolerance below is a percentage of THIS image's own "
"intensity range, so one setting behaves the same on 8-bit and "
"16-bit data. Off: a fixed grey-level distance, which selects "
"nothing on a 16-bit image at a value tuned for 8-bit and "
"floods the whole frame the other way round."
)
self._wand_relative.toggled.connect(self._on_wand_relative_changed)
wand_card.body_layout.addWidget(self._wand_relative)
self._wand_pct = QDoubleSpinBox()
self._wand_pct.setDecimals(3)
self._wand_pct.setRange(0.001, 100.0)
self._wand_pct.setSingleStep(0.5)
self._wand_pct.setValue(5.0)
self._wand_pct.setToolTip(
"How far the flood may stray from the value under the click, as "
"a percentage of this image's own intensity range. Raise it to "
"take in more of a dim object; lower it when the flood spills "
"into the background."
)
self._wand_pct.valueChanged.connect(self._on_wand_pct_changed)
wand_form.addRow("Tolerance %", self._wand_pct)
self._wand_tol = QDoubleSpinBox()
self._wand_tol.setRange(0.0, 1_000_000.0)
self._wand_tol.setSingleStep(50.0)
self._wand_tol.setValue(1000.0)
self._wand_tol.setEnabled(False)
self._wand_tol.setToolTip(
"The same distance in raw grey levels, read only while the "
"percentage above is switched off. A setting tuned on 8-bit data "
"selects almost nothing on a 16-bit image, which is why the "
"percentage is the default."
)
self._wand_tol.valueChanged.connect(self._on_wand_tolerance_changed)
wand_form.addRow("Tolerance (absolute)", self._wand_tol)
self._wand_max = QSpinBox()
self._wand_max.setRange(1, 10_000_000)
self._wand_max.setSingleStep(1000)
self._wand_max.setValue(100_000)
self._wand_max.setToolTip(
"The most pixels one wand click may flood, which is also what "
"bounds how long a click can take. What happens when a flood "
"reaches the cap is the switch below."
)
self._wand_max.valueChanged.connect(self._on_wand_max_changed)
wand_form.addRow("Max pixels", self._wand_max)
self._wand_salvage = Toggle("Keep the nearest pixels at the cap")
self._wand_salvage.setChecked(True)
self._wand_salvage.setToolTip(
"On: a flood over the budget is trimmed back to the pixels "
"reachable nearest the click, which leaves a bounded object you "
"can edit. Off: an over-budget flood is refused outright and the "
"mask is untouched, so a tolerance that is far too high says so "
"instead of handing back a piece of the field."
)
self._wand_salvage.toggled.connect(self._on_wand_salvage_changed)
wand_form.addRow("", self._wand_salvage)
wand_card.body_layout.addLayout(wand_form)
_screens_package._breathe_while_a_window_opens()
runaway = QGroupBox("Trim a runaway flood")
runaway.setCheckable(True)
runaway.setChecked(True)
runaway.setToolTip(
"A flood that reaches a bright seam — debris, a saturated "
"membrane, a well rim — walks out along it and takes the field. "
"This reads the flood's width outward from the click and cuts it "
"where it suddenly and persistently widens. It does nothing to a "
"flood that did not run away."
)
runaway.toggled.connect(self._on_wand_trim_runaway_changed)
runaway_form = QFormLayout(runaway)
self._wand_runaway_ratio = QDoubleSpinBox()
self._wand_runaway_ratio.setDecimals(2)
self._wand_runaway_ratio.setRange(1.2, 10.0)
self._wand_runaway_ratio.setSingleStep(0.1)
self._wand_runaway_ratio.setValue(2.0)
self._wand_runaway_ratio.setToolTip(
"How much wider than the object a scanline must be to count as a "
"leak. Lower it if leaks are getting through; raise it if a "
"genuinely lobed object is being cut."
)
self._wand_runaway_ratio.valueChanged.connect(
self._on_wand_runaway_ratio_changed)
runaway_form.addRow("Growth ratio", self._wand_runaway_ratio)
self._wand_runaway_warmup = QSpinBox()
self._wand_runaway_warmup.setRange(1, 500)
self._wand_runaway_warmup.setValue(12)
self._wand_runaway_warmup.setToolTip(
"Pixels nearest the click that are not judged. One pixel widening "
"to two is a ratio of 2 and means nothing, so the first rows out "
"of the seed are skipped."
)
self._wand_runaway_warmup.valueChanged.connect(
self._on_wand_runaway_warmup_changed)
runaway_form.addRow("Warm-up (px)", self._wand_runaway_warmup)
self._wand_runaway_min_base = QSpinBox()
self._wand_runaway_min_base.setRange(1, 1000)
self._wand_runaway_min_base.setValue(8)
self._wand_runaway_min_base.setToolTip(
"The width the object must reach before a leak can be called at "
"all. Raise it for large objects, lower it if the wand is used on "
"something only a few pixels across."
)
self._wand_runaway_min_base.valueChanged.connect(
self._on_wand_runaway_min_base_changed)
runaway_form.addRow("Min baseline (px)", self._wand_runaway_min_base)
self._wand_runaway_confirm = QSpinBox()
self._wand_runaway_confirm.setRange(1, 20)
self._wand_runaway_confirm.setValue(2)
self._wand_runaway_confirm.setToolTip(
"Consecutive widened scanlines required before cutting, so one "
"noisy row cannot take half the object off."
)
self._wand_runaway_confirm.valueChanged.connect(
self._on_wand_runaway_confirm_changed)
runaway_form.addRow("Confirmation (px)", self._wand_runaway_confirm)
wand_card.body_layout.addWidget(runaway)
self._wand_runaway_group = runaway
edge = QGroupBox("Shape the cut edge")
edge.setToolTip(
"A trimmed runaway ends in a straight line, which no cell has. "
"These two put the boundary back on the image: one re-floods at "
"a tolerance that does not escape, the other lets the edge settle "
"onto the nearest intensity gradient."
)
edge_form = QFormLayout(edge)
self._wand_intensity_border = Toggle("Re-flood below the escape")
self._wand_intensity_border.setChecked(True)
self._wand_intensity_border.setToolTip(
"When a leak is found, search for the highest tolerance whose "
"flood stays put and take that instead of the straight cut. The "
"boundary is then drawn by the image's own intensities. Only "
"helps when the seam is dimmer than the object; when it is "
"exactly as bright, no tolerance separates them and the cut "
"stands."
)
self._wand_intensity_border.toggled.connect(
self._on_wand_intensity_border_changed)
edge_form.addRow("", self._wand_intensity_border)
self._wand_intensity_steps = QSpinBox()
self._wand_intensity_steps.setRange(3, 14)
self._wand_intensity_steps.setValue(8)
self._wand_intensity_steps.setToolTip(
"Halvings used to find that tolerance. Each step is one more "
"flood, so this is precision against click latency; eight is "
"finer than one grey level on most images."
)
self._wand_intensity_steps.valueChanged.connect(
self._on_wand_intensity_steps_changed)
edge_form.addRow("Search steps", self._wand_intensity_steps)
self._wand_gradient_taper = Toggle("Taper onto the gradient")
self._wand_gradient_taper.setChecked(True)
self._wand_gradient_taper.setToolTip(
"Let the provisional edge move onto the nearest real intensity "
"change, inside the band below. This is what removes the last "
"straight lines and circular arcs left by a cut or a budget."
)
self._wand_gradient_taper.toggled.connect(
self._on_wand_gradient_taper_changed)
edge_form.addRow("", self._wand_gradient_taper)
self._wand_gradient_sigma = QDoubleSpinBox()
self._wand_gradient_sigma.setDecimals(1)
self._wand_gradient_sigma.setRange(0.0, 10.0)
self._wand_gradient_sigma.setSingleStep(0.5)
self._wand_gradient_sigma.setValue(2.0)
self._wand_gradient_sigma.setToolTip(
"Blur applied before looking for the edge. Raise it on speckled "
"fields so noise is not mistaken for a boundary; lower it for "
"small, sharply bounded objects."
)
self._wand_gradient_sigma.valueChanged.connect(
self._on_wand_gradient_sigma_changed)
edge_form.addRow("Smoothing (sigma)", self._wand_gradient_sigma)
self._wand_gradient_margin = QSpinBox()
self._wand_gradient_margin.setRange(1, 100)
self._wand_gradient_margin.setValue(8)
self._wand_gradient_margin.setToolTip(
"How far either side of the cut the edge is free to move. Wider "
"lets it find a boundary further away; too wide and it can reach "
"the seam the cut was made to escape."
)
self._wand_gradient_margin.valueChanged.connect(
self._on_wand_gradient_margin_changed)
edge_form.addRow("Transition band (px)", self._wand_gradient_margin)
self._wand_gradient_erode = QSpinBox()
self._wand_gradient_erode.setRange(0, 50)
self._wand_gradient_erode.setValue(3)
self._wand_gradient_erode.setToolTip(
"How far inside the kept region counts as certainly the object. "
"Everything between that inset and the discarded part is what the "
"taper is allowed to decide."
)
self._wand_gradient_erode.valueChanged.connect(
self._on_wand_gradient_erode_changed)
edge_form.addRow("Foreground inset (px)", self._wand_gradient_erode)
wand_card.body_layout.addWidget(edge)
self._wand_edge_group = edge
col.addWidget(wand_card)
norm_card = self._settings_category("Display")
_screens_package._breathe_while_a_window_opens()
norm_form = QFormLayout()
from ..mask_thumbnail_quality import quality_combo
self._thumbnail_quality = quality_combo(self)
norm_form.addRow(tr("Thumbnail quality"), self._thumbnail_quality)
self._norm_lo = QDoubleSpinBox()
self._norm_lo.setDecimals(PERCENTILE_DECIMALS)
self._norm_lo.setRange(0.0, 100.0)
self._norm_lo.setSingleStep(0.01)
self._norm_lo.setValue(1.0)
self._norm_lo.setToolTip(
"Percentile mapped to black. Raise it to sink background "
"speckle. Six decimals, so 0.0001 clips only the darkest few "
"pixels of a megapixel field."
)
self._norm_lo.valueChanged.connect(self._on_normalize_changed)
self._norm_hi = QDoubleSpinBox()
self._norm_hi.setDecimals(PERCENTILE_DECIMALS)
self._norm_hi.setRange(0.0, 100.0)
self._norm_hi.setSingleStep(0.01)
self._norm_hi.setValue(99.9)
self._norm_hi.setToolTip(
"Percentile mapped to white. Lower it to lift faint objects. "
"On a 16-bit field a handful of hot pixels hold the top of the "
"range on their own, so the useful setting is 99.9999 — the "
"top four pixels of four million — which needs six decimals."
)
self._norm_hi.valueChanged.connect(self._on_normalize_changed)
norm_form.addRow("Lower %", self._norm_lo)
norm_form.addRow("Upper %", self._norm_hi)
self._btn_levels = QPushButton(tr("Levels…"))
self._btn_levels.setToolTip(tr(
"Set black and white cutoffs by dragging on the image histogram."))
self._btn_levels.clicked.connect(self._on_levels)
norm_form.addRow(self._btn_levels)
self._detect_normalized = Toggle("Detect on the normalized image")
self._detect_normalized.setChecked(False)
self._detect_normalized.setToolTip(
"Off: Otsu, Cellpose and the magnifier read the image as it was "
"loaded (inverted if Invert is on), and Lower % / Upper % only "
"change how it is drawn. On: they read it stretched between Lower "
"% and Upper %, exactly as drawn, and the intensity in the "
"top-left corner shows that stretched value. What is saved is "
"never changed.")
self._detect_normalized.toggled.connect(self._on_detect_normalized)
norm_form.addRow(self._detect_normalized)
self._zoom_speed = QDoubleSpinBox()
self._zoom_speed.setDecimals(2)
self._zoom_speed.setRange(1.01, 3.0)
self._zoom_speed.setSingleStep(0.05)
self._zoom_speed.setValue(1.15)
self._zoom_speed.setToolTip(
"How far one wheel notch zooms. Higher jumps across a large "
"field faster; lower gives the fine steps that trimming an "
"object boundary needs. Shift or Alt + drag pans, from any tool."
)
self._zoom_speed.valueChanged.connect(self._on_zoom_speed_changed)
norm_form.addRow("Zoom per notch", self._zoom_speed)
norm_card.body_layout.addLayout(norm_form)
self._invert_display = Toggle("Invert image")
self._invert_display.setToolTip(tr(
"Invert the picture and the pixels used for detection, so dark "
"objects become bright. The field is normalized to 0..1, then "
"each pixel becomes 1 minus itself. Hover pixel intensity follows "
"the inversion; object mean intensity and Filter thresholds use "
"the original loaded values. The loaded image data and existing "
"mask are unchanged. To swap foreground and background in a "
"finished mask, use 'Swap object and background' in Object operations."
))
self._invert_display.toggled.connect(self._on_invert_display)
self._invert_display.toggled.connect(self._on_invert_toggled)
norm_card.body_layout.addWidget(self._invert_display)
col.addWidget(norm_card)
filter_card = self._settings_category(
"Filter",
tr("Add a filter for any regionprop scikit-image measures. "
"The list applies as you edit it and when a field opens; "
"each object it hides is listed below, and removing a row "
"brings back what it hid."),
)
self._filter_list = ObjectFilterList()
self._filter_list.changed.connect(self._on_filters_changed)
self._filter_list.row_added.connect(self._on_filter_row_added)
self._filter_add = self._filter_list.add_button
self._filter_property = self._filter_list.property_box
filter_card.body_layout.addWidget(self._filter_list)
self._btn_filter = QPushButton("Filter")
self._btn_filter.setCursor(Qt.PointingHandCursor)
self._btn_filter.setToolTip(tr(
"Apply the filter list to the mask on screen again. Every "
"object it hides is listed below it, with the value that hid "
"it. One undo step."))
self._btn_filter.clicked.connect(self._on_apply_filter)
filter_card.body_layout.addWidget(self._btn_filter)
self._filter_log = QPlainTextEdit()
self._filter_log.setObjectName(FILTER_LOG_NAME)
self._filter_log.setReadOnly(True)
self._filter_log.setLineWrapMode(QPlainTextEdit.NoWrap)
self._filter_log.setFixedHeight(
self._filter_log.fontMetrics().lineSpacing() * FILTER_LOG_ROWS + 12)
self._filter_log.setToolTip(tr(
"What the filter list hides, one row per object: its id, its "
"area and mean intensity, and each bound that hid it with the "
"value it was judged on. The ids are the ones the hover readout "
"shows."))
self._set_filter_log([])
filter_card.body_layout.addWidget(self._filter_log)
col.addWidget(filter_card)
obj_card = self._settings_category("Object operations")
_screens_package._breathe_while_a_window_opens()
ops_col = QVBoxLayout()
ops_col.setSpacing(SPACING["xs"])
for label, cb, hint in (
("Fill holes", self._on_fill_holes,
"Close every enclosed hole inside an object, so a nucleus "
"outlined as a ring becomes a filled disc."),
("Relabel", self._on_relabel,
"Renumber the objects 1, 2, 3… with no gaps. The picture does "
"not change; the ids underneath it do."),
("Swap object and background", self._on_invert,
"Turn every labelled pixel into background and every "
"background pixel into an object. On an ordinary field that "
"gives ONE object covering the whole frame with holes where "
"your objects were — useful only when you have outlined the "
"space between the cells and wanted the cells. This is not the "
"picture invert: that is 'Invert image', in the Display "
"category, and it leaves the mask alone."),
):
btn = QPushButton(label)
btn.setToolTip(hint)
btn.clicked.connect(cb)
ops_col.addWidget(btn)
ops_col.addWidget(self._build_uncertainty_setting())
remove_row = QHBoxLayout()
remove_row.setSpacing(SPACING["sm"])
self._min_area = QSpinBox()
self._min_area.setRange(0, 1_000_000)
self._min_area.setValue(100)
self._min_area.setToolTip(
"Smallest object worth keeping, in pixels. Removing small "
"objects drops everything under it, and neither Otsu detect nor "
"Object detection will produce an object below it, so one "
"judgement about debris is made in one box."
)
remove_row.addWidget(QLabel("Min area:"))
remove_row.addWidget(self._min_area, 1)
remove_btn = QPushButton("Remove")
remove_btn.clicked.connect(self._on_remove_small)
remove_row.addWidget(remove_btn)
remove_wrap = QWidget(); remove_wrap.setLayout(remove_row)
ops_col.addWidget(remove_wrap)
size_row = QHBoxLayout()
size_row.setSpacing(SPACING["sm"])
self._grow_step = QSpinBox()
self._grow_step.setRange(1, 50)
self._grow_step.setValue(1)
self._grow_step.setSuffix(" px")
self._grow_step.setToolTip(
"How many pixels one press of Dilate or Shrink moves every "
"object's boundary. The distance is the straight-line one, so 1 "
"moves the edge onto its four neighbours and not its corners, "
"and a Shrink undoes a Dilate of the same size on an object that "
"had room to grow.")
size_row.addWidget(QLabel("Grow / shrink:"))
size_row.addWidget(self._grow_step, 1)
self._btn_dilate = QPushButton("Dilate")
self._btn_dilate.setCursor(Qt.PointingHandCursor)
self._btn_dilate.setToolTip(
"Grow every object by that many pixels, into background only: an "
"object never takes a pixel from its neighbour and two objects "
"never become one, so the ids and the object count are the ones "
"you had. One undo step.")
self._btn_dilate.clicked.connect(self._on_dilate)
size_row.addWidget(self._btn_dilate)
self._btn_shrink = QPushButton("Shrink")
self._btn_shrink.setCursor(Qt.PointingHandCursor)
self._btn_shrink.setToolTip(
"Pull every object's boundary in by that many pixels. Each "
"object is pulled back from its neighbours as well as from the "
"background, so a pair that was touching comes apart. AN OBJECT "
"THINNER THAN TWICE THE DISTANCE DISAPPEARS, and the status line "
"says how many did. One undo step.")
self._btn_shrink.clicked.connect(self._on_shrink)
size_row.addWidget(self._btn_shrink)
size_wrap = QWidget(); size_wrap.setLayout(size_row)
ops_col.addWidget(size_wrap)
detect_row = QHBoxLayout()
detect_row.setSpacing(SPACING["sm"])
self._btn_otsu = QPushButton("Otsu detect")
self._btn_otsu.setCursor(Qt.PointingHandCursor)
self._btn_otsu.setToolTip(
"Run the CPU method chosen under Object detection on the whole "
"image and label what it finds, honouring the minimum area "
"above. Everything the method reads is that category: the "
"level's algorithm, the correction, the smoothing, which side "
"is the object, filling holes, splitting a pair that touches "
"and dropping what the frame cut. With Cellpose or a backend "
"chosen this button falls back to Otsu, because those have "
"the Object detection button of their own.")
self._btn_otsu.clicked.connect(self._on_detect_otsu)
detect_row.addWidget(self._btn_otsu)
self._combine_mode = QComboBox()
for _mode in ("replace", "merge"):
self._combine_mode.addItem(_mode, _mode)
self._combine_mode.setToolTip(
"replace: the detection becomes the mask and what was there is "
"gone. merge: keep every existing object and add detected ones "
"only where nothing is labelled, so a detection run halfway "
"through cannot undo the editing done so far.")
detect_row.addWidget(self._combine_mode, 1)
detect_wrap = QWidget(); detect_wrap.setLayout(detect_row)
ops_col.addWidget(detect_wrap)
from ..i18n import tr
obj_ops_wrap = QWidget(); obj_ops_wrap.setLayout(ops_col)
obj_card.body_layout.addWidget(obj_ops_wrap)
col.addWidget(obj_card)
from .. import screens as _screens_package
_screens_package._breathe_while_a_window_opens()
self._methods_card = self._build_detection_card()
col.addWidget(self._methods_card)
self._sync_method_controls()
_screens_package._breathe_while_a_window_opens()
col.addWidget(self._build_enhance_card())
_screens_package._breathe_while_a_window_opens()
col.addWidget(self._build_prompt_card())
col.addStretch(1)
return wrap
def _settings_category(self, title: str, subtitle: str = "") -> Section:
"""One settings category, folding the way the core applications' do.
The same :class:`~spacr.qt.widgets.section.Section` a core module's
settings panel is built from -- the chevron heading, the uppercase
title, the card it draws -- so a category here looks and folds like
one there. Its body is a plain vertical layout, which is what the
panel's controls were laid out in.
Categories START OPEN, unlike a core module's. This panel is the
editor's tool column, used between strokes, and a first visit that
showed seven closed headings would hide the brush radius behind a
click. What the user folds is remembered, per category, through
:func:`spacr.qt.preferences.set_section_layout`, and comes back
folded on the next visit.
:param title: the category's name, as written; the heading translates
and uppercases it.
:param subtitle: an optional sentence under the heading.
:returns: the category, with its layout as ``body_layout``.
"""
section = Section(title, expanded=True)
body = QWidget(section)
body.setObjectName("MakeMasksCategoryBody")
layout = QVBoxLayout(body)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(SPACING["sm"])
if subtitle:
note = QLabel(subtitle, body)
note.setObjectName("CardSubtitle")
note.setWordWrap(True)
layout.addWidget(note)
section.add_prose(body)
section.body_layout = layout
if title in self._folded_categories:
section.set_expanded(False)
section.toggled.connect(self._remember_folded_categories)
self._settings_categories.append((title, section))
return section
def _remember_folded_categories(self, *_args) -> None:
"""Store which settings categories are folded away, by title."""
folded = [title for title, section in self._settings_categories
if not section.is_expanded()]
self._folded_categories = set(folded)
try:
from ..preferences import set_section_layout
set_section_layout(_SETTINGS_LAYOUT_KEY, folded=folded)
except Exception: # noqa: BLE001
LOG.debug("could not store the folded categories", exc_info=True)
def _install_shortcuts(self):
"""Bind the keys that move through fields and undo edits.
ARROWS AND UNDO ARE THE WHOLE POINT of a curation screen: the work is
hundreds of small corrections, and a hand that has to find the mouse
between each one does a fraction as many.
"""
from ..shortcuts import _bind_screen_key
_bind_screen_key(self, "Make Masks", "Left", self._on_prev)
_bind_screen_key(self, "Make Masks", "Right", self._on_next)
_bind_screen_key(self, "Make Masks", "Ctrl+S", self._on_save)
_bind_screen_key(self, "Make Masks", "B", lambda: self._set_mode(MODE_BRUSH))
_bind_screen_key(self, "Make Masks", "E", lambda: self._set_mode(MODE_ERASE))
_bind_screen_key(self, "Make Masks", "W", lambda: self._set_mode(MODE_WAND_ADD))
_bind_screen_key(self, "Make Masks", "D", lambda: self._set_mode(MODE_DRAW))
_bind_screen_key(self, "Make Masks", "X", lambda: self._set_mode(MODE_BOX))
_bind_screen_key(self, "Make Masks", "V", lambda: self._set_mode(MODE_DIVIDE))
_bind_screen_key(self, "Make Masks", "Z", lambda: self._set_mode(MODE_ZOOM))
_bind_screen_key(self, "Make Masks", "R", lambda: self._set_mode(MODE_RECROP))
_bind_screen_key(self, "Make Masks", "M", self._toggle_magnifier_key)
_bind_screen_key(self, "Make Masks", "Esc", self._on_reset_zoom)
_bind_screen_key(self, "Make Masks", "Ctrl+Z", self._on_undo)
_bind_screen_key(self, "Make Masks", "Ctrl+Y", self._on_redo)
_bind_screen_key(self, "Make Masks", "Ctrl+Shift+Z", self._on_redo)
def _toggle_magnifier_key(self) -> bool:
"""Turn the live magnifier on or off from the keyboard: the M key.
THE SAME TOGGLE AS THE TOOL-ROW BUTTON, pressed rather than
imitated: ``toggle()`` on :attr:`_btn_magnifier` sends the button's
own ``toggled`` signal, so the status line, the box and the button's
checked look go through :meth:`_on_toggle_magnifier` exactly as a
click on it does, and the two can never disagree about whether the
magnifier is on. A button that is disabled or hidden is not pressed
by its key either.
WHY M. Every other letter this screen binds names its tool (B brush,
E erase, W wand, D draw, V divide, Z zoom, R recrop), and M is the
first free letter of "magnifier". It is bare, like theirs, so it
carries no Ctrl and therefore no Command on macOS, where Cmd+M
minimises the window. :data:`spacr.qt.shortcuts.SCREEN_SHORTCUTS`
lists it with this screen as its scope, under the card's own caption
"Live magnifier", which the generated catalog already translates.
:returns: whether the magnifier changed state.
"""
button = getattr(self, "_btn_magnifier", None)
if button is None or not button.isEnabled() \
or not button.isVisibleTo(self):
return False
button.toggle()
return True
def _set_mode(self, mode: str):
"""Select an editing, navigation or measurement tool.
:param mode: the mode's name.
"""
from ..i18n import tr
if mode == MODE_WAND_ERASE:
mode = MODE_WAND_ADD
if mode == MODE_BOX and self._canvas.image is None:
return
if mode == MODE_BOX and self._box_load_error is not None:
self._warn(tr("Cannot edit box annotations"), str(self._box_load_error))
return
if mode == MODE_BOX and self._recrop_children:
self._warn(tr("Finish recropping first"),
tr("Move to a cropped field before adding box annotations."))
return
if mode == MODE_RECROP and self._canvas.boxes:
self._warn(tr("Image has box annotations"),
tr("Remove this image's box annotations before recropping it."))
return
self._canvas.cancel_gesture()
self._canvas.mode = mode
self._box_controls.setVisible(mode == MODE_BOX)
self._btn_save.setText(tr("Save boxes") if mode == MODE_BOX else tr("Save mask"))
self._refresh_history_buttons()
self._canvas.ruler.set_active(mode == MODE_RULER)
if mode in (MODE_RULER, MODE_BOX, MODE_DIVIDE):
self._btn_magnifier.setChecked(False)
if mode in (MODE_BOX, MODE_DIVIDE):
self._btn_prompt.setChecked(False)
row = getattr(self, '_shortcut_rows', {}).get('Right button')
if row is not None:
text = (tr('Clear the ruler line') if mode == MODE_RULER else
tr('Delete the box under the cursor') if mode == MODE_BOX else
tr('Drag a line across objects to merge them') if mode == MODE_DIVIDE else
tr('Sweep away the objects it passes'))
row[1].setText(text)
save_row = getattr(self, '_shortcut_rows', {}).get('Ctrl+S')
if save_row is not None:
save_row[1].setText(tr('Save boxes') if mode == MODE_BOX else tr('Save the mask'))
for keys, normal, boxes in (
('Ctrl + left click', tr('Split the object at its waist'), tr('Draw an overlapping box')),
('Ctrl + right click', tr('Remove the object under the cursor'), tr('Delete the box under the cursor'))):
shortcut = getattr(self, '_shortcut_rows', {}).get(keys)
if shortcut is not None:
text = (tr('Remove the intensity region')
if keys == 'Ctrl + left click' and mode == MODE_WAND_ADD
else boxes if mode == MODE_BOX else normal)
shortcut[1].setText(text)
for m, btn in self._mode_buttons.items():
btn.setChecked(m == mode)
def _on_brush_size_changed(self, v: int):
"""Resize the brush.
:param v: the new radius in pixels.
"""
self._canvas.brush_radius = int(v)
self._brush_size_label.setText(f"{v} px")
def _close_levels(self):
"""Close the levels editor before its field or inversion changes."""
dialog = self._levels_dialog
if dialog is not None:
dialog.close()
dialog.deleteLater()
self._levels_dialog = None
def _on_levels(self):
"""Open an interactive histogram for the current normalization levels."""
image = self._canvas.displayed_source()
if image is None:
return
if self._levels_dialog is not None and not self._levels_dialog.closed:
self._levels_dialog.raise_()
self._levels_dialog.activateWindow()
return
self._close_levels()
dialog = _LevelsDialog(image, (self._norm_lo.value(), self._norm_hi.value()), self)
self._levels_dialog = dialog
dialog.detect.setChecked(self._detect_normalized.isChecked())
dialog.detect.toggled.connect(self._detect_normalized.setChecked)
dialog.levels_changed.connect(self._set_levels)
dialog.show()
def _set_levels(self, low, high):
"""Apply both histogram percentiles together, refreshing only once."""
for control, value in ((self._norm_lo, low), (self._norm_hi, high)):
blocked = control.blockSignals(True)
control.setValue(value)
control.blockSignals(blocked)
self._on_normalize_changed(0)
def _on_normalize_changed(self, _v: float):
"""Re-stretch the displayed intensity range.
The loaded pixels and existing masks are untouched. Detection also
uses the stretch when Detect on the normalized image is enabled.
:param _v: the changed value; both ends are re-read from the widgets.
"""
self._canvas.norm_lo = float(self._norm_lo.value())
self._canvas.norm_hi = float(self._norm_hi.value())
if self._levels_dialog is not None and not self._levels_dialog.closed:
self._levels_dialog.set_percentiles(self._canvas.norm_lo, self._canvas.norm_hi)
self._canvas.refresh()
if self._canvas.detect_on_normalized:
self._on_magnifier_context_changed()
def _on_detect_normalized(self, on: bool) -> None:
"""Hand the detectors the stretched field, or the loaded one.
:param on: whether detection reads the normalized image.
"""
self._canvas.detect_on_normalized = bool(on)
if self._levels_dialog is not None and not self._levels_dialog.closed:
blocked = self._levels_dialog.detect.blockSignals(True)
self._levels_dialog.detect.setChecked(bool(on))
self._levels_dialog.detect.blockSignals(blocked)
self._canvas.refresh()
self._on_magnifier_context_changed()
self._status_label.setText(
"Detection reads the image as drawn (normalized)." if on else
"Detection reads the image as loaded.")
def _on_invert_display(self, on: bool) -> None:
"""Draw the image as its own negative, or stop.
There is ONE of these. The screen used to carry two inversions that
a user could hold in disagreeing states: this one, which drew a
negative and left detection unaffected, and "Invert for detection",
which inverted what the detectors read and drew nothing. The reason
to invert is the reason they are merged -- inverting a field of dark
objects so that Otsu and the magnifier can find them is one
intention, and a curator who inverts the picture to see dark objects
means the detector to see them too.
So this switch now drives both, through
:func:`spacr.qt.mask_engine.invert_normalized`, and
``_cp_invert`` is this same widget under its old name rather than a
second one that can disagree with it.
WHAT STILL READS THE LOADED PIXELS, and the status line says it:
the Filter category and the mask that is saved. THE HOVER READOUT
NO LONGER DOES: it once reported the loaded value
while the picture showed the negative, and since the readout is the
instrument a curator checks an inversion WITH, that read as the
switch doing nothing. The pixel intensity now follows the picture
and says "(inverted)"; the object mean still follows the Filter,
and says "(as loaded)" so the two cannot be confused.
Detection no longer does, which is the point of the switch, and the
warning banner above the image says that for as long as it is on.
:param on: the switch's new state.
"""
self._close_levels()
self._canvas.invert_display = bool(on)
self._canvas.refresh()
if self._canvas.image is None:
return
if not on:
self._status_label.setText("Showing the image as it was loaded.")
else:
self._status_label.setText(
"Showing the image inverted — dark is bright — and "
"detecting on it. The hover intensity follows the picture "
"and says so; filtering, the object mean and saving still "
"use the original pixels."
)
def _on_wand_tolerance_changed(self, v: float):
"""Set how far the wand will grow in intensity.
:param v: the tolerance.
"""
self._canvas.wand_tolerance = float(v)
def _on_wand_pct_changed(self, v: float):
"""Set the wand's tolerance as a percentile instead of an absolute.
:param v: the percentile.
"""
self._canvas.wand_tol_pct = float(v)
def _on_wand_relative_changed(self, on: bool):
"""Switch the wand between a percentage and a fixed grey distance.
Only the box that is in force stays enabled, so the panel cannot
show two tolerances and leave which one the wand uses to be guessed
from a checkbox three rows up.
"""
self._canvas.wand_relative = bool(on)
self._wand_pct.setEnabled(bool(on))
self._wand_tol.setEnabled(not on)
def _on_wand_max_changed(self, v: int):
"""Cap how many pixels one wand fill may claim.
:param v: the pixel cap.
"""
self._canvas.wand_max_pixels = int(v)
def _on_wand_salvage_changed(self, on: bool):
"""Keep or discard a fill that hit the cap.
:param on: True to keep the truncated fill.
"""
self._canvas.wand_salvage_over_cap = bool(on)
def _on_wand_trim_runaway_changed(self, on: bool):
"""Turn runaway trimming on or off.
:param on: True to trim.
"""
self._canvas.wand_trim_runaway = bool(on)
def _on_wand_runaway_ratio_changed(self, v: float):
"""Set the growth ratio that counts as a runaway.
:param v: the ratio.
"""
self._canvas.wand_runaway_ratio = float(v)
def _on_wand_runaway_warmup_changed(self, v: int):
"""Set how many steps run before runaway detection starts.
A WARMUP IS NEEDED because every fill grows fast at first: judging the
ratio from step one would call every fill a runaway.
:param v: the step count.
"""
self._canvas.wand_runaway_warmup = int(v)
def _on_wand_runaway_min_base_changed(self, v: int):
"""Set the smallest area a runaway judgement will be made against.
:param v: the pixel count.
"""
self._canvas.wand_runaway_min_base = int(v)
def _on_wand_runaway_confirm_changed(self, v: int):
"""Set how many consecutive steps confirm a runaway.
:param v: the step count.
"""
self._canvas.wand_runaway_confirm = int(v)
def _on_wand_intensity_border_changed(self, on: bool):
"""Enable the re-flood, and its step count with it.
The step count is the precision of a search that is not running
when the re-flood is off, so leaving it live would offer a setting
that changes nothing.
"""
self._canvas.wand_intensity_border = bool(on)
self._wand_intensity_steps.setEnabled(bool(on))
def _on_wand_intensity_steps_changed(self, v: int):
"""Set how many intensity steps the wand grows through.
:param v: the step count.
"""
self._canvas.wand_intensity_steps = int(v)
def _on_wand_gradient_taper_changed(self, on: bool):
"""Enable the taper, and the three numbers that shape it."""
self._canvas.wand_gradient_taper = bool(on)
for w in (self._wand_gradient_sigma, self._wand_gradient_margin,
self._wand_gradient_erode):
w.setEnabled(bool(on))
def _on_wand_gradient_sigma_changed(self, v: float):
"""Set the blur applied before the gradient is measured.
:param v: the sigma.
"""
self._canvas.wand_gradient_sigma = float(v)
def _on_wand_gradient_margin_changed(self, v: int):
"""Set how far past the gradient edge the fill may reach.
:param v: the margin in pixels.
"""
self._canvas.wand_gradient_margin = int(v)
def _on_wand_gradient_erode_changed(self, v: int):
"""Set how much the gradient mask is eroded before use.
:param v: the erosion in pixels.
"""
self._canvas.wand_gradient_erode = int(v)
def _on_zoom_speed_changed(self, v: float):
"""Set how fast the wheel zooms.
:param v: the speed multiplier.
"""
self._canvas.zoom_speed = float(v)
def _on_reset_zoom(self):
"""Put the view back to the whole field."""
self._canvas.reset_zoom()
def _on_zoom_changed(self, zoomed: bool):
"""Enable the reset button only while the view is zoomed.
:param zoomed: True when the view is not showing the whole field.
"""
self._btn_reset_zoom.setEnabled(zoomed)
self._status_label.setText("Zoomed — press Esc to reset" if zoomed
else "Zoom reset")
def _on_undo(self):
"""Step the mask back one edit, and record that as an edit itself.
The ledger is append-only: taking a stroke back adds an ``undo``
entry rather than removing the entry for the stroke. That something
was painted and then reconsidered is part of what happened to the
data, and a history that can be quietly tidied is not evidence of
anything.
"""
if self._canvas.mode == MODE_BOX:
self._restore_box_history(self._box_history.undo())
return
prev = self._history.undo()
if prev is None or self._canvas.mask is None:
return
changed = self._diff(self._canvas.mask, prev)
self._canvas.mask = prev
self._canvas.manual_ids = self._manual_id_history.get(self._manual_mask_key(prev), False)
self._canvas.refresh()
self._record("undo", None, changed)
self._refresh_history_buttons()
def _on_redo(self):
"""Restore the most recently undone edit, recorded as a ``redo``."""
if self._canvas.mode == MODE_BOX:
self._restore_box_history(self._box_history.redo())
return
nxt = self._history.redo()
if nxt is None or self._canvas.mask is None:
return
changed = self._diff(self._canvas.mask, nxt)
self._canvas.mask = nxt
self._canvas.manual_ids = self._manual_id_history.get(self._manual_mask_key(nxt), False)
self._canvas.refresh()
self._record("redo", None, changed)
self._refresh_history_buttons()
def _refresh_history_buttons(self):
"""Enable undo and redo from what the history actually holds."""
history = self._box_history if self._canvas.mode == MODE_BOX else self._history
self._btn_undo.setEnabled(history.can_undo())
self._btn_redo.setEnabled(history.can_redo())
def _on_boxes_changed(self):
"""Snapshot one box edit without changing segmentation history."""
self._box_history.push(np.asarray(self._canvas.boxes, dtype=float).reshape(-1, 5))
self._boxes_dirty = True
self._refresh_history_buttons()
def _restore_box_history(self, snapshot):
"""Restore an annotation snapshot without creating another undo step."""
if snapshot is None:
return
self._canvas.boxes = [(int(row[0]), *map(float, row[1:])) for row in snapshot]
self._canvas.selected_box = None
self._boxes_dirty = True
self._canvas.update()
self._refresh_history_buttons()
def _on_box_selected(self, class_id):
"""Show a selected box's class without relabelling it."""
blocked = self._box_class_combo.blockSignals(True)
self._box_class_combo.setCurrentIndex(self._box_class_combo.findData(class_id))
self._box_class_combo.blockSignals(blocked)
self._canvas.box_class_id = class_id
def _on_box_class_changed(self, index):
"""Choose the next class, or relabel the selected annotation."""
class_id = self._box_class_combo.itemData(index)
if class_id is None:
return
self._canvas.box_class_id = int(class_id)
selected = self._canvas.selected_box
if selected is not None and 0 <= selected < len(self._canvas.boxes):
previous = self._canvas.boxes[selected]
if int(previous[0]) != class_id:
self._canvas.boxes[selected] = (class_id, *previous[1:])
self._canvas.boxes_changed.emit()
self._canvas.update()
def _on_add_box_class(self, name=None):
"""Append a stable class identifier and select it for future boxes.
:param name: optional class name, otherwise requested in a dialog.
:returns: the new class identifier, or None if cancelled or invalid.
"""
from ..i18n import tr
if self._box_field is None or self._canvas.image is None:
return None
if name is None:
name, accepted = QInputDialog.getText(self, tr("Add box class"), tr("Class name"))
if not accepted:
return None
name = str(name).strip()
try:
engine._yolo_classes(self._box_classes + [name])
except ValueError:
self._warn(tr("Invalid class"), tr("Enter a unique, nonempty class name."))
return None
self._canvas.selected_box = None
self._box_classes.append(name)
self._canvas.box_classes = list(self._box_classes)
class_id = len(self._box_classes) - 1
self._box_class_combo.addItem(name, class_id)
self._box_class_combo.setCurrentIndex(class_id)
self._boxes_dirty = True
return class_id
def _load_box_annotations(self, filename, image):
"""Bind annotations and class IDs to this field's original bytes."""
from ..i18n import tr
self._box_field = (self._folder, filename)
self._box_source_sha256 = None
self._boxes_dirty = False
self._box_load_error = None
try:
if engine.is_seg_bundle(filename):
self._box_load_error = ValueError(tr(
"Box annotations need a standalone image. Export the image from "
"this segmentation bundle first, then open it in Make Masks."))
record = {'classes': ['object'], 'boxes': [], 'source_sha256': None}
else:
record = engine.load_yolo_boxes(self._folder, filename, image.shape)
except Exception as exc:
self._box_load_error = exc
self._warn(tr("Cannot load box annotations"), str(exc))
record = {'classes': ['object'], 'boxes': [], 'source_sha256': None}
self._box_classes = list(record['classes'])
self._canvas.box_classes = list(self._box_classes)
self._canvas.boxes = list(record['boxes'])
self._canvas.selected_box = None
self._box_source_sha256 = record['source_sha256']
blocked = self._box_class_combo.blockSignals(True)
self._box_class_combo.clear()
for class_id, name in enumerate(self._box_classes):
self._box_class_combo.addItem(name, class_id)
self._box_class_combo.setCurrentIndex(0)
self._box_class_combo.blockSignals(blocked)
self._canvas.box_class_id = 0
self._canvas.boxes_editable = self._box_load_error is None
self._box_history.clear()
self._box_history.push(np.asarray(self._canvas.boxes, dtype=float).reshape(-1, 5))
self._mode_buttons[MODE_BOX].setEnabled(self._box_load_error is None)
self._canvas.update()
def _on_save_boxes(self):
"""Save editable annotations beside the field, retaining original pixels.
:returns: the project path, or None if no field is open or saving fails.
"""
from ..i18n import tr
if self._box_field is None or self._canvas.image is None:
return None
try:
if self._box_load_error is not None:
raise ValueError(str(self._box_load_error))
path = engine.save_yolo_boxes(
*self._box_field, self._canvas.image.shape, self._canvas.boxes,
self._box_classes, expected_source_sha256=self._box_source_sha256)
except Exception as exc:
self._warn(tr("Cannot save box annotations"), str(exc))
return None
self._boxes_dirty = False
self._status_label.setText(self._blind_text(
tr("Box annotations saved → {path}").format(path=path)))
return path
def _save_boxes_if_needed(self):
"""Persist changed boxes before leaving their image; refuse loss on error."""
return not self._boxes_dirty or self._on_save_boxes() is not None
def _on_export_yolo_boxes(self, path=None):
"""Write normalized YOLO labels and the stable class-name mapping.
:param path: optional label-file destination, otherwise chosen in a dialog.
:returns: the written label path, or None on cancellation or failure.
"""
from ..i18n import tr
if self._box_field is None or self._canvas.image is None:
return None
if self._blind is not None:
self._warn(tr("YOLO export is off while blinded"),
tr("Unblind this session before exporting labels named after source images."))
return None
if path is None:
default = os.path.splitext(os.path.join(*self._box_field))[0] + '.txt'
path, _filter = QFileDialog.getSaveFileName(
self, tr("Export YOLO labels"), default, tr("YOLO labels (*.txt)"))
if not path:
return None
if self._on_save_boxes() is None:
return None
try:
written = engine.export_yolo_boxes(
path, self._canvas.boxes, self._canvas.image.shape, self._box_classes)
except Exception as exc:
self._warn(tr("Cannot export YOLO labels"), str(exc))
return None
self._status_label.setText(tr("YOLO labels exported → {path}").format(path=written))
return written
def _record(self, kind: str, target=None, n_changed: int = 0, **detail):
"""Append one edit to this field's ledger, if it changed anything.
An edit that moved no pixels is not recorded, for the same reason
:mod:`spacr.napari_bridge` does not record one: a ledger padded with
entries for clicks that landed on background is a ledger nobody
reads, and ``is_curated`` would then answer True for every mask
anyone ever opened the editor on.
IT IS ALSO WHERE THE FILTER'S REMOVAL ROWS ARE DROPPED, because it
is the one place every edit passes through. Those rows promise to
name objects in the mask ON SCREEN (:meth:`_set_filter_log`), and
the promise is broken by the next edit whatever it was: Ctrl+Z puts
a removed object back under the id the row still lists, and a detect
in replace mode rebuilds the mask around it. Any recorded edit that
is not the filter's own therefore empties the box, and the user
presses Filter again to ask the question again.
Manual identity policy is recorded against exact label values and
shape so saving and reopening can retain deliberate disconnected
groupings. Undo and redo restore the policy with the mask snapshot.
:param kind: what happened, as the curation ledger names it.
"""
self._refresh_secondary_report()
if int(n_changed) <= 0:
return None
if kind != "filter":
self._set_filter_log([])
if kind in ("undo", "redo"):
self._filter_baseline = None
self._filter_shown = None
mask = self._canvas.mask
if mask is None:
return None
key = self._manual_mask_key(mask)
self._manual_id_history[key] = self._canvas.manual_ids
if any(self._manual_id_history.values()):
detail.update(manual_ids=bool(self._canvas.manual_ids), manual_mask_sha256=key)
if self._log is None:
return None
if getattr(self._canvas, 'preserve_ids', False):
detail.update(self._secondary_detail())
return self._log.append(kind, target, n_changed=int(n_changed),
**detail)
@staticmethod
def _manual_mask_key(mask):
"""Bind a manual identity policy to exact label values and shape.
:param mask: the current label array; integer width is normalized
for exact save/reopen comparisons without renumbering labels.
:returns: SHA-256 over the shape and signed 64-bit label values.
"""
import hashlib
values = np.asarray(mask, dtype='<i8')
return hashlib.sha256(str(values.shape).encode() + values.tobytes()).hexdigest()
def _require_primary_source(self):
"""Require a validated primary snapshot belonging to this queue field."""
from ..i18n import tr
source = self._primary_selector.snapshot
if source is None:
if self._mag_mode.currentData() == cpu_modes.PUNCTA:
raise ValueError(tr('Load a valid parent mask before detecting objects.'))
raise ValueError(tr('Load a valid primary mask before growing secondary objects.'))
filename = self._image_files[self._current_index]
image_path = os.path.realpath(os.path.join(self._folder, filename))
if source.image_path != image_path or source.labels.shape != self._canvas.mask.shape:
raise ValueError(tr('The primary mask belongs to a different field. Reload it for this image.'))
source.validate_destination(engine.mask_save_path(
self._folder, filename, **self._layout_kwargs()))
return source
def _on_primary_source_changed(self):
"""Invalidate asynchronous detections as soon as their primary changes."""
self._refresh_secondary_report()
if hasattr(self, '_magnifier'):
self._magnifier.refresh()
def _require_secondary_merge(self, source):
"""Refuse numeric ID collisions with an unrelated nonempty target mask.
:param source: the validated primary-mask snapshot for this detection.
:raises ValueError: when the existing output belongs to another source
or was not created with primary IDs preserved.
"""
from ..i18n import tr
if self._canvas.mask is None or not self._canvas.mask.any():
return
record = getattr(self, '_paired_source', None) or {}
expected = source.provenance()
if not getattr(self._canvas, 'preserve_ids', False) or expected != {
key: record.get(key) for key in expected}:
raise ValueError(tr('The existing mask is not paired with this primary source. Use whole-image Replace or Clear all objects before accepting secondary objects.'))
def _refresh_secondary_report(self):
"""Show explicit missing, orphaned and incompletely enclosing IDs."""
from ..i18n import tr
label = getattr(self, '_secondary_relations', None)
if label is None:
return
source = self._primary_selector.snapshot
mask = self._canvas.mask
if source is None or mask is None or source.labels.shape != mask.shape:
label.setText(tr('Load a primary mask to inspect object relationships.'))
return
report = engine.primary_secondary_report(source.labels, mask)
names = (('matched_ids', tr('Matched')), ('missing_secondary_ids', tr('Missing secondary')),
('orphan_secondary_ids', tr('No primary')), ('incomplete_primary_ids', tr('Primary not enclosed')),
('unexpanded_primary_ids', tr('Not expanded')))
label.setText('\n'.join(tr('{name}: {count} ({ids})', name=name,
count=len(getattr(report, key)),
ids=', '.join(map(str, getattr(report, key)[:12])) +
('…' if len(getattr(report, key)) > 12 else ''))
for key, name in names))
def _secondary_detail(self):
"""Source association and exact identity diagnostics for the current edit."""
record = getattr(self, '_paired_source', None)
if not getattr(self._canvas, 'preserve_ids', False) or record is None:
return {}
detail = {'preserve_ids': True, 'primary_source': record}
source = self._primary_selector.snapshot
if source is not None and source.provenance() == {key: record.get(key) for key in source.provenance()}:
report = engine.primary_secondary_report(source.labels, self._canvas.mask)
detail['primary_secondary'] = {key: list(value) for key, value in report._asdict().items()}
return detail
def _retain_secondary_ids(self, record):
"""Associate an accepted detection with its immutable primary identity."""
self._paired_source = dict(record)
self._canvas.preserve_ids = True
self._canvas._lookup = None
self._canvas._lookup_dirty = True
self._refresh_secondary_report()
if self._log is not None:
previous = next((edit.detail.get('primary_source') for edit in reversed(self._log.edits)
if edit.detail.get('preserve_ids')), None)
if previous != self._paired_source:
self._log.append('secondary_source', None, **self._secondary_detail())
def _validate_secondary_save(self):
"""Keep every recorded primary file separate from the editable output."""
from ..secondary_masks import _same_file
from ..i18n import tr
destination = engine.mask_save_path(self._folder, self._image_files[self._current_index],
**self._layout_kwargs())
records = [getattr(self, '_paired_source', None)]
if self._log is not None:
records.extend(edit.detail.get('primary_source') for edit in self._log.edits)
records.extend(edit.detail.get('parent_source') for edit in self._log.edits)
source = self._primary_selector.snapshot
if source is not None:
source.validate_destination(destination)
for record in records:
if record and record.get('path') and _same_file(record['path'], destination):
raise ValueError(tr('Primary and secondary masks must be saved to different files.'))
@staticmethod
def _diff(before, after) -> int:
"""How many pixels two masks disagree on.
A pair with no common shape is counted as everything ``after`` has
labelled, which is the honest answer when there is nothing to
compare against rather than a silent zero.
"""
if after is None:
return 0
if before is None or before.shape != after.shape:
return int(np.count_nonzero(after))
return int(np.count_nonzero(before != after))
def _pixels_changed(self, after) -> int:
"""How many pixels ``after`` differs from the last history snapshot.
The snapshot is the state the edit in progress started from, so this
is the size of that one edit — and it is what tells a stroke that
repainted a third of the field from one that was a stray click.
"""
return self._diff(self._history.head(), after)
def _filter_rules(self) -> list:
"""The filter list as the rows say, serialised for the engine."""
return self._filter_list.filters()
@staticmethod
def _filter_number(name: str, value: float) -> str:
"""A bound or a measurement, as the ledger prints it."""
value = float(value)
if name.startswith("intensity_"):
return f"{value:.2f}"
if value.is_integer():
return f"{int(value)}"
return f"{value:.4g}"
def _filter_removal_line(self, removal) -> str:
"""One hidden object as the Filter category's ledger prints it.
The line reads like "Object 22 with area 31 px and intensity 12.50
was removed by minimum area 40", with each bound the object fell
outside and the bound's own number: a row saying only which filter
removed an object leaves the reader looking for the row it came
from. A property other than area and mean intensity also gives the
object's own value, since that is what the bound judged.
The id is :func:`mask_engine.canonical_labels`' id, which is the id
the hover readout showed for the same object.
:param removal: a :class:`mask_engine.ObjectRemoval`.
"""
from ..i18n import tr
names = {"area": tr("area"), "intensity_mean": tr("intensity")}
reasons = []
for failed in removal.failed:
side = tr("minimum") if failed.side == "min" else tr("maximum")
label = names.get(failed.property, failed.property)
reason = f"{side} {label} {self._filter_number(failed.property, failed.bound)}"
if failed.property not in names:
reason += " " + tr("(was {value})", value=self._filter_number(
failed.property, failed.value))
reasons.append(reason)
area = int(round(removal.values.get("area", 0)))
mean = removal.values.get("intensity_mean")
head = tr("Object {label} with area {area} px", label=removal.label,
area=area)
if mean is not None:
head += " " + tr("and intensity {mean}", mean=f"{mean:.2f}")
return (head + " " + tr("was removed by") + " "
+ (" " + tr("and") + " ").join(reasons))
def _set_filter_log(self, lines) -> None:
"""Show ``lines`` in the Filter category's removal ledger.
Empty puts the box back to its placeholder rather than to a blank
red field: the rows name objects in the mask ON SCREEN, so carrying
the last field's rows into the next field would name objects that
are not there.
"""
from ..i18n import tr
self._filter_log.setPlainText("\n".join(str(line) for line in lines))
self._filter_log.setPlaceholderText(tr(
"Nothing is hidden. Add a filter above and set a bound."))
def _refresh_filter_properties(self) -> None:
"""Offer the intensity properties only while an image is open."""
self._filter_list.set_intensity_available(
self._canvas.image is not None)
def _on_filter_row_added(self, _widget) -> None:
"""Give a new filter row the linked help every other control has."""
if getattr(self, "_api_tooltip_filter", None) is None:
return
from ..widgets.make_masks_help import install_make_masks_help
install_make_masks_help(self)
def _on_filters_changed(self) -> None:
"""Apply the edited filter list live to the field on screen."""
if self._canvas.mask is None or self._canvas.image is None:
return
self.apply_object_filter(on_load=False)
def _filter_base(self):
"""The mask the filter list is applied to, with later edits folded in.
The list HIDES objects rather than deleting them for good: it is
applied to the mask as it stood before any filter ran, so removing a
row, or loosening a bound, brings back what that row hid. Edits made
since the last run (a stroke, a detect, a merge) are carried into
that baseline pixel for pixel, so re-applying the list does not
undo them. Undo, redo and opening a field start a new baseline from
the mask on screen.
"""
mask = self._canvas.mask
base = getattr(self, "_filter_baseline", None)
shown = getattr(self, "_filter_shown", None)
if (base is None or shown is None or base.shape != mask.shape
or shown.shape != mask.shape):
base = np.array(mask, copy=True)
else:
edited = shown != mask
if edited.any():
base = base.copy()
base[edited] = mask[edited]
self._filter_baseline = base
return base
[docs]
def apply_object_filter(self, *, on_load: bool = False) -> int:
"""Apply the filter list to the field on screen; return how many it hides.
Runs itself when a field loads -- a draft segmentation usually
arrives with the same class of junk in every field -- whenever the
list is edited, and when the user presses Filter.
One engine: :func:`mask_engine.apply_filters`, the same one Mask
generation's ``object_filters`` setting runs, with one
``regionprops_table`` pass for every property the list names. The
result is one undo step and one ledger entry that records the whole
list, so the mask's provenance says which filters shaped it.
Every hidden object is written into the Filter category's ledger,
one red row each, cleared at the start of every run.
:param on_load: True when this is the automatic run on opening a
field. It starts a fresh baseline and keeps a run that hid
nothing quiet.
"""
from ..i18n import tr
self._set_filter_log([])
self._refresh_filter_properties()
if on_load:
self._filter_baseline = None
self._filter_shown = None
if self._canvas.mask is None or self._canvas.image is None:
return 0
try:
rules = self._filter_rules()
except ValueError as error:
self._status_label.setText(str(error))
return 0
if not rules and getattr(self, "_filter_baseline", None) is None:
if not on_load:
self._status_label.setText(
tr("Object filters: nothing outside the bounds."))
return 0
base = self._filter_base()
try:
out, removals = engine.apply_filters(
base, self._canvas.image, rules,
preserve_ids=self._canvas.retain_label_ids())
except ValueError as error:
self._status_label.setText(str(error))
return 0
if out is base:
out = np.array(base, copy=True)
changed = int(np.count_nonzero(self._canvas.mask != out))
self._set_filter_log(
self._filter_removal_line(removal) for removal in removals)
if changed:
dropped = [removal.label for removal in removals]
self._canvas.mask = out
self._canvas.refresh()
self._record("filter", dropped, changed, n_objects=len(dropped),
automatic=bool(on_load), filters=rules)
self._history.push(out)
self._refresh_history_buttons()
self._filter_shown = np.array(self._canvas.mask, copy=True)
if not removals:
if not on_load:
self._status_label.setText(
tr("Object filters: nothing outside the bounds."))
return 0
self._status_label.setText(tr(
"Object filter removed {count} object(s) — Ctrl+Z to undo",
count=len(removals)))
return len(removals)
def _on_apply_filter(self):
"""Apply the object filter list to the mask on screen."""
self.apply_object_filter(on_load=False)
def _cpu_detect(self, image, method: str, otsu: dict) -> tuple:
"""Run the CPU method ``method`` on ``image``.
The one place the detect button's method is dispatched, so the
button and the magnifier's box cannot end up running different
things under one name. A model mode (Cellpose, a backend) falls
back to Otsu, because those have the Object detection button.
Otsu's saved local toggle and class/band choices cannot override
another named threshold. Multi-Otsu alone reads the class and band;
every other named threshold forces a two-class, non-Local-Otsu run.
:returns: ``(labels, centres)``; ``centres`` is the number of
maxima for the propagation and None for everything else.
"""
if method == cpu_modes.PUNCTA:
import hashlib
from ..i18n import tr
source = self._require_primary_source()
with open(source.path, 'rb') as stream:
if hashlib.file_digest(stream, 'sha256').hexdigest() != source.sha256:
raise ValueError(tr('The parent mask changed. Reload it before detecting puncta.'))
params = self._cpu_params()
image_sha256 = self._puncta_native_cache[2]
labels, candidates = cpu_modes.puncta(image, source.labels, params,
measurements=True)
with open(source.image_path, 'rb') as stream:
if hashlib.file_digest(stream, 'sha256').hexdigest() != image_sha256:
raise ValueError(tr('The source image changed during puncta detection. Reload it and repeat detection.'))
with open(source.path, 'rb') as stream:
if hashlib.file_digest(stream, 'sha256').hexdigest() != source.sha256:
raise ValueError(tr('The parent mask changed. Reload it before detecting puncta.'))
self._puncta_measurement_snapshot = dict(
image_path=source.image_path, image_sha256=image_sha256,
parent=source.provenance(), parameters=cpu_modes.provenance(method, params),
labels=labels.copy(), candidates=candidates.copy())
return labels, None
if method in organelle_modes.MODE_LABELS:
return (organelle_modes.segment(
image, method, self._method_params(),
min_area=self._detect_min_area()), None)
if method == cpu_modes.PROPAGATE:
found = cpu_modes.propagate(
image, self._cpu_params(), min_area=self._detect_min_area(),
fill_holes=bool(otsu["fill_holes"]))
return (found.labels, found.seeds)
if method == cpu_modes.SECONDARY:
found = cpu_modes.secondary(image, self._require_primary_source().labels,
self._cpu_params(), min_area=self._detect_min_area(),
fill_holes=bool(otsu['fill_holes']))
return found.labels, None
algorithm = cpu_modes.engine_algorithm(
method if method in cpu_modes.THRESHOLD_LABELS else "otsu")
settings = dict(otsu)
if method == cpu_modes.MULTIOTSU:
settings["classes"] = max(3, int(settings["classes"]))
settings["local"] = False
elif method in cpu_modes.THRESHOLD_LABELS:
settings.update(classes=2, foreground_class=1, local=False)
return (engine._otsu_instances(
image, bright=self._otsu_bright.isChecked(),
min_area=int(self._min_area.value()), algorithm=algorithm,
local_k=float(self._otsu_local_k.value()), **settings), None)
def _on_detect_otsu(self):
"""Detect on the whole image and fold the result in per replace/merge.
IT RUNS WHATEVER THE DETECTION METHOD CATEGORY IS SET TO, not Otsu
alone: after item 473 that category holds ten threshold
algorithms, the propagation and the organelle methods, and a
button that went on running Otsu while the box under the mouse ran
Li would be two answers to one question. Only a model mode falls
back, and only because it has a button of its own.
With Invert on it detects on the INVERTED field
(:meth:`_detector_image`), which is what lets it take dark objects:
Bright and Invert together are the dark side of the dark side, and
the ledger records which way up the image was.
"""
from ..i18n import tr
if self._canvas.image is None or self._canvas.mask is None:
return
mode = self._combine_mode.currentData()
otsu = self._otsu_settings()
correction = otsu["correction"]
method = canonical_magnifier_mode(
getattr(self._magnifier, "mode", None))
if method == cpu_modes.SECONDARY:
otsu['fill_holes'] = self._secondary_fill_holes.isChecked()
try:
detected, seeds = self._cpu_detect(self._detector_image(),
method, otsu)
except Exception as exc:
self._warn("Detect failed", str(exc))
return
if method not in (cpu_modes.PROPAGATE, cpu_modes.SECONDARY, cpu_modes.PUNCTA):
detected = detect_chain.finish(detected, self._detect_chain(),
intensity=self._detector_image())
found = _object_count(detected)
centres = ("" if seeds is None
else tr(" from {n} centre(s)", n=seeds))
if not found and method not in (cpu_modes.SECONDARY, cpu_modes.PUNCTA):
self._status_label.setText(tr(
"{method}{centres} found no objects — the mask is "
"unchanged. Lower the minimum area, or try the other side.",
method=_magnifier_mode_label(method), centres=centres))
return
try:
if method == cpu_modes.PUNCTA and mode == 'replace':
out = engine.canonical_labels(detected, preserve_ids=True)
elif method == cpu_modes.SECONDARY:
source = self._require_primary_source()
if mode != 'replace':
self._require_secondary_merge(source)
out = (engine.canonical_labels(detected, preserve_ids=True) if mode == 'replace'
else engine._paste_region_objects(self._canvas.mask, detected, (0, 0),
overlap='clip', preserve_ids=True)[0])
else:
out = engine.combine_masks(self._canvas.mask, detected, mode)
except Exception as exc:
self._warn("Detect failed", str(exc))
return
changed = self._pixels_changed(out)
self._canvas.mask = out
if method == cpu_modes.PUNCTA and mode == 'replace':
self._canvas.preserve_ids = True
self._paired_source = None
self._canvas.refresh()
if method == cpu_modes.SECONDARY:
self._retain_secondary_ids(dict(source.provenance(), selection=self._primary_selector.path.text()))
puncta_source = ({'parent_source': self._require_primary_source().provenance(),
'detect_on_normalized': False, 'normalization_percentiles': None}
if method == cpu_modes.PUNCTA else {})
self._record("detect", mode, changed, method=method,
**puncta_source,
n_objects=found,
invert=bool(self._cp_invert.isChecked()) and method != cpu_modes.PUNCTA,
bright=bool(self._otsu_bright.isChecked()),
min_area=int(self._min_area.value()),
otsu_correction=correction,
otsu_smoothing=otsu["smoothing"],
otsu_fill_holes=otsu["fill_holes"],
otsu_split=otsu["split_touching"],
otsu_exclude_border=otsu["exclude_border"],
otsu_classes=otsu["classes"],
otsu_foreground_class=otsu["foreground_class"],
otsu_local=otsu["local"],
otsu_window=otsu["window"],
method_parameters=(
organelle_modes.provenance(
method, self._method_params())
or cpu_modes.provenance(method, self._cpu_params())),
**self._chain_provenance())
self._history.push(out)
self._refresh_history_buttons()
inverted = (tr(" of the INVERTED image")
if self._cp_invert.isChecked() else "")
described = (self._otsu_description()
if method in ("otsu",) + cpu_modes.threshold_modes()
else _magnifier_mode_label(method))
self._status_label.setText(tr(
"{method} ({how}){inverted}{centres} found {n} object(s) — "
"{combine}d into the mask",
method=_magnifier_mode_label(method), how=described,
inverted=inverted, centres=centres, n=found, combine=mode))
def _otsu_description(self) -> str:
"""How the threshold was taken, for a status line and the preview.
Local and multi-class Otsu are two ways of cutting that are not
"bright" or "dark",
and a line that went on saying one of those two would be describing
a run that had not happened.
"""
settings = self._otsu_settings()
mode = canonical_magnifier_mode(self._magnifier.mode)
if settings["local"] or mode in ("sauvola", "niblack"):
side = "bright" if self._otsu_bright.isChecked() else "dark"
return f"local {settings['window']} px, {side}"
classes = settings["classes"]
if classes > 2:
return (f"{classes} classes, class "
f"{settings['foreground_class']}")
return "bright" if self._otsu_bright.isChecked() else "dark"
def _on_show_otsu_histogram(self) -> None:
"""Snapshot a threshold preview and compute it off the GUI thread.
The historical method name is retained for callers. All named CPU
thresholds use their own levels on the full detector input, including
inversion, normalization, enhancement and smoothing. Local methods
show counts without a misleading global marker. Reopening replaces
the previous snapshot; only the newest result may update its dialog.
"""
from ..i18n import tr
mode = canonical_magnifier_mode(self._magnifier.mode)
if mode != "otsu" and mode not in cpu_modes.THRESHOLD_LABELS:
return
image = self._canvas.image
if image is None:
self._status_label.setText(
tr("Open a field first — a histogram needs an image."))
return
previous = self._otsu_histogram_dialog
if previous is not None:
previous.close()
previous.deleteLater()
dialog = _OtsuHistogramDialog(
np.zeros(2), np.arange(3), [], self._otsu_description(),
parent=self, method=mode, pending=True)
request = _ThresholdHistogramRequest(
np.array(image, copy=True), mode, self._otsu_settings(),
bool(self._otsu_bright.isChecked()), bool(self._canvas.invert_display),
(float(self._canvas.norm_lo), float(self._canvas.norm_hi))
if self._canvas.detect_on_normalized else None,
self._detect_chain(), _RunTicket())
dialog.request = request
self._otsu_histogram_dialog = dialog
dialog.show()
if self._histogram_worker is None:
self._histogram_worker = _NewestRequestWorker(
_threshold_histogram, self._histogram_done, name="spacr-threshold-histogram")
self._histogram_worker.submit(request)
def _histogram_done(self, request, result, error) -> None:
"""Deliver a worker result to Qt; the screen may already be destroyed."""
try:
self._histogram_delivered.emit((request, result, error))
except RuntimeError:
pass
def _take_histogram(self, payload) -> None:
"""Accept only the currently open histogram's snapshot on the GUI thread."""
request, result, error = payload
dialog = self._otsu_histogram_dialog
if dialog is None or dialog.closed or dialog.request is not request:
return
dialog.show_result(result, error)
def _build_view_tabs(self) -> QTabWidget:
"""The canvas and Cellpose's two intermediates, as tabs.
THE PANES ARE NOT DECORATION. The probability map and the flow
field are what say *why* a mask came out the way it did: seeing
which pixels the network was confident about, beside the objects
it drew from them, is the difference between moving a threshold
with a reason and moving it by guessing. They sit on tabs of the
same pane as the mask so they are at the same size and the same
zoom-to-fit as the image being judged.
Both tabs stay ENABLED before Cellpose has run, unlike the
standalone tool's, because a disabled tab cannot be opened to
read the one sentence that explains why it is empty.
"""
tabs = QTabWidget()
tabs.setObjectName("MakeMasksViewTabs")
tabs.addTab(self._canvas, "Mask")
self._prob_pane = _FlowPane()
self._flow_pane = _FlowPane()
self._tab_prob = tabs.addTab(self._prob_pane, "Cell probability")
self._tab_flow = tabs.addTab(self._flow_pane, "Flows")
tabs.tabBar().setTabButton(self._tab_flow, QTabBar.RightSide,
self._build_queue_tally())
self._view_tabs = tabs
return tabs
def _build_queue_tally(self) -> QWidget:
"""The editable ``index / total`` beside the Flows tab (item 685).
Typing a 1-based position and pressing Enter opens that image. A
number outside the queue, or text that is not a number, puts the
current position back and leaves the image on screen as it was.
"""
from ..i18n import tr
tally = QWidget()
tally.setObjectName("MakeMasksQueueTally")
row = QHBoxLayout(tally)
row.setContentsMargins(SPACING["sm"], 0, 0, 0)
row.setSpacing(2)
self._tally_index = QLineEdit("0")
self._tally_index.setObjectName("MakeMasksQueueIndex")
self._tally_index.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
self._tally_index.setMaximumWidth(56)
self._tally_index.setToolTip(tr(
"Position of this image in the queue. Type a number and press "
"Enter to open that image."))
self._tally_index.returnPressed.connect(self._on_tally_entered)
self._tally_total = QLabel("/0")
self._tally_total.setObjectName("MakeMasksQueueTotal")
row.addWidget(self._tally_index)
row.addWidget(self._tally_total)
return tally
def _build_vvvv_export_buttons(self) -> tuple:
"""The alpha vvvv export: Export for vvvv and Export on save (685).
Both are registered in ``spacr.settings.ALPHA_FEATURES`` and hidden
unless Show alpha features is on. The export folder and the
on-save choice are remembered between sessions.
"""
from ..i18n import tr
from ..prefs import _s
self._btn_vvvv_export = QPushButton(tr("Export for vvvv…"))
self._btn_vvvv_export.setObjectName("MakeMasksVvvvExport")
self._btn_vvvv_export.setCursor(Qt.PointingHandCursor)
self._btn_vvvv_export.setToolTip(tr(
"Write this image's labels (16-bit PNG), outlines (RGBA PNG), "
"one row per object (CSV) and a manifest into a folder named "
"after the image, for a vvvv patch to read. Files are replaced "
"whole, so a watching patch never reads half a file."))
self._btn_vvvv_export.clicked.connect(lambda: self._on_export_vvvv())
self._btn_vvvv_on_save = QPushButton(tr("Export on save"))
self._btn_vvvv_on_save.setObjectName("MakeMasksVvvvExportOnSave")
self._btn_vvvv_on_save.setCheckable(True)
self._btn_vvvv_on_save.setCursor(Qt.PointingHandCursor)
self._btn_vvvv_on_save.setToolTip(tr(
"Export for vvvv again every time a mask is saved, so a patch "
"watching the export folder updates live."))
self._btn_vvvv_on_save.setChecked(bool(
_s().value("make_masks/vvvv_export_on_save", False, type=bool)
and self._vvvv_export_root()))
self._btn_vvvv_on_save.toggled.connect(self._on_vvvv_on_save_toggled)
return self._btn_vvvv_export, self._btn_vvvv_on_save
@staticmethod
def _vvvv_export_root() -> str:
"""The remembered vvvv export folder, or ""."""
from ..prefs import _s
return str(_s().value("make_masks/vvvv_export_dir", "", type=str) or "")
def _ask_vvvv_export_root(self) -> str:
"""Ask for the vvvv export folder and remember it; "" when cancelled."""
from ..i18n import tr
from ..prefs import _s
chosen = QFileDialog.getExistingDirectory(
self, tr("Choose the vvvv export folder"),
self._vvvv_export_root() or (self._folder or ""))
if chosen:
_s().setValue("make_masks/vvvv_export_dir", chosen)
return chosen or ""
def _on_vvvv_on_save_toggled(self, enabled: bool) -> None:
"""Remember Export on save; choose a folder first when there is none."""
from ..prefs import _s
if enabled and not self._vvvv_export_root() \
and not self._ask_vvvv_export_root():
blocked = self._btn_vvvv_on_save.blockSignals(True)
self._btn_vvvv_on_save.setChecked(False)
self._btn_vvvv_on_save.blockSignals(blocked)
enabled = False
_s().setValue("make_masks/vvvv_export_on_save", bool(enabled))
def _on_export_vvvv(self, root: Optional[str] = None, *, labels=None,
quiet: bool = False) -> Optional[str]:
"""Export the field on screen for vvvv.
:param root: the export folder; asked for when None.
:param labels: the labels to write; the canvas mask when None.
:param quiet: leave the status line alone, as a save just set it.
:returns: the field's export folder, or None when nothing was written.
"""
from ..i18n import tr
if not self._image_files or self._canvas.mask is None:
return None
if self._blind is not None:
self._warn(tr("vvvv export is off while blinded"),
tr("Unblind this session before exporting folders "
"named after source images."))
return None
if root is None:
root = self._ask_vvvv_export_root()
if not root:
return None
filename = self._image_files[self._current_index]
source = os.path.join(self._folder or "", filename)
ruler = getattr(self._canvas, "ruler", None)
spacing = getattr(ruler, "spacing", None)
try:
written = engine._export_vvvv(
root, filename,
self._canvas.mask if labels is None else labels,
self._canvas.image, source_path=source, pixel_size=spacing,
unit=getattr(ruler, "unit", "µm"))
except Exception as exc: # noqa: BLE001
self._warn(tr("vvvv export failed"), str(exc))
return None
if not quiet:
self._status_label.setText(tr("Exported for vvvv → {path}",
path=written))
return written
def _export_vvvv_on_save(self) -> Optional[str]:
"""Export the labels just saved for vvvv, when Export on save is on.
Blinded sessions and a closed alpha gate export nothing.
"""
from ..preferences import _is_alpha_visible
button = getattr(self, "_btn_vvvv_on_save", None)
root = self._vvvv_export_root()
if (button is None or not button.isChecked() or not root
or self._blind is not None or self._canvas.mask is None
or not _is_alpha_visible()):
return None
labels = engine.canonical_labels(
self._canvas.mask, preserve_ids=self._canvas.retain_label_ids())
return self._on_export_vvvv(root, labels=labels, quiet=True)
def _sync_queue_tally(self) -> None:
"""Show the current 1-based position and the queue's length."""
index_edit = getattr(self, "_tally_index", None)
if index_edit is None:
return
total = len(self._image_files or [])
current = (min(self._current_index, total - 1) + 1) if total else 0
index_edit.setText(str(current))
index_edit.setEnabled(total > 0)
self._tally_total.setText(f"/{total}")
first = getattr(self, "_btn_first_unreviewed", None)
if first is not None:
first.setEnabled(total > 0 and not getattr(self, "_loading", False))
def _on_tally_entered(self) -> bool:
"""Open the image whose 1-based position was typed in the tally.
:returns: whether another image was opened.
"""
from ..i18n import tr
total = len(self._image_files or [])
text = self._tally_index.text().strip()
try:
wanted = int(text)
except ValueError:
wanted = 0
if not 1 <= wanted <= total:
self._sync_queue_tally()
self._status_label.setText(tr(
"Type a number from 1 to {total} to open that image.",
total=total) if total else tr("There are no images in the queue."))
return False
return self._go_to_index(wanted - 1)
def _go_to_index(self, index: int) -> bool:
"""Open the field at zero-based ``index``, leaving this one properly.
The same leaving rules as Next and Previous: pending boxes are
saved, Save on navigation runs first (N680), and a failed save keeps
the field on screen. Visiting records no Keep or Discard. A field
cut up with Recrop is retired first, and its crops open instead.
:returns: whether another field was opened.
"""
if not self._image_files or not 0 <= index < len(self._image_files):
self._sync_queue_tally()
return False
if not self._save_mask_if_needed() or not self._save_boxes_if_needed():
self._sync_queue_tally()
return False
if index == self._current_index:
self._sync_queue_tally()
return False
if self.finish_recrop():
self._sync_queue_tally()
return False
self._current_index = index
self._load_current()
self._sync_queue_tally()
return True
def _on_first_unreviewed(self) -> bool:
"""Open the first field in queue order with no Keep or Discard.
:returns: whether another field was opened.
"""
from ..i18n import tr
if not self._image_files:
self._status_label.setText(tr("There are no images in the queue."))
return False
try:
index = engine._first_unreviewed(
(folder, os.path.join(folder or "", name))
for folder, name in self._field_pairs())
except OSError as exc:
self._warn(tr("Cannot read review status"), str(exc))
return False
if index is None:
self._status_label.setText(tr(
"Every image in this queue has Keep or Discard."))
return False
if index == self._current_index:
self._status_label.setText(tr(
"This is the first image without Keep or Discard."))
return False
return self._go_to_index(index)
def _build_view_pane(self) -> QWidget:
"""The views, with the shortcut list down their right side.
The shortcuts sit to the right of the Mask, Cell probability and
Flows views -- the three view tabs -- so the pane the
splitter holds is the tabs and the list side by side, and the list
travels with the views when the settings are hidden and the image
takes their width.
THE LIST IS NOT SETTINGS, so the Settings toggle does not take it
away: a shortcut list that disappears the moment the screen is
cleared for work is a list you can only read when you do not need
it. It hides independently as an EDGE pane of its own splitter. Its handle
folds it to the right edge and drags it wider, and the image takes
the room it leaves.
THE CONSOLE IS UNDER THE LIST (item 507), in the same right-hand
column, where it was asked for: "to the right of the image and
below the hot key map". The column is a vertical splitter of the
list and the console's :class:`FoldSection`, so the console folds to
its heading at the bottom of the column and drags taller, and the
whole column still folds away as the one "Shortcuts" pane.
"""
from ..widgets.collapsible_splitter import CollapsibleSplitter, EDGE
pane = CollapsibleSplitter(Qt.Horizontal,
persist_key="make_masks::views")
pane.setObjectName("MakeMasksViewPane")
self._shortcut_card = self._build_shortcut_panel()
column = CollapsibleSplitter(Qt.Vertical,
persist_key="make_masks::side")
column.setObjectName("MakeMasksSideColumn")
column.add_pane(self._shortcut_card, "Shortcut list", stretch=1)
self._console_section = column.add_section(
self._masks_console, "Console", persist_key="make_masks/Console",
stretch=1, extent=240, minimum=120)
self._shortcut_panel = column
views = QWidget()
views.setObjectName("MakeMasksViewsColumn")
views_col = QVBoxLayout(views)
views_col.setContentsMargins(0, 0, 0, 0)
views_col.setSpacing(SPACING["sm"])
views_col.addWidget(self._view_tabs, 1)
self._image_nav_row = QHBoxLayout()
self._image_nav_row.setContentsMargins(0, 0, 0, 0)
self._image_nav_row.setSpacing(SPACING["sm"])
views_col.addLayout(self._image_nav_row)
pane.add_pane(views, "Views", stretch=1, extent=900)
pane.add_pane(column, "Shortcuts", mode=EDGE, stretch=0,
extent=SHORTCUTS_WIDTH, minimum=SHORTCUTS_WIDTH,
fold_key="make_masks/Shortcuts",
hint="or drag to make the shortcut list wider")
self._view_pane = pane
return pane
def _report(self, text: str, kind: str = "info") -> None:
"""Say ``text`` in the console and show it in the corner.
:param text: the line.
:param kind: ``progress``, ``stream``, ``info``, ``warning`` or
``error``; see :meth:`_MasksConsole.post`. A ``stream`` line (an
install's own output) goes to the console only, which throttles
it; the corner keeps the task's own words.
"""
text = self._blind_text(text)
self._masks_console.post(text, kind)
if kind != "stream":
self._status_label.set_quietly(text)
def _report_status(self, text: str) -> None:
"""Copy a new corner text into the console.
A text that ends in an ellipsis says that something is under way,
and rewrites the console's one progress line; anything else is a
line of the scrollback and ends that progress.
"""
stripped = str(text or "").rstrip()
running = stripped.endswith(("…", "..."))
self._masks_console.post(stripped, "progress" if running else "info")
def _build_shortcut_panel(self) -> QWidget:
"""The gestures, one terse line each.
Every row comes from :data:`SHORTCUT_HINTS`, so the list and the
gestures cannot drift apart without the table being edited, and a
test reads the same table off the built widget.
Read between strokes rather than studied, which is why nothing here
is a sentence. The keys sit on their own line ABOVE what they do,
rather than in a column beside it: measured on a rendered screen, a
two-column list 230 px wide broke every line of prose after one word.
IT IS A CARD, with the card's own title and subtitle labels. A bare
QLabel with an object name the theme does not style takes its colour
from the palette instead of the stylesheet, and this panel was
drawing its keys in near-black on the dark canvas -- invisible, and
visible as such only in a rendered grab.
"""
from ..i18n import tr
panel = Card("Shortcuts")
panel.setObjectName("Card")
panel.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred)
content = QWidget()
body = QVBoxLayout(content)
body.setContentsMargins(0, 0, 0, 0)
body.setSpacing(SPACING["xs"])
self._shortcut_scroll = QScrollArea(panel)
self._shortcut_scroll.setWidgetResizable(True)
self._shortcut_scroll.setFrameShape(QScrollArea.NoFrame)
self._shortcut_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self._shortcut_scroll.setWidget(content)
panel.body_layout.addWidget(self._shortcut_scroll, 1)
#: ``keys -> (key label, what it does label)``, so a test can ask the
#: built panel what it is telling the user rather than re-reading the
#: table it was built from.
self._shortcut_rows = {}
for index, (keys, does) in enumerate(SHORTCUT_HINTS):
if index:
body.addSpacing(SPACING["xs"])
key_label = QLabel(tr(keys), panel)
key_label.setObjectName("CardSubtitle")
key_label.setWordWrap(True)
font = key_label.font()
font.setBold(True)
key_label.setFont(font)
does_label = QLabel(tr(does), panel)
does_label.setObjectName("Muted")
does_label.setWordWrap(True)
key_label.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Minimum)
does_label.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Minimum)
body.addWidget(key_label)
body.addWidget(does_label)
self._shortcut_rows[keys] = (key_label, does_label)
body.addStretch(1)
return panel
def _reset_flow_panes(self) -> None:
"""Empty both intermediates and put the view back on the mask.
They belong to ONE Cellpose run on ONE field. Carried over to the
next field they would be a picture of the wrong image, read as a
picture of this one — the worst shape this could take, because
nothing on screen would say so.
"""
self._prob_pane.clear_view()
self._flow_pane.clear_view()
self._view_tabs.setCurrentIndex(0)
def _build_cellpose_card(self) -> _MethodGroup:
"""The Object detection settings, and the detect button they drive.
Called Object detection rather than "Cellpose-SAM", because what
belongs in it is every model that finds objects and not one of them.
The Otsu settings have a category of their own.
The settings are ON THE PANEL rather than assumed. Both
thresholds start at Cellpose's own defaults —
:data:`CELLPROB_THRESHOLD` and :data:`FLOW_THRESHOLD` — so a run
made without touching anything is the run Cellpose would have
made, and a changed number is visibly a departure from it.
The button itself goes in the one tool row rather than in this
card: it is an action, and it has to stay reachable when the
settings are toggled away.
"""
from ...settings import cellpose_model_choices
#: Loaded models, by the name that was asked for. Loading cpsam
#: costs seconds and hundreds of megabytes, and a segmentation
#: session runs it once per field.
self._cp_loaded: dict = {}
card = _MethodGroup()
form = QFormLayout()
self._cp_model = QComboBox()
for name in cellpose_model_choices():
self._cp_model.addItem(name, name)
#: The zoo download running now, if one is.
self._cp_download = None
#: ``zoo key -> path`` of the models downloaded from this box this
#: session. The zoo files a download under the first free versioned
#: name, which is not always the entry's own name -- a name ending in
#: ``_v1`` lands without it -- so the path the download reported is
#: what says the model is here.
self._cp_fetched: dict = {}
self._cp_last_model = 0
self._fill_zoo_models()
self._cp_last_model = self._cp_model.currentIndex()
self._cp_model.currentIndexChanged.connect(self._keep_model_loadable)
self._cp_model.activated.connect(self._on_model_activated)
self._cp_model.setToolTip(
"Which weights segment this field, and the Live magnifier's box "
"in Cellpose mode. The list is the Cellpose installed on this "
"machine and every Cellpose model in the model zoo; a zoo model "
"not downloaded yet is greyed out, and choosing it downloads it "
"and selects it.")
model_row = QWidget()
model_row_layout = QHBoxLayout(model_row)
model_row_layout.setContentsMargins(0, 0, 0, 0)
model_row_layout.setSpacing(SPACING["xs"])
model_row_layout.addWidget(self._cp_model, 1)
self._cp_model_zoo_btn = QPushButton("Model zoo…", model_row)
self._cp_model_zoo_btn.setToolTip(
"Browse the model zoo, download a Cellpose model and segment with "
"it. The model chosen there is selected in the list beside this "
"button.")
self._cp_model_zoo_btn.clicked.connect(
lambda _checked=False: self._choose_cellpose_model_from_zoo())
model_row_layout.addWidget(self._cp_model_zoo_btn)
form.addRow("Model", model_row)
from ..widgets.eliding import ProgressLine
self._cp_download_bar = ProgressLine(count_below=True)
self._cp_download_bar.hide()
form.addRow(self._cp_download_bar)
self._cp_cellprob = QDoubleSpinBox()
self._cp_cellprob.setDecimals(2)
self._cp_cellprob.setRange(-12.0, 12.0)
self._cp_cellprob.setSingleStep(0.1)
self._cp_cellprob.setValue(CELLPROB_THRESHOLD)
self._cp_cellprob.setToolTip(
"Where the cell-probability map is cut. Lower it to keep dimmer "
"objects the network was unsure about; raise it to keep only "
"confident ones. Open the Cell probability tab after a run and "
"the number has something to be judged against.")
form.addRow("Cell probability", self._cp_cellprob)
self._cp_flow = QDoubleSpinBox()
self._cp_flow.setDecimals(2)
self._cp_flow.setRange(0.0, 10.0)
self._cp_flow.setSingleStep(0.1)
self._cp_flow.setValue(FLOW_THRESHOLD)
self._cp_flow.setToolTip(
"How far a candidate object's flows may disagree with the ones "
"the network predicted before it is thrown away. LOWER IS "
"STRICTER, which is the opposite of the way it reads. 0 turns "
"the check off entirely.")
form.addRow("Flow threshold", self._cp_flow)
self._cp_diameter = QSpinBox()
self._cp_diameter.setRange(0, 10_000)
self._cp_diameter.setSingleStep(5)
self._cp_diameter.setValue(0)
self._cp_diameter.setToolTip(
"Expected object diameter in pixels; 0 lets Cellpose work it "
"out. It is the one pre-SAM sizing setting Cellpose 4 still "
"honours — it rescales the image by 30/diameter — so it is the "
"control to reach for when objects come out split or fused.")
form.addRow("Diameter (px)", self._cp_diameter)
card.body_layout.addLayout(form)
self._cp_normalize = Toggle("Normalize each field")
self._cp_normalize.setChecked(True)
self._cp_normalize.setToolTip(
"Percentile-normalize the field before segmenting it, which is "
"what Cellpose expects. Turn it off only for data already "
"normalized upstream, where doing it twice changes the result.")
card.body_layout.addWidget(self._cp_normalize)
self._cp_invert = self._invert_display
inverts = QLabel(
"Detecting dark objects? Turn on Invert image, in the Display "
"category. It inverts the picture you see AND the pixels these "
"buttons and the Live magnifier read, because those were two "
"switches until 2026-09-20 and a curator could have either one "
"without the other.")
inverts.setObjectName("CardSubtitle")
inverts.setWordWrap(True)
card.body_layout.addWidget(inverts)
drives = QLabel(
"The Live magnifier reads these settings too: Cellpose mode uses "
"the model, both thresholds, the diameter and the normalization, "
"and DINOCell the cell probability. Choosing another method "
"above shows that method's settings here instead.")
drives.setObjectName("CardSubtitle")
drives.setWordWrap(True)
card.body_layout.addWidget(drives)
self._btn_cellpose = QPushButton("Object detection")
self._btn_cellpose.setIcon(iconset.icon("run"))
self._btn_cellpose.setCursor(Qt.PointingHandCursor)
self._btn_cellpose.setToolTip(
"Segment the open field with the Object detection model and "
"fold the result "
"in as the replace/merge setting says. Fills the Cell "
"probability and Flows tabs with what the run was thinking.")
self._btn_cellpose.clicked.connect(self._on_detect_cellpose)
self.add_toolbar_action(self._btn_cellpose)
return card
def _build_otsu_card(self) -> _MethodGroup:
"""The Otsu settings, driving both the button and the magnifier.
The threshold correction once sat at the bottom of the Cellpose-SAM
category, where a user looking for the Otsu settings had no reason
to open it. It lives here with the Bright switch, which was a bare
tick-box beside the detect button, and four more settings.
ALL SIX DRIVE BOTH PLACES OTSU RUNS -- the Otsu detect button on the
whole field, and the Live magnifier's Otsu mode on the box under the
mouse -- so the box under the mouse is a close preview of what the
button will do rather than a second opinion. The one exception is
Exclude at the image border, which the magnifier answers with its
own "Exclude objects touching the box border"; applying both to a
box would drop everything the box cut twice over.
THE DEFAULTS ARE THE MAGNIFIER'S, AND THAT MOVES THE BUTTON. Before
this category the two disagreed in three ways and nothing on the
screen said so: the magnifier smoothed the region, filled the holes
and cut a blob with two centres in two, and Otsu detect thresholded
and labelled and did none of it. A user who set the correction by
watching the box and then pressed the button got a different mask.
The three boxes start where the preview has always been; three
clicks put the plain threshold back, which is what
:func:`spacr.qt.mask_engine._otsu_instances` still does when it is
asked for nothing.
"PREVIEW", NOT "THE SAME FUNCTION", AND THE DIFFERENCE IS MEASURED.
Closing those three gaps does not make the two one routine.
:func:`spacr.qt.mask_engine._classical_region_labels` still opens the
binary image, still offsets Otsu's level by the magnifier's own
Sensitivity, and still falls back to a noise-floor cut where a region
holds no two clear populations;
:func:`spacr.qt.mask_engine._otsu_instances` does none of the three.
Driven from these defaults over twelve synthetic 96x96 fields of
three to six bright discs on noise, the two agreed on
the object COUNT in twelve of twelve and were pixel-identical in two
of twelve, the other ten differing by 3 to 16 boundary pixels out of
9,216. So the box tells a curator what the button is about to do; it
does not promise the same array.
"""
card = _MethodGroup()
form = QFormLayout()
self._otsu_correction = QDoubleSpinBox()
self._otsu_correction.setDecimals(2)
self._otsu_correction.setRange(0.1, 5.0)
self._otsu_correction.setSingleStep(0.05)
self._otsu_correction.setValue(1.0)
self._otsu_correction.setToolTip(
"Threshold factor (default 1; range 0.1 to 5), applied before "
"foreground selection. For positive global thresholds and local "
"Otsu, increasing it keeps fewer bright or dark pixels. Sauvola "
"and Niblack multiply their direct local levels: at positive "
"levels, increasing it keeps fewer bright but more dark pixels; "
"negative levels reverse that direction. Multi-Otsu shifts class "
"boundaries, so a selected middle band can gain and lose pixels.")
form.addRow("Threshold correction", self._otsu_correction)
self._otsu_smoothing = QDoubleSpinBox()
self._otsu_smoothing.setDecimals(1)
self._otsu_smoothing.setRange(0.0, 10.0)
self._otsu_smoothing.setSingleStep(0.5)
self._otsu_smoothing.setValue(OTSU_SMOOTHING)
self._otsu_smoothing.setToolTip(
"Gaussian blur, in pixels, before the level is found and before "
"the image is cut at it. It is what stops a noisy field coming "
"back as a thousand single-pixel objects. 0 thresholds the raw "
"data.")
form.addRow("Smoothing (sigma)", self._otsu_smoothing)
card.body_layout.addLayout(form)
self._otsu_bright = Toggle("Objects are brighter than background")
self._otsu_bright.setChecked(True)
self._otsu_bright.setToolTip(
"On: objects are brighter than background, as in fluorescence. "
"Off: take the dark side instead, for brightfield or stain.")
card.body_layout.addWidget(self._otsu_bright)
self._otsu_fill_holes = Toggle("Fill holes inside an object")
self._otsu_fill_holes.setChecked(True)
self._otsu_fill_holes.setToolTip(
"Close the holes inside what was thresholded, before it is "
"labelled. A nucleus dimmer in the middle than at its rim comes "
"back as a ring without this. The magnifier's Otsu mode has "
"always done it and Otsu detect did not, which is why the two "
"could disagree about the same field; now they both read this "
"box.")
card.body_layout.addWidget(self._otsu_fill_holes)
self._otsu_split = Toggle("Split objects that touch")
self._otsu_split.setChecked(True)
self._otsu_split.setToolTip(
"Cut a blob with two centres in two, at the ridge between them "
"(a watershed on the distance to the background). A blob with "
"one centre is left whole, so this is not a splitter that cuts "
"everything. Off, a pair of touching cells arrives as one "
"object.")
card.body_layout.addWidget(self._otsu_split)
self._otsu_exclude_border = Toggle(
"Drop objects the image border cuts")
self._otsu_exclude_border.setChecked(False)
self._otsu_exclude_border.setToolTip(
"Leave out the objects the edge of the field runs through. Their "
"area and their mean intensity are properties of where the frame "
"fell rather than of the object, so a detection meant to be "
"measured is better without them. Otsu detect only: the Live "
"magnifier has its own box-border switch.")
card.body_layout.addWidget(self._otsu_exclude_border)
more = QFormLayout()
self._otsu_classes = QSpinBox()
self._otsu_classes.setRange(2, 6)
self._otsu_classes.setValue(2)
self._otsu_classes.setToolTip(
"How many brightness bands to cut the field into. 2 is Otsu's "
"own split, one level between background and objects. Raise it "
"where a field holds more than two populations — background, a "
"dim halo and bright nuclei — and the cut moves off the one "
"compromise level between all three onto the boundary you "
"actually want, chosen below. Multi-Otsu uses this count in both "
"magnifier scopes and whole-image detection. Each scope estimates "
"thresholds from its own pixels; a small region may contain too "
"few distinct intensities for the requested class count. In plain "
"Otsu mode, this count still applies only to whole-image detect.")
more.addRow("Classes", self._otsu_classes)
self._otsu_foreground = QSpinBox()
self._otsu_foreground.setRange(0, 5)
self._otsu_foreground.setValue(1)
self._otsu_foreground.setToolTip(
"Which band becomes the objects, counting 0 for the dimmest. "
"With 3 classes, 2 takes the bright nuclei and 1 takes the dim "
"halo AROUND them without the nuclei inside it — exactly that "
"band, which is what more than two classes is for. With more "
"than two classes this replaces 'Objects are brighter than "
"background': the number says which side you mean.")
more.addRow("Foreground class", self._otsu_foreground)
self._otsu_window = QSpinBox()
self._otsu_window.setRange(3, 999)
self._otsu_window.setSingleStep(2)
self._otsu_window.setValue(OTSU_LOCAL_WINDOW)
self._otsu_window.setSuffix(" px")
self._otsu_window.setToolTip(
"The square the local level is measured in, centred on each "
"pixel. Make it comfortably bigger than one object and smaller "
"than the illumination's own scale: too small and the inside of "
"a large object becomes its own background, so it comes back "
"hollow; too large and it is the whole-field threshold again. "
"Even numbers are rounded up, so the square has a centre.")
more.addRow("Local window", self._otsu_window)
self._otsu_local_k = QDoubleSpinBox()
self._otsu_local_k.setDecimals(3)
self._otsu_local_k.setRange(-2.0, 2.0)
self._otsu_local_k.setSingleStep(0.05)
self._otsu_local_k.setValue(0.2)
self._otsu_local_k.setToolTip(
"Dimensionless local contrast weight (default 0.2; range -2 to 2). "
"Niblack uses T=m-k*s: increasing k lowers the threshold and keeps "
"more bright pixels, fewer dark pixels. Sauvola uses "
"T=m*(1+k*(s/R-1)), where m is the local mean and s its standard "
"deviation. Here R=1 because the detector receives floats without "
"range rescaling; its response to k depends on m and s/R. "
"Check the preview after changing intensity scale.")
self._otsu_local_k_label = QLabel("Local k")
more.addRow(self._otsu_local_k_label, self._otsu_local_k)
card.body_layout.addLayout(more)
self._otsu_local = Toggle("Local threshold (uneven illumination)")
self._otsu_local.setChecked(False)
self._otsu_local.setToolTip(
"Find Otsu's level separately in a window around every pixel "
"instead of once for the whole field. A corner that the lamp "
"falls away from is then judged against its own corner, so the "
"objects in it stop being lost while the bright middle stays "
"clean. It costs more objects on a noisy background, which the "
"minimum area is there to take back. Two classes only, and Otsu "
"detect only.")
self._otsu_local.toggled.connect(self._sync_otsu_controls)
self._otsu_classes.valueChanged.connect(self._sync_otsu_controls)
self._otsu_classes.valueChanged.connect(self._on_magnifier_context_changed)
self._otsu_foreground.valueChanged.connect(self._on_magnifier_context_changed)
self._otsu_local_k.valueChanged.connect(
self._on_magnifier_context_changed)
self._otsu_window.valueChanged.connect(
self._on_magnifier_context_changed)
card.body_layout.addWidget(self._otsu_local)
min_area_note = QLabel(
"Objects under the Min area in Object operations are dropped "
"after the threshold."
)
min_area_note.setWordWrap(True)
card.body_layout.addWidget(min_area_note)
self._btn_otsu_hist = QPushButton("Show histogram and level")
self._btn_otsu_hist.setCursor(Qt.PointingHandCursor)
self._btn_otsu_hist.setToolTip(
"Preview the full-field detector input after inversion, normalization, "
"enhancement and smoothing, with the selected method's thresholds. "
"Local methods have no single threshold marker. Computation runs in "
"the background; close the window to discard it. Open again to "
"refresh after changing settings.")
self._btn_otsu_hist.clicked.connect(self._on_show_otsu_histogram)
card.body_layout.addWidget(self._btn_otsu_hist)
self._sync_otsu_controls()
return card
def _build_methods_card(self) -> _MethodGroup:
"""The parameters of organelle detection's methods, one mode at a time.
Six of the Mode box's rows are organelle detection's own methods
(:mod:`spacr.qt.organelle_modes`), and each reads different numbers:
a block size and an offset, a pair of sigmas, a list of filament
widths, two hysteresis levels, a checkpoint. Putting all nineteen on
the panel at once would put eighteen controls that are being ignored
in front of a curator who cannot tell which is which -- the argument
:meth:`_sync_otsu_controls` already makes about three of them.
SO THE CARD SHOWS ONE MODE'S PARAMETERS AND HIDES THE REST, from
:data:`spacr.qt.organelle_modes.PARAMETERS_FOR`, which is also what
the ledger records (:func:`spacr.qt.organelle_modes.provenance`). One
list, read by the form, by the recorder and by the engine's own
settings dict, so the three cannot disagree about what a method
read.
The sentence at the top is
:func:`spacr.organelle_types.method_guidance`, built from
:data:`spacr.organelle_types.LEGAL_METHODS` -- the shapes spaCR
already records the method as a legal detector for.
"""
card = _MethodGroup()
form = self._method_form = QFormLayout()
#: ``field of MethodParams -> its control``. One control per
#: parameter and not one per mode-and-parameter, so the block size
#: the Adaptive mode was tuned at is the block size the Ridge
#: mode's adaptive threshold then uses.
self._method_widgets: dict = {}
def row(field: str, caption: str, widget, tip: str) -> None:
"""Add one parameter row and remember it under its field name."""
widget.setToolTip(tip)
self._method_widgets[field] = widget
if caption:
form.addRow(caption, widget)
else:
form.addRow(widget)
block = QSpinBox()
block.setRange(3, 999)
block.setSingleStep(2)
block.setValue(51)
block.setSuffix(" px")
row("adaptive_block", "Block size", block,
"The square the local threshold is measured in, centred on each "
"pixel; the engine forces it odd. A few times the object "
"diameter is the starting point: too small and the middle of a "
"large object becomes its own background, too large and it is a "
"whole-field threshold again.")
offset = QDoubleSpinBox()
offset.setDecimals(2)
offset.setRange(-1000.0, 1000.0)
offset.setSingleStep(1.0)
offset.setValue(5.0)
row("adaptive_offset", "Offset", offset,
"Subtracted from the Gaussian-weighted local mean (default 5). "
"Increasing the offset lowers the threshold and keeps more "
"foreground pixels before cleanup; a negative offset is stricter. "
"Units follow the processed image: smoothed intensity for Adaptive "
"threshold, ridge response for Ridge filter with an adaptive "
"threshold. A raw-intensity offset can overwhelm a 0-to-1 response.")
morph = QSpinBox()
morph.setRange(0, 50)
morph.setValue(3)
morph.setSuffix(" px")
row("morph_radius", "Cleanup radius", morph,
"The disk the detection is closed and opened with after it is "
"thresholded. Raise it to smooth ragged outlines, lower it to "
"keep fine detail. The Adaptive mode also pre-smooths with half "
"of it; the network methods close with half of it.")
holes = QSpinBox()
holes.setRange(0, 1_000_000)
holes.setValue(64)
holes.setSuffix(" px²")
row("fill_holes", "Fill holes up to", holes,
"Holes inside an object smaller than this are filled, so an "
"object dimmer in the middle than at its rim does not come back "
"as a ring. 0 leaves every hole where it is.")
watershed = Toggle("Split touching spots")
watershed.setChecked(True)
row("watershed_spots", "", watershed,
"Grow a watershed from each blob centre instead of stamping a "
"disk whose radius comes from that blob's own scale. Turn it "
"off when single spots are being fragmented.")
log_min = QDoubleSpinBox()
log_min.setDecimals(2)
log_min.setRange(0.1, 100.0)
log_min.setValue(1.0)
row("log_min_sigma", "Min sigma", log_min,
"The smallest scale searched, in pixels; a blob's radius is "
"about sigma times the square root of two, so 1 finds roughly "
"1.4 px puncta. Raise it to ignore single-pixel noise.")
log_max = QDoubleSpinBox()
log_max.setDecimals(2)
log_max.setRange(0.1, 200.0)
log_max.setValue(10.0)
row("log_max_sigma", "Max sigma", log_max,
"The largest scale searched, in pixels. Raise it to catch large "
"puncta; the filter runs once per scale, so it costs time.")
log_num = QSpinBox()
log_num.setRange(1, 50)
log_num.setValue(10)
row("log_num_sigma", "Scales", log_num,
"How many scales are evaluated between the two sigmas. More "
"resolves a wider spread of spot sizes and costs one filter "
"pass each; 3 to 5 is enough when the spots are all one size.")
log_thresh = QDoubleSpinBox()
log_thresh.setDecimals(4)
log_thresh.setRange(0.0001, 1.0)
log_thresh.setSingleStep(0.005)
log_thresh.setValue(0.01)
row("log_threshold", "Blob threshold", log_thresh,
"The blob response a spot has to reach to be kept. Lower it to "
"find fainter spots and more noise. DoG reads this one too: it "
"has no threshold of its own.")
dog_low = QDoubleSpinBox()
dog_low.setDecimals(2)
dog_low.setRange(0.1, 100.0)
dog_low.setValue(1.0)
row("dog_sigma_low", "Low sigma", dog_low,
"The smaller of the two Gaussians, in pixels: the finest "
"detail kept. Raise it to suppress noise.")
dog_high = QDoubleSpinBox()
dog_high.setDecimals(2)
dog_high.setRange(0.1, 200.0)
dog_high.setValue(3.0)
row("dog_sigma_high", "High sigma", dog_high,
"The larger of the two Gaussians, in pixels. Scales step up "
"from the low sigma by a factor of 1.6 until this bound, so a "
"wider gap covers more spot sizes and costs more passes.")
ridge_filter = QComboBox()
for name in ("frangi", "sato", "meijering"):
ridge_filter.addItem(name, name)
row("ridge_filter", "Ridge filter", ridge_filter,
"Which vesselness filter measures how tube-like each pixel's "
"neighbourhood is. Frangi is the usual choice; Sato responds "
"more to bright tubes, Meijering to thin neurite-like ones.")
ridge_sigmas = QLineEdit("1, 2, 3")
row("ridge_sigmas", "Filament widths", ridge_sigmas,
"The scales the filter looks for, in pixels, separated by "
"commas; each should be about the half-width of a filament. Add "
"a larger value for thick bundles and keep the small ones for "
"fine tubules. Runtime grows with the list.")
ridge_threshold = QComboBox()
ridge_threshold.addItem("Otsu", "otsu")
ridge_threshold.addItem("Adaptive", "adaptive")
row("ridge_threshold", "Cut the response at", ridge_threshold,
"How the filter's response becomes a foreground. Otsu takes one "
"level for the whole region; Adaptive uses the block size and "
"offset above and keeps faint filaments in dim corners, at the "
"cost of background elsewhere.")
skeleton = Toggle("Reduce to a one-pixel skeleton")
row("skeletonize", "", skeleton,
"Label the centre line of the network instead of the filled "
"filaments, so a measured area tracks network LENGTH rather "
"than filament thickness. Leave it off to measure filament "
"mass.")
hyst_low = QDoubleSpinBox()
hyst_low.setDecimals(3)
hyst_low.setRange(0.0, 1_000_000.0)
hyst_low.setValue(0.2)
row("hysteresis_low", "Weak level", hyst_low,
"Pixels above this are kept only where they connect to a seed "
"above the strong level. Under 1.0 it is read as a fraction and "
"becomes that percentile of the region (0.2 is the 20th); 1.0 "
"and above is an absolute intensity.")
hyst_high = QDoubleSpinBox()
hyst_high.setDecimals(3)
hyst_high.setRange(0.0, 1_000_000.0)
hyst_high.setValue(0.6)
row("hysteresis_high", "Strong level", hyst_high,
"Only pieces holding a pixel above this survive at all, and "
"they then grow outward down to the weak level. Read as a "
"percentile below 1.0, as an absolute intensity above it.")
unet_path = QWidget()
unet_row = QHBoxLayout(unet_path)
unet_row.setContentsMargins(0, 0, 0, 0)
self._unet_path_edit = QLineEdit()
self._unet_path_edit.setPlaceholderText("model.pt")
unet_browse = QPushButton("Browse…")
unet_browse.setCursor(Qt.PointingHandCursor)
unet_browse.clicked.connect(self._on_pick_unet_model)
unet_row.addWidget(self._unet_path_edit, 1)
unet_row.addWidget(unet_browse)
row("unet_model_path", "U-Net checkpoint", unet_path,
"A .pt or .pth file holding a model that takes one channel and "
"returns one channel of logits. HEAVY: the file is loaded and "
"the network is run, which on a CPU is seconds per box and "
"minutes per field.")
unet_threshold = QDoubleSpinBox()
unet_threshold.setDecimals(3)
unet_threshold.setRange(0.0, 1.0)
unet_threshold.setSingleStep(0.05)
unet_threshold.setValue(0.5)
row("unet_threshold", "Probability cut-off", unet_threshold,
"Where the network's output is cut. Lower it to recover faint "
"branches along with false positives; raise it to keep only "
"confident pixels, which tends to break weak connections.")
card.body_layout.addLayout(form)
for widget in self._method_widgets.values():
for signal in ("valueChanged", "currentIndexChanged",
"textChanged", "toggled"):
changed = getattr(widget, signal, None)
if changed is not None:
changed.connect(self._on_magnifier_context_changed)
self._sync_method_controls()
return card
def _build_detection_card(self) -> Section:
"""Build the shared detection-mode selector and its method controls.
The mode drives the detect buttons, whole-image runs and Live
magnifier. Magnification settings nest here and control size, zoom,
scope, overlap, sensitivity and optional instant acceptance.
Inside, four :class:`_MethodGroup` s, of which one is shown:
the threshold family's settings (Otsu's own, and every algorithm in
:mod:`spacr.qt.cpu_modes` reads them), Cellpose's, the organelle
methods' and the propagation's. :meth:`_sync_method_controls` is
what shows one and hides three.
"""
card = self._settings_category(
"Object detection",
"What finds the objects, and the settings that method reads. "
"Drives the detect buttons and the Live magnifier alike.",
)
form = QFormLayout()
self._mag_mode = QComboBox()
self._mag_mode.setSizeAdjustPolicy(
QComboBox.AdjustToMinimumContentsLengthWithIcon)
self._mag_mode.setMinimumContentsLength(14)
self._mag_mode.addItem("Otsu", "otsu")
from ..i18n import tr
for source in (cpu_modes.MODE_LABELS, organelle_modes.MODE_LABELS):
for mode, label in source.items():
self._mag_mode.addItem(tr(label), mode)
self._mag_mode.setItemData(
self._mag_mode.count() - 1, self._mode_guidance(mode),
Qt.ToolTipRole)
if _cellpose_installed():
self._mag_mode.addItem("Cellpose", "cellpose")
self._mag_uninstalled = set()
_offer_cellpose_dino_modes()
_offer_prefixed_modes()
for mode, (_backend, label) in _MAGNIFIER_BACKENDS.items():
self._mag_mode.addItem(label, mode)
self._resync_magnifier_modes()
self._mag_mode.setToolTip(
"Which algorithm finds the objects, for the detect buttons and "
"for the Live magnifier alike. Otsu and the threshold "
"algorithms under it cut the field at one level and need "
"nothing installed; Maxima + propagate grows an object out of "
"each bright centre, which is how two touching objects come "
"apart; the organelle methods are the ones organelle detection "
"runs, through the same code; Cellpose and the backends are "
"models. Choosing a row changes which settings this category "
"shows, and the row's own tooltip says what it suits.")
self._mag_mode.currentIndexChanged.connect(self._on_mode_row_changed)
self._mag_mode.activated.connect(self._on_magnifier_mode_activated)
form.addRow("Method", self._mag_mode)
card.body_layout.addLayout(form)
self._method_note = QLabel()
self._method_note.setWordWrap(True)
card.body_layout.addWidget(self._method_note)
from .. import screens as _screens_package
builders = (
("threshold", self._build_otsu_card),
("organelle", self._build_methods_card),
("propagate", self._build_propagate_card),
("secondary", partial(self._build_propagate_card, secondary=True)),
("puncta", self._build_puncta_card),
("cellpose", self._build_cellpose_card),
)
self._method_groups = {}
for name, build in builders:
_screens_package._breathe_while_a_window_opens()
self._method_groups[name] = build()
for group in self._method_groups.values():
card.body_layout.addWidget(group)
card.body_layout.addWidget(self._build_uncertainty_ensemble_setting())
self._magnification_card = self._build_magnifier_card()
card.body_layout.addWidget(self._magnification_card)
return card
def _mode_guidance(self, mode: str) -> str:
"""What the mode ``mode`` suits, from whichever module owns it.
A backend is named after Cellpose's sentence: they are all models
that know what a cell looks like, and what distinguishes them from
each other is the training set rather than the kind of object.
"""
if mode in organelle_modes.MODE_LABELS:
return organelle_modes.guidance(mode)
if mode in _MAGNIFIER_BACKENDS:
return cpu_modes.guidance("cellpose")
return cpu_modes.guidance(mode)
@staticmethod
def _mode_family(mode: str) -> str:
"""Which :attr:`_method_groups` family ``mode`` belongs to."""
if mode in organelle_modes.MODE_LABELS:
return "organelle"
if mode == cpu_modes.PUNCTA:
return "puncta"
if mode == cpu_modes.PROPAGATE:
return "propagate"
if mode == cpu_modes.SECONDARY:
return "secondary"
if mode == "cellpose" or mode in _MAGNIFIER_BACKENDS:
return "cellpose"
return "threshold"
def _build_puncta_card(self) -> _MethodGroup:
"""Controls for noise-standardised, parent-constrained centre puncta."""
from ..i18n import tr
card = _MethodGroup()
form = QFormLayout()
form.setRowWrapPolicy(QFormLayout.WrapLongRows)
self._puncta_widgets = {}
scales = QLineEdit('1.5, 2, 3, 4, 6')
scales.setToolTip(tr('LoG sigma values in image pixels, separated by commas. Defaults: 1.5, 2, 3, 4, 6.'))
self._puncta_widgets['puncta_sigmas'] = scales
form.addRow(tr('Spot scales (px)'), scales)
scales.editingFinished.connect(self._on_magnifier_context_changed)
for key, label, default, low, high in (
('puncta_k', tr('Candidate threshold (noise units)'), 2.5, .01, 100.),
('puncta_center_pixels', tr('Centre pixels per punctum'), 20, 1, 81),
('puncta_min_corrected', tr('Minimum corrected intensity (AU)'), 3., -1000000., 1000000.),
('puncta_min_distance', tr('Minimum peak spacing (px)'), 2, 1, 1000),
('puncta_edge_margin', tr('Parent edge margin (px)'), 3., 0., 1000.),
):
widget = QSpinBox() if key in ('puncta_center_pixels', 'puncta_min_distance') else QDoubleSpinBox()
widget.setRange(low, high)
widget.setValue(default)
widget.setToolTip(tr('Uses original intensity values. Raising candidate or corrected-intensity thresholds rejects faint spots. Centre pixels set the measurement window, not a grown object area.'))
self._puncta_widgets[key] = widget
form.addRow(label, widget)
widget.valueChanged.connect(self._on_magnifier_context_changed)
card.body_layout.addLayout(form)
note = QLabel(tr('Choose the parent-mask folder above. Use whole-image Replace, then Save mask. Centre measurements are saved beside the mask; shared centre pixels have one label owner. Min area is bypassed.'))
note.setWordWrap(True)
card.body_layout.addWidget(note)
return card
def _build_propagate_card(self, *, secondary=False) -> _MethodGroup:
"""The settings of Maxima + propagate, an intensity watershed.
Four steps with a setting each, in the order they run: blur, find
the maxima, grow, stop. The engine is
:func:`spacr.qt.mask_engine.maxima_propagate_instances`, whose
docstring describes the four stop rules, parameter units and
defaults, and the difference between seed and surviving-label counts.
``secondary=True`` selects existing primary masks instead of finding
centres, with independent settings and a default global threshold.
"""
from ..i18n import tr
card = _MethodGroup()
form = QFormLayout()
form.setRowWrapPolicy(QFormLayout.WrapLongRows)
widgets = {}
if secondary:
from ..widgets.primary_mask_selector import PrimaryMaskSelector
self._secondary_widgets = widgets
self._primary_selector = PrimaryMaskSelector(card)
self._primary_selector.changed.connect(self._on_primary_source_changed)
card.body_layout.addWidget(self._primary_selector)
self._secondary_relations = QLabel(tr('Primary/secondary relationships will appear after detection.'))
self._secondary_relations.setWordWrap(True)
card.body_layout.addWidget(self._secondary_relations)
else:
self._propagate_form = form
self._propagate_widgets = widgets
def row(field: str, caption: str, widget, tip: str) -> None:
"""Add one parameter row and remember it under its field name."""
widget.setToolTip(tip)
widgets[field] = widget
if caption:
form.addRow(caption, widget)
else:
form.addRow(widget)
if secondary:
growth = QComboBox()
growth.setSizeAdjustPolicy(QComboBox.AdjustToMinimumContentsLengthWithIcon)
growth.setMinimumContentsLength(14)
growth.addItem(tr('Intensity watershed'), 'intensity')
growth.addItem(tr('Distance watershed'), 'distance')
row('secondary_growth', tr('Growth'), growth,
tr('Intensity follows bright structures. Distance spreads from primary pixels. Common thresholds constrain the paths; fraction-of-peak trims after growth. Neither is CellProfiler Propagation.'))
sigma = QDoubleSpinBox()
sigma.setDecimals(2)
sigma.setRange(0.0, 50.0)
sigma.setSingleStep(0.5)
sigma.setValue(2.0)
sigma.setSuffix(" px")
row("propagate_sigma", "Blur first", sigma,
"Gaussian blur applied before the centres are found, in "
"pixels. It is what makes ONE object have ONE centre: a raw "
"object has a dozen maxima in its own noise. About a third of "
"the object radius is a starting point. This is NOT the Image "
"enhancement card's denoise, which has already run by now; "
"leave that one off unless the field is genuinely noisy, or "
"the two blurs compound.")
distance = QSpinBox()
distance.setRange(1, 500)
distance.setValue(10)
distance.setSuffix(" px")
row("propagate_min_distance", "Min centre spacing", distance,
"No two centres closer together than this, so one object "
"cannot become two. About one object radius. Raise it when "
"objects are being split, lower it when two touching objects "
"come back as one.")
level = QDoubleSpinBox()
level.setDecimals(2)
level.setRange(0.0, 1_000_000.0)
level.setValue(90.0)
row("propagate_seed_level", "Centre level", level,
"How bright a maximum has to be to count as a centre. Read as "
"a percentile of the blurred image by default, so one setting "
"suits any exposure: 90 means the top tenth of the pixels. "
"This and the distance together decide HOW MANY objects there "
"will be, and the status line says how many centres were "
"found.")
percentile = Toggle("Centre level is a percentile")
percentile.setChecked(True)
row("propagate_seed_percentile", "", percentile,
"On: the number above is a percentile of this image. Off: it "
"is an absolute intensity, which is what to use when the "
"same setting must mean the same thing across fields of "
"different exposure.")
border = Toggle("Drop centres near the edge")
row("propagate_exclude_border", "", border,
"Leave out maxima within one minimum distance of the frame. "
"An object the edge cuts has its centre in the wrong place, "
"so what grows from it is the wrong shape.")
stop = QComboBox()
stop.setSizeAdjustPolicy(QComboBox.AdjustToMinimumContentsLengthWithIcon)
stop.setMinimumContentsLength(18)
stop.addItem("Fraction of this centre's own peak", "seed_fraction")
stop.addItem("Absolute intensity", "absolute")
stop.addItem("Percentile of the image", "percentile")
stop.addItem("A threshold algorithm's level", "threshold")
row("propagate_stop", "Grow until", stop,
tr("Fraction of peak: trim each watershed basin at the chosen "
"fraction of its seed intensity. This is an intensity ratio, "
"not a percentile; background offsets affect the result. "
"The other three rules restrict the watershed with one "
"threshold for the processed field or region. Hole filling "
"and minimum-area filtering run afterward."))
stop.currentIndexChanged.connect(self._sync_propagate_controls)
stop_value = QDoubleSpinBox()
stop_value.setDecimals(3)
stop_value.setRange(0.0, 1_000_000.0)
stop_value.setValue(0.4)
row("propagate_stop_value", "Stop at", stop_value,
"The number the rule above reads: a fraction from 0 to 1 for "
"the per-centre rule, an intensity for the absolute one, a "
"percentile from 0 to 100 for the third. Not read when a "
"threshold algorithm provides the level.")
algorithm = QComboBox()
for name in engine.GLOBAL_THRESHOLDS:
algorithm.addItem(_threshold_label(name), name)
row("propagate_stop_algorithm", "Stop threshold", algorithm,
"Which global threshold provides the floor, when the rule "
"above is a threshold algorithm's level. The same algorithms "
"the Method box offers on their own.")
card.body_layout.addLayout(form)
if secondary:
self._secondary_fill_holes = Toggle(
tr('Fill holes inside secondary objects'), word_wrap=True)
self._secondary_fill_holes.setToolTip(tr(
'Fill enclosed background holes within each secondary object after growth '
'and before minimum-area filtering. Pixels belonging to another object are preserved.'))
self._secondary_fill_holes.setChecked(True)
self._secondary_fill_holes.toggled.connect(self._on_magnifier_context_changed)
card.body_layout.addWidget(self._secondary_fill_holes)
for field, widget in widgets.items():
form.setRowVisible(widget, field in cpu_modes.PARAMETERS_FOR[cpu_modes.SECONDARY])
stop.setCurrentIndex(stop.findData('threshold'))
stop.setItemText(stop.findData('seed_fraction'), tr("Fraction of the primary object's peak"))
sigma.setToolTip(tr('Gaussian smoothing before growth, in pixels. Primary labels are kept unchanged; no centres are detected.'))
for widget in widgets.values():
for signal in ("valueChanged", "currentIndexChanged", "toggled"):
changed = getattr(widget, signal, None)
if changed is not None:
changed.connect(self._on_magnifier_context_changed)
self._sync_propagate_controls()
return card
def _sync_propagate_controls(self, *_args) -> None:
"""Leave enabled only the propagation boxes that answer anything."""
for attribute in ('_propagate_widgets', '_secondary_widgets'):
widgets = getattr(self, attribute, None)
if widgets:
rule = str(widgets["propagate_stop"].currentData())
widgets["propagate_stop_value"].setEnabled(rule != "threshold")
widgets["propagate_stop_algorithm"].setEnabled(rule == "threshold")
def _cpu_params(self) -> "cpu_modes.CpuParams":
"""The CPU modes' settings, as the engine's parameters."""
if getattr(self, '_mag_mode', None) is not None and self._mag_mode.currentData() == cpu_modes.PUNCTA:
values = {key: widget.value() for key, widget in self._puncta_widgets.items()
if key != 'puncta_sigmas'}
text = self._puncta_widgets['puncta_sigmas'].text()
try:
sigmas = tuple(float(piece) for piece in text.replace(',', ' ').split())
except ValueError:
sigmas = (float('nan'),)
return cpu_modes.CpuParams(puncta_sigmas=sigmas, **values)
secondary = getattr(self, '_mag_mode', None) is not None and self._mag_mode.currentData() == cpu_modes.SECONDARY
widgets = getattr(self, "_secondary_widgets" if secondary else "_propagate_widgets", None)
if not widgets:
return cpu_modes.DEFAULT_PARAMS
return cpu_modes.CpuParams(
local_k=float(self._otsu_local_k.value()),
propagate_sigma=float(widgets["propagate_sigma"].value()),
propagate_min_distance=int(
widgets["propagate_min_distance"].value()),
propagate_seed_level=float(widgets["propagate_seed_level"].value()),
propagate_seed_percentile=bool(
widgets["propagate_seed_percentile"].isChecked()),
propagate_exclude_border=bool(
widgets["propagate_exclude_border"].isChecked()),
propagate_stop=str(widgets["propagate_stop"].currentData()),
propagate_stop_value=float(
widgets["propagate_stop_value"].value()),
propagate_stop_algorithm=str(
widgets["propagate_stop_algorithm"].currentData()),
secondary_growth=(str(widgets['secondary_growth'].currentData())
if 'secondary_growth' in widgets else 'intensity'),
)
def _on_pick_unet_model(self) -> None:
"""Choose the U-Net checkpoint the U-Net mode is to load."""
from ..i18n import tr
path, _filter = QFileDialog.getOpenFileName(
self, tr("Choose a U-Net checkpoint"), "",
tr("Torch checkpoints (*.pt *.pth)"))
if path:
self._unet_path_edit.setText(path)
def _sync_method_controls(self, *_args) -> None:
"""Show the chosen mode's parameters and hide every other method's.
A control that is being read and a control that is being ignored
look identical; the modes each read four to seven of nineteen, so
the card would otherwise be mostly controls that do nothing.
THE CARD ITSELF STAYS, empty but for a sentence, under Otsu,
Cellpose and the backends, which read none of these parameters. A
category that comes and goes is a category whose folded state, and
whose place on the panel, a user cannot learn -- and the note is
where they are told which category their mode reads instead.
"""
from ..i18n import tr
mode = canonical_magnifier_mode(getattr(self._magnifier, "mode", None))
family = self._mode_family(mode)
groups = getattr(self, '_method_groups', {})
if 'puncta' in groups:
target = groups['puncta' if mode == cpu_modes.PUNCTA else 'secondary']
if self._primary_selector.parent() is not target:
target.body_layout.insertWidget(0, self._primary_selector)
entering = mode == cpu_modes.PUNCTA and not getattr(self, '_puncta_active', False)
self._puncta_active = mode == cpu_modes.PUNCTA
if entering:
self._min_area.setValue(1)
self._min_area.setEnabled(mode != cpu_modes.PUNCTA)
for name, group in getattr(self, "_method_groups", {}).items():
group.setVisible(name == family)
shown = organelle_modes.PARAMETERS_FOR.get(mode, ())
for field, widget in self._method_widgets.items():
self._method_form.setRowVisible(widget, field in shown)
self._sync_otsu_controls()
note = tr(self._mode_guidance(mode)) if self._mode_guidance(mode) \
else ""
heavy = organelle_modes.HEAVY_MODES.get(mode)
if heavy:
note = f"{note} {tr('Heavy: this mode {what}.', what=tr(heavy))}"
self._method_note.setText(note)
for name in ('_enh_morphology', '_enh_morphology_radius', '_enh_split'):
widget = getattr(self, name, None)
if widget is not None:
widget.setEnabled(mode not in (cpu_modes.SECONDARY, cpu_modes.PUNCTA))
self._sync_detect_button(mode)
def _sync_detect_button(self, mode: str) -> None:
"""Name the whole-image CPU detect button after the chosen method.
The button runs the method, so it says the method. A model mode
leaves it reading "Otsu detect", because that is what it falls back
to and a button that claimed to run Cellpose while running Otsu
would be the disagreement this fold was meant to end.
"""
from ..i18n import tr
button = getattr(self, "_btn_otsu", None)
if button is None:
return
named = ("otsu" if self._mode_family(mode) == "cellpose" else mode)
button.setText(tr("{method} detect",
method=_magnifier_mode_label(named)))
def _method_params(self) -> "organelle_modes.MethodParams":
"""The Object detections card as the engine's parameters.
Read on the GUI thread whenever a request is built, like every
other setting a model reads, so the box under the mouse and the
whole-image run cannot be answering under different numbers.
"""
widgets = getattr(self, "_method_widgets", None)
if not widgets:
return organelle_modes.DEFAULT_PARAMS
return organelle_modes.MethodParams(
adaptive_block=int(widgets["adaptive_block"].value()),
adaptive_offset=float(widgets["adaptive_offset"].value()),
morph_radius=int(widgets["morph_radius"].value()),
fill_holes=int(widgets["fill_holes"].value()),
watershed_spots=bool(widgets["watershed_spots"].isChecked()),
log_min_sigma=float(widgets["log_min_sigma"].value()),
log_max_sigma=float(widgets["log_max_sigma"].value()),
log_num_sigma=int(widgets["log_num_sigma"].value()),
log_threshold=float(widgets["log_threshold"].value()),
dog_sigma_low=float(widgets["dog_sigma_low"].value()),
dog_sigma_high=float(widgets["dog_sigma_high"].value()),
ridge_filter=str(widgets["ridge_filter"].currentData()),
ridge_sigmas=_parsed_sigmas(widgets["ridge_sigmas"].text()),
ridge_threshold=str(widgets["ridge_threshold"].currentData()),
skeletonize=bool(widgets["skeletonize"].isChecked()),
hysteresis_low=float(widgets["hysteresis_low"].value()),
hysteresis_high=float(widgets["hysteresis_high"].value()),
unet_model_path=str(self._unet_path_edit.text()).strip(),
unet_threshold=float(widgets["unet_threshold"].value()),
)
def _build_enhance_card(self) -> Section:
"""The pre- and post-detection chain, for every mode alike.
:mod:`spacr.qt.detect_chain` is what each row means and
:data:`spacr.qt.detect_chain.CHAIN_ORDER` is the order they run in,
which is fixed and is printed at the top of the card rather than
left to be inferred from the order the rows happen to sit in.
THE FIRST STAGE IS NOT HERE. The percentile stretch is the Display
category's "Detect on the normalized image", because its two levels
are percentiles of the WHOLE FIELD and the magnifier's box is not
the field. The note says where it is rather than putting a second
switch for it on this card.
Apply enables the configured chain for image display and detection;
Compare previews it independently. "Use in Mask generation" writes
the configured chain and PSF into the Mask module's settings as the
``enhance_*`` and ``psf_*`` keys (:meth:`mask_settings`), so a plate
run applies the steps tuned here; see
:func:`spacr.psf_pipeline.prepare_chain`.
"""
from ..i18n import tr
from ..widgets.psf_controls import _PSFControls
from ..widgets.restoration_controls import _RestorationControls
card = self._settings_category(
"Image enhancement",
"Optional steps applied to what the detector reads, in one "
"fixed order. The image on disk is never changed.",
)
order = QLabel(tr(
"Order: percentile stretch (Display) → background → PSF → restoration → denoise → "
"contrast → sharpen → detect → morphology → split."))
order.setWordWrap(True)
order.setObjectName("Muted")
card.body_layout.addWidget(order)
form = QFormLayout()
self._enh_background = QComboBox()
self._enh_background.addItem("None", "none")
self._enh_background.addItem("Rolling ball", "rolling_ball")
self._enh_background.addItem("Top-hat", "tophat")
self._enh_background.setToolTip(
"Subtract the slowly varying background before anything else. "
"Rolling ball fits a surface of the radius below and takes it "
"away, which is what flattens uneven illumination; Top-hat "
"keeps only what is brighter than its surroundings within that "
"radius and is much faster. Set the radius comfortably LARGER "
"than the largest object: a radius under the object size eats "
"the objects with the background. The background is ESTIMATED "
"on a smaller copy, at the scale below, because a background is "
"by definition what varies slowly across the field and so is "
"the one thing that survives being looked at smaller.")
form.addRow("Background", self._enh_background)
self._enh_background_radius = QSpinBox()
self._enh_background_radius.setRange(1, 2000)
self._enh_background_radius.setValue(50)
self._enh_background_radius.setSuffix(" px")
self._enh_background_radius.setToolTip(
"The ball's or the top-hat disk's radius, in pixels. Larger "
"than the largest object and smaller than the scale the "
"illumination itself varies on.")
form.addRow("Background radius", self._enh_background_radius)
self._enh_background_scale = QDoubleSpinBox()
self._enh_background_scale.setDecimals(2)
self._enh_background_scale.setRange(0.10, 1.00)
self._enh_background_scale.setSingleStep(0.05)
self._enh_background_scale.setValue(0.50)
self._enh_background_scale.setToolTip(
"What fraction of full size the background is measured at. The "
"surface is then scaled back up and subtracted from the "
"full-size image, so only the ESTIMATE is smaller. On one "
"1,994 px field a rolling ball at radius 50 takes 14.5 s at "
"1.00 and about 1 s at 0.50. 1.00 is scikit-image's own answer "
"exactly, for when you want it and will wait; lower it further "
"on a very large field, and raise it if the surface is missing "
"illumination that changes over a short distance.")
form.addRow("Background scale", self._enh_background_scale)
self._psf_controls = _PSFControls(image_paths=self._current_image_paths)
form.addRow(self._psf_controls)
self._restoration_controls = _RestorationControls()
self._restoration_controls.said.connect(self._report)
form.addRow(self._restoration_controls)
self._enh_denoise = QComboBox()
self._enh_denoise.addItem("None", "none")
self._enh_denoise.addItem("Gaussian", "gaussian")
self._enh_denoise.addItem("Median", "median")
self._enh_denoise.addItem("Bilateral", "bilateral")
self._enh_denoise.addItem("Non-local means", "nlm")
self._enh_denoise.addItem(tr("Total variation"), "tv")
self._enh_denoise.setToolTip(
"Smooth the noise before the contrast step amplifies it. "
"Gaussian is a blur and softens edges with the noise; Median "
"removes speckle and keeps edges; Bilateral and Non-local means "
"keep edges better still and are much slower. HEAVY: non-local "
"means is minutes on a whole 2,000 px field, and seconds on a "
"magnifier box.")
form.addRow("Denoise", self._enh_denoise)
self._enh_denoise_strength = QDoubleSpinBox()
self._enh_denoise_strength.setDecimals(2)
self._enh_denoise_strength.setRange(0.1, 50.0)
self._enh_denoise_strength.setValue(1.0)
self._enh_denoise_strength.setToolTip(
"How much smoothing: the Gaussian's sigma in pixels, the "
"median's and the bilateral's disk radius, or the non-local "
"means' cut-off in multiples of the noise it measures.")
form.addRow("Denoise strength", self._enh_denoise_strength)
self._enh_gamma = QDoubleSpinBox()
self._enh_gamma.setDecimals(2)
self._enh_gamma.setRange(0.05, 5.0)
self._enh_gamma.setSingleStep(0.05)
self._enh_gamma.setValue(1.0)
self._enh_gamma.setToolTip(
"The exponent the intensities are raised to on 0..1. Below 1 "
"lifts the dim end, so faint objects rise out of the "
"background; above 1 pushes it down and leaves only the bright "
"ones. 1.00 is off.")
form.addRow("Gamma", self._enh_gamma)
card.body_layout.addLayout(form)
self._enh_percentile_clip = Toggle(tr("Percentile clip"))
self._enh_percentile_clip.setToolTip(tr(
"Clip the image to two percentiles of its own intensities before "
"the contrast curves, so a hot or dead pixel cannot set the range "
"they are drawn on. Nothing is stretched; intensities keep their "
"units."))
card.body_layout.addWidget(self._enh_percentile_clip)
curve_body = QWidget()
curve_form = QFormLayout(curve_body)
curve_form.setContentsMargins(0, 0, 0, 0)
self._enh_percentile_low = QDoubleSpinBox()
self._enh_percentile_low.setRange(0.0, 99.9)
self._enh_percentile_low.setValue(1.0)
self._enh_percentile_low.setToolTip(tr(
"The lower percentile of the clip, 0 to 100, below the upper."))
curve_form.addRow(tr("Clip low percentile"), self._enh_percentile_low)
self._enh_percentile_high = QDoubleSpinBox()
self._enh_percentile_high.setRange(0.1, 100.0)
self._enh_percentile_high.setValue(99.0)
self._enh_percentile_high.setToolTip(tr(
"The upper percentile of the clip, 0 to 100, above the lower."))
curve_form.addRow(tr("Clip high percentile"), self._enh_percentile_high)
self._enh_percentile_details = self._folded_rows(
curve_body, "Percentile clip settings", "make_masks/percentile_details")
card.body_layout.addWidget(self._enh_percentile_details)
self._enh_log = Toggle(tr("Logarithm"))
self._enh_log.setToolTip(tr(
"A logarithmic curve on 0..1, log(1 + gain x) / log(1 + gain): "
"it compresses the bright end and lifts the dim one, more "
"strongly near zero than a gamma below 1."))
card.body_layout.addWidget(self._enh_log)
log_body = QWidget()
log_form = QFormLayout(log_body)
log_form.setContentsMargins(0, 0, 0, 0)
self._enh_log_gain = QDoubleSpinBox()
self._enh_log_gain.setRange(0.01, 1000.0)
self._enh_log_gain.setValue(10.0)
self._enh_log_gain.setToolTip(tr(
"What the intensities are multiplied by before the logarithm. "
"Larger compresses the bright end harder."))
log_form.addRow(tr("Logarithm gain"), self._enh_log_gain)
self._enh_log_details = self._folded_rows(
log_body, "Logarithm settings", "make_masks/log_details")
card.body_layout.addWidget(self._enh_log_details)
self._enh_sqrt = Toggle(tr("Square root"))
self._enh_sqrt.setToolTip(tr(
"A square-root curve on 0..1, the curve a gamma of 0.5 draws: "
"it lifts the dim end."))
card.body_layout.addWidget(self._enh_sqrt)
self._enh_clahe = Toggle("CLAHE (local histogram equalisation)")
self._enh_clahe.setToolTip(
"Equalise the histogram inside each tile rather than over the "
"whole field, with a limit on how much any one level may be "
"stretched. It is what brings out objects in a dim corner "
"without saturating the bright middle. It also amplifies "
"noise in empty tiles, which is what the clip limit is for.")
card.body_layout.addWidget(self._enh_clahe)
clahe_body = QWidget()
clahe_form = QFormLayout(clahe_body)
clahe_form.setContentsMargins(0, 0, 0, 0)
self._enh_clahe_tile = QSpinBox()
self._enh_clahe_tile.setRange(8, 1024)
self._enh_clahe_tile.setValue(64)
self._enh_clahe_tile.setSuffix(" px")
self._enh_clahe_tile.setToolTip(
"The side of one tile, in pixels. Comfortably larger than one "
"object and smaller than the scale the illumination varies on; "
"a tile the size of one object equalises the object against "
"itself.")
clahe_form.addRow("CLAHE tile", self._enh_clahe_tile)
self._enh_clahe_clip = QDoubleSpinBox()
self._enh_clahe_clip.setDecimals(3)
self._enh_clahe_clip.setRange(0.001, 1.0)
self._enh_clahe_clip.setSingleStep(0.005)
self._enh_clahe_clip.setValue(0.01)
self._enh_clahe_clip.setToolTip(
"How much contrast a tile may be given, 0 to 1. Higher is more "
"contrast and more amplified noise in tiles that hold only "
"background.")
clahe_form.addRow("CLAHE clip limit", self._enh_clahe_clip)
self._enh_clahe_details = self._folded_rows(
clahe_body, "CLAHE settings", "make_masks/clahe_details")
card.body_layout.addWidget(self._enh_clahe_details)
self._enh_equalize = Toggle("Histogram equalisation (whole image)")
self._enh_equalize.setToolTip(
"Flatten the histogram of the whole region at once, so every "
"brightness band ends up with the same number of pixels. It is "
"the strongest of the contrast steps and the least respectful "
"of the data: a field that is mostly background has its "
"background stretched across half the range.")
card.body_layout.addWidget(self._enh_equalize)
self._enh_sharpen = Toggle("Unsharp mask")
self._enh_sharpen.setToolTip(
"Add back a high-pass copy of the image, which makes edges "
"steeper and helps a threshold land on the boundary rather "
"than in the halo. Too much amount puts a bright rim around "
"every object and a dark moat outside it.")
card.body_layout.addWidget(self._enh_sharpen)
sharpen_body = QWidget()
sharpen_form = QFormLayout(sharpen_body)
sharpen_form.setContentsMargins(0, 0, 0, 0)
self._enh_sharpen_radius = QDoubleSpinBox()
self._enh_sharpen_radius.setDecimals(2)
self._enh_sharpen_radius.setRange(0.1, 50.0)
self._enh_sharpen_radius.setValue(1.0)
self._enh_sharpen_radius.setSuffix(" px")
self._enh_sharpen_radius.setToolTip(
"The blur the mask is built from, in pixels: about the scale "
"of the edges to sharpen.")
sharpen_form.addRow("Sharpen radius", self._enh_sharpen_radius)
self._enh_sharpen_amount = QDoubleSpinBox()
self._enh_sharpen_amount.setDecimals(2)
self._enh_sharpen_amount.setRange(0.0, 10.0)
self._enh_sharpen_amount.setValue(1.0)
self._enh_sharpen_amount.setToolTip(
"How much of the mask is added back. 1 is a normal sharpen; "
"above 2 the halos start to become objects of their own.")
sharpen_form.addRow("Sharpen amount", self._enh_sharpen_amount)
self._enh_sharpen_details = self._folded_rows(
sharpen_body, "Unsharp mask settings", "make_masks/sharpen_details")
card.body_layout.addWidget(self._enh_sharpen_details)
after_form = QFormLayout()
self._enh_morphology = QComboBox()
self._enh_morphology.addItem("None", "none")
self._enh_morphology.addItem("Opening (separate)", "open")
self._enh_morphology.addItem("Closing (join)", "close")
self._enh_morphology.addItem("Opening then closing", "open_close")
self._enh_morphology.setToolTip(
"Applied to what the detector found, not to the image. Opening "
"erases what is thinner than the radius, which breaks two "
"objects joined by a bridge; Closing fills what is thinner, "
"which joins one object broken into pieces.")
after_form.addRow("Morphology", self._enh_morphology)
self._enh_morphology_radius = QSpinBox()
self._enh_morphology_radius.setRange(1, 50)
self._enh_morphology_radius.setValue(1)
self._enh_morphology_radius.setSuffix(" px")
self._enh_morphology_radius.setToolTip(
"The disk the opening or closing uses, in pixels. It is a "
"length: a bridge narrower than twice this is broken, a gap "
"narrower than twice this is filled.")
after_form.addRow("Morphology radius", self._enh_morphology_radius)
card.body_layout.addLayout(after_form)
self._enh_split = Toggle("Split objects that touch")
self._enh_split.setToolTip(
"Cut an object with two centres in two, at the ridge between "
"them, on the distance to the background. It is the Otsu "
"category's own split offered to every other method. The Otsu "
"mode is not affected by this box and keeps using its own: "
"applying both would join what Otsu had just separated and cut "
"it again.")
card.body_layout.addWidget(self._enh_split)
from ..i18n import tr
self._enh_heavy = QLabel()
self._enh_heavy.setWordWrap(True)
card.body_layout.addWidget(self._enh_heavy)
actions = QHBoxLayout()
self._btn_compare = QPushButton("Compare raw and enhanced")
self._btn_compare.setCursor(Qt.PointingHandCursor)
self._btn_compare.setToolTip(tr(
"Preview the configured enhancements beside the unenhanced image, "
"for the magnifier's box when it has one and for the whole "
"field otherwise, so every step of the chain can be judged by "
"looking at what it did. Processing runs in the background. "
"Cancel closes the comparison; a running filter finishes without "
"displaying its result. Whole-field normalization is applied "
"before cropping, as it is for detection."))
self._btn_compare.clicked.connect(self._on_compare_enhanced)
actions.addWidget(self._btn_compare)
self._btn_apply = QPushButton(tr("Apply"))
self._btn_apply.setCheckable(True)
self._btn_apply.setCursor(Qt.PointingHandCursor)
self._btn_apply.setToolTip(tr(
"Apply these enhancements to the displayed image and subsequent "
"detections. Blue means active. Click again to use unenhanced "
"input while keeping these settings and existing masks. "
"Measurements retain the original image values."))
self._btn_apply.setStyleSheet(
"QPushButton:checked { background-color: #2563eb; color: #ffffff; "
"border: 1px solid #60a5fa; }")
self._enh_show = self._btn_apply
self._btn_apply.toggled.connect(self._on_show_enhanced)
actions.addWidget(self._btn_apply)
self._btn_to_mask = QPushButton(tr("Use in Mask generation"))
self._btn_to_mask.setCursor(Qt.PointingHandCursor)
self._btn_to_mask.setToolTip(tr(
"Write the configured chain and PSF into the Mask module's "
"Image Enhancement and Point Spread Function settings, so a "
"plate run applies these steps to every selected channel after "
"illumination correction and before normalization. Morphology "
"and split reshape a detector's labels and stay here."))
self._btn_to_mask.setObjectName("MakeMasksUseInMaskGeneration")
self._btn_to_mask.clicked.connect(self._send_chain_to_mask)
actions.addWidget(self._btn_to_mask)
card.body_layout.addLayout(actions)
from ..preferences import _apply_alpha_widgets
_apply_alpha_widgets(self._btn_to_mask)
for widget in (self._enh_background, self._enh_denoise,
self._enh_morphology):
widget.currentIndexChanged.connect(self._on_chain_changed)
for widget in (self._enh_background_radius,
self._enh_background_scale, self._enh_gamma,
self._enh_denoise_strength, self._enh_clahe_tile,
self._enh_clahe_clip, self._enh_sharpen_radius,
self._enh_sharpen_amount,
self._enh_morphology_radius,
self._enh_percentile_low, self._enh_percentile_high,
self._enh_log_gain):
widget.valueChanged.connect(self._on_chain_changed)
for widget in (self._enh_clahe, self._enh_equalize,
self._enh_sharpen, self._enh_split,
self._enh_percentile_clip, self._enh_log,
self._enh_sqrt):
widget.toggled.connect(self._on_chain_changed)
self._psf_controls.changed.connect(self._on_chain_changed)
self._restoration_controls.changed.connect(self._on_chain_changed)
self._on_chain_changed()
return card
@staticmethod
def _folded_rows(body: QWidget, name: str, key: str) -> QWidget:
"""Put a step's parameter rows under a fold that starts shut (item 509).
The step's own switch stays visible above it; the fold remembers
being opened.
"""
from ..widgets.collapsible_splitter import FoldSection
section = FoldSection(body, name, persist_key=key, follow_body=False,
stretch=0, folded=True)
section.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Maximum)
return section
def _current_image_paths(self) -> list:
"""The open field's file, which "Infer from images…" reads first."""
files = getattr(self, "_image_files", None) or []
index = getattr(self, "_current_index", 0)
if not files or not 0 <= index < len(files):
return []
return [os.path.join(self._folder, files[index])]
def _detect_chain(self) -> "detect_chain.Chain":
"""The applied enhancement chain, or no changes while Apply is off."""
if getattr(self, '_mag_mode', None) is not None and self._mag_mode.currentData() == cpu_modes.PUNCTA:
return detect_chain.NO_CHAIN
button = getattr(self, "_btn_apply", None)
if button is None or not button.isChecked():
return detect_chain.NO_CHAIN
chain = self._enhancement_chain()
if getattr(self, '_mag_mode', None) is not None and self._mag_mode.currentData() == cpu_modes.SECONDARY:
chain = chain._replace(morphology='none', split=False)
return chain
def _enhancement_chain(self) -> "detect_chain.Chain":
"""Configured enhancement settings, available to Compare before applying."""
if not hasattr(self, "_enh_background"):
return detect_chain.NO_CHAIN
return detect_chain.Chain(
background=str(self._enh_background.currentData()),
background_radius=int(self._enh_background_radius.value()),
background_scale=float(self._enh_background_scale.value()),
denoise=str(self._enh_denoise.currentData()),
denoise_strength=float(self._enh_denoise_strength.value()),
gamma=float(self._enh_gamma.value()),
percentile_clip=bool(self._enh_percentile_clip.isChecked()),
percentile_low=float(self._enh_percentile_low.value()),
percentile_high=float(self._enh_percentile_high.value()),
log=bool(self._enh_log.isChecked()),
log_gain=float(self._enh_log_gain.value()),
sqrt=bool(self._enh_sqrt.isChecked()),
clahe=bool(self._enh_clahe.isChecked()),
clahe_tile=int(self._enh_clahe_tile.value()),
clahe_clip=float(self._enh_clahe_clip.value()),
equalize=bool(self._enh_equalize.isChecked()),
sharpen=bool(self._enh_sharpen.isChecked()),
sharpen_radius=float(self._enh_sharpen_radius.value()),
sharpen_amount=float(self._enh_sharpen_amount.value()),
morphology=str(self._enh_morphology.currentData()),
morphology_radius=int(self._enh_morphology_radius.value()),
split=bool(self._enh_split.isChecked()),
**self._psf_controls._chain_fields(),
**self._restoration_controls._chain_fields(),
)
[docs]
def mask_settings(self) -> dict:
"""The configured chain and PSF as the Mask module's settings.
:func:`spacr.qt.detect_chain.chain_settings` writes the image steps
as ``enhance_*`` keys and the PSF controls write the ``psf_*`` keys,
which is exactly what :func:`spacr.psf_pipeline.prepare_chain` reads
back, so the chain a plate run applies is the chain configured here
-- whether or not Apply is on, since Apply is this screen's switch.
"""
settings = detect_chain.chain_settings(self._enhancement_chain())
controls = getattr(self, "_psf_controls", None)
if controls is not None:
settings.update(controls.mask_settings())
return settings
def _send_chain_to_mask(self) -> None:
"""Write :meth:`mask_settings` into the Mask module and show it.
The Mask screen gets the values if it is built, and is built for
them otherwise; a Timelapse screen already built gets them too, since
it runs the same preprocessing. Standalone, with no application
window around this screen, there is nowhere to write and the status
line says so.
"""
from ..i18n import tr
settings = self.mask_settings()
window = self.window()
screens = getattr(window, "_screens", None)
written = []
for key in ("mask", "timelapse"):
screen = screens.get(key) if isinstance(screens, dict) else None
apply = getattr(screen, "apply_settings_dict", None)
if callable(apply):
apply(settings)
written.append(key)
rebuild = getattr(window, "rebuild_app_screen", None)
if "mask" not in written and callable(rebuild):
rebuild("mask", settings)
written.append("mask")
if not written:
self._status_label.setText(tr(
"Open spaCR's Mask module to receive the enhancement chain."))
return
self._status_label.setText(tr(
"Enhancement chain written to the Mask settings: {steps}.",
steps=detect_chain.describe(self._enhancement_chain())
or tr("every step off")))
navigate = getattr(window, "_on_nav_selected", None)
if callable(navigate):
navigate("mask")
def _chain_provenance(self) -> dict:
"""The chain as a mask's ledger entry records it.
The percentile stretch is read from the Display category, because
it is the chain's first stage and lives there; see
:meth:`_build_enhance_card`.
"""
return detect_chain.provenance(
self._detect_chain(),
percentile_stretch=bool(self._canvas.detect_on_normalized) and self._mag_mode.currentData() != cpu_modes.PUNCTA)
def _on_chain_changed(self, *_args) -> None:
"""A chain step changed: warn about the slow ones and re-detect."""
from ..i18n import tr
chain = self._detect_chain()
heavy = detect_chain.heavy_steps(self._enhancement_chain())
self._enh_heavy.setText(
tr("Heavy: {steps}. A whole-image run shows progress and can be "
"cancelled.", steps=", ".join(tr(step) for step in heavy))
if heavy else "")
self._canvas.enhance_chain = chain
self._canvas._enhance_cancel.set()
self._canvas._enhance_asked = None
self._canvas.refresh()
self._on_magnifier_context_changed()
def _on_show_enhanced(self, on: bool) -> None:
"""Apply or bypass enhancements for display and subsequent detections."""
from ..i18n import tr
self._canvas.enhance_display = bool(on)
if on:
self._canvas._enhance_failure = None
self._on_chain_changed()
self._magnifier.refresh_view()
self._status_label.setText(
tr("Showing the enhanced image the detector reads.") if on else
tr("Showing the image as loaded."))
def _on_compare_enhanced(self) -> None:
"""Open the raw image beside the enhanced one, and say what ran.
The magnifier's box when there is one, because that is the region a
curator is judging and the region every step ran on live; the whole
field otherwise. A worker prepares a captured snapshot; closing the
dialog discards it, and changed fields/settings reject late results.
"""
from ..i18n import tr
image = self._canvas.image
if image is None:
return
chain = self._enhancement_chain()
box = self._magnifier.compare_box()
self._cancel_comparison()
old_dialog = getattr(self, '_compare_dialog', None)
if old_dialog is not None:
old_dialog.close()
self._comparison_serial += 1
request = _CompareRequest(
key=(self._comparison_serial, self._comparison_context()),
image=np.array(self._canvas.displayed_source(), copy=True),
box=tuple(box), chain=chain,
normalized=bool(self._canvas.detect_on_normalized),
percentiles=(float(self._canvas.norm_lo), float(self._canvas.norm_hi)),
cancelled=threading.Event())
self._comparison_request = request
self._compare_dialog = _ComparePreview(
request.image[box[1]:box[3], box[0]:box[2]], None,
tr("Preparing enhanced image… Cancel closes this comparison; a running filter finishes in the background."), self)
self._compare_dialog.finished.connect(self._cancel_comparison)
self._compare_dialog.show()
if self._comparison_worker is None:
self._comparison_worker = _NewestRequestWorker(
_compare_picture_for, self._comparison_done, name='spacr-compare')
self._comparison_worker.submit(request)
def _comparison_context(self):
"""Settings and field identity whose comparison remains meaningful."""
canvas = self._canvas
return (self._load_token, id(canvas.image), bool(canvas.invert_display),
bool(canvas.detect_on_normalized), float(canvas.norm_lo),
float(canvas.norm_hi), self._enhancement_chain())
def _cancel_comparison(self, *_args):
"""Discard a comparison without waiting for an active native filter."""
request, self._comparison_request = self._comparison_request, None
if request is not None:
request.cancelled.set()
if self._comparison_worker is not None:
self._comparison_worker.drop_waiting()
def _comparison_done(self, request, result, error):
"""Marshal a worker completion back to the owning Qt screen."""
try:
self._comparison_delivered.emit((request, result, error))
except RuntimeError:
pass
def _take_comparison(self, payload):
"""Only show results from the still-open, unchanged comparison."""
from ..i18n import tr
request, result, error = payload
if request is not self._comparison_request or request.cancelled.is_set():
return
self._comparison_request = None
if request.key[1] != self._comparison_context():
self._compare_dialog._show_result(None, tr(
"The image or enhancement settings changed. Choose Compare again."))
return
if error is not None:
self._compare_dialog._show_result(None, tr(
"Image enhancement failed: {error}", error=str(error)))
return
steps = " → ".join(tr(name) for name in detect_chain.step_names(
request.chain, percentile_stretch=request.normalized))
self._compare_dialog._show_result(
result, steps or tr("No enhancement step is switched on."))
def _sync_otsu_controls(self, *_args) -> None:
"""Leave enabled only the threshold boxes that answer anything.
A control that is being read and a control that is being ignored
look identical, and a curator cannot tell which is which. So:
the foreground class is a choice only once there is more than one
band to choose from, the local window only matters while the local
threshold is on, and the two cannot both be on -- a level per window
and a split into several bands have no joint meaning, and the engine
refuses the pair rather than quietly dropping one.
"""
mode = canonical_magnifier_mode(getattr(self._magnifier, "mode", None))
multi = mode == cpu_modes.MULTIOTSU
plain = mode == "otsu"
window_family = mode in ("sauvola", "niblack")
if multi and int(self._otsu_classes.value()) < 3:
self._otsu_classes.setValue(3)
self._otsu_local_k.setEnabled(window_family)
self._otsu_local.setEnabled(plain)
self._otsu_local_k_label.setVisible(window_family)
self._otsu_local_k.setVisible(window_family)
classes = int(self._otsu_classes.value())
local = bool(self._otsu_local.isChecked()) and plain
bands = (plain or multi) and not local
self._otsu_classes.setEnabled(bands)
self._otsu_foreground.setEnabled(bands and classes > 2)
self._otsu_foreground.setRange(0, max(1, classes - 1))
if classes > 2 and self._otsu_foreground.value() > classes - 1:
self._otsu_foreground.setValue(classes - 1)
self._otsu_window.setEnabled(local or window_family)
self._otsu_bright.setEnabled(not bands or classes == 2)
def _otsu_settings(self) -> dict:
"""What the Otsu category says, as :func:`_otsu_instances` keywords.
One reader for the button and the magnifier, so a setting added to
the category reaches both by being read here once.
Multi-Otsu reads the class count and foreground band in both
magnifier scopes through :meth:`_magnifier_context`. Its local
Otsu toggle is disabled and ignored because one threshold per
window cannot be combined with multiple intensity bands. Plain
Otsu's magnifier keeps its existing region-specific algorithm;
that mode's class count and Local Otsu toggle remain button-only.
Every other threshold ignores these saved Otsu settings and uses
two classes with its named algorithm. Disabled controls retain
their values for a later return to Otsu or Multi-Otsu.
"""
mode = canonical_magnifier_mode(self._magnifier.mode)
local = bool(self._otsu_local.isChecked()) and mode == "otsu"
bands = mode in ("otsu", cpu_modes.MULTIOTSU) and not local
return {
"correction": float(self._otsu_correction.value()),
"smoothing": float(self._otsu_smoothing.value()),
"fill_holes": bool(self._otsu_fill_holes.isChecked()),
"split_touching": bool(self._otsu_split.isChecked()),
"exclude_border": bool(self._otsu_exclude_border.isChecked()),
"classes": int(self._otsu_classes.value()) if bands else 2,
"foreground_class": int(self._otsu_foreground.value()) if bands else 1,
"local": local,
"window": int(self._otsu_window.value()),
}
def _detect_min_area(self) -> int:
"""Smallest object a detection may keep, in pixels.
The same box the Remove-small button reads, because they are the
same judgement: an object this size is debris either way, and
having Cellpose keep what the next button would delete would be
two answers to one question.
"""
return int(self._min_area.value())
def _cellpose_model(self, model_name: str):
"""Load ``model_name`` once and keep it for the rest of the session."""
with _CELLPOSE_LOCK:
if model_name not in self._cp_loaded:
self._cp_loaded[model_name] = load_cellpose_model(model_name)
return self._cp_loaded[model_name]
def _sync_model_choices(self) -> None:
"""Add any model the live Cellpose reports that the combo has not.
The combo is built while the screen is, and importing Cellpose
costs about two and a half seconds because it pulls in torch — so
:func:`spacr.settings.cellpose_model_choices` answers from its
fallback list until something has actually imported it. The first
detect run is that something, and it is the first moment the live
list can be had for free.
"""
from ...settings import cellpose_model_choices
for name in cellpose_model_choices():
if self._cp_model.findData(name) < 0:
self._cp_model.addItem(name, name)
def _fill_zoo_models(self) -> None:
"""List every Cellpose model in the model zoo in the Model box.
A model on this machine is listed by its zoo key and carries its path,
which is what :func:`load_cellpose_model` loads. One that is not
downloaded is listed greyed, carrying no path and its zoo entry under
:data:`_ZOO_PENDING_ROLE`: choosing it downloads it
(:meth:`_on_model_activated`) rather than selecting it, so the box
never rests on a model there is nothing to load for. Called again
after the picker closes or a download ends, the zoo rows are rebuilt
-- so a model just downloaded becomes selectable -- and the model
chosen stays chosen, without a change signal when it did not change.
Each Cellpose-DINO model downloaded follows, as
``cellpose_dino:<path>`` under "Cellpose-DINO · <file>" (item 525),
which :func:`load_cellpose_model` runs in its backend, and then
each model of an installed StarDist, InstanSeg or Omnipose backend
(:func:`_zoo_prefixed_models`, items 551-553).
"""
from ..i18n import tr
from ..model_install import UNINSTALLED_GREY
combo = self._cp_model
chosen = combo.currentData()
combo.blockSignals(True)
try:
for index in reversed(range(combo.count())):
if combo.itemData(index, _ZOO_ROLE):
combo.removeItem(index)
for key, path, entry in _zoo_cellpose_models():
fetched = self._cp_fetched.get(key)
if not path and fetched and os.path.isfile(fetched):
path = fetched
if path and combo.findData(path) >= 0:
continue
if path:
combo.addItem(key, path)
combo.setItemData(combo.count() - 1, path, Qt.ToolTipRole)
else:
combo.addItem(tr("{name} (not downloaded)", name=key))
row = combo.count() - 1
combo.setItemData(row, entry, _ZOO_PENDING_ROLE)
combo.setItemData(row, QBrush(UNINSTALLED_GREY),
Qt.ForegroundRole)
combo.setItemData(row, tr(
"{name} is not downloaded. Choosing it downloads it "
"from the model zoo and selects it.", name=key),
Qt.ToolTipRole)
combo.setItemData(combo.count() - 1, True, _ZOO_ROLE)
for value, label in (_zoo_cellpose_dino_models()
+ _zoo_prefixed_models()):
if combo.findData(value) >= 0:
continue
combo.addItem(label, value)
row = combo.count() - 1
combo.setItemData(row, value, Qt.ToolTipRole)
combo.setItemData(row, True, _ZOO_ROLE)
index = combo.findData(chosen) if chosen is not None else -1
combo.setCurrentIndex(max(index, 0))
finally:
combo.blockSignals(False)
self._cp_last_model = combo.currentIndex()
if combo.currentData() != chosen:
combo.currentIndexChanged.emit(combo.currentIndex())
def _keep_model_loadable(self, index: int) -> None:
"""Never let the Model box rest on a model that is not downloaded.
A click is handled by :meth:`_on_model_activated`; this catches the
other ways a row becomes current -- the keyboard, the wheel -- and
puts the box back on the last model that can be loaded.
"""
combo = self._cp_model
if index >= 0 and combo.itemData(index, _ZOO_PENDING_ROLE) is not None:
back = self._cp_last_model
if back == index or not 0 <= back < combo.count():
back = 0
combo.setCurrentIndex(back)
return
self._cp_last_model = index
def _on_model_activated(self, index: int) -> None:
"""A person chose a Model row: download it if it is not here yet."""
entry = self._cp_model.itemData(index, _ZOO_PENDING_ROLE)
if entry is not None:
self.download_zoo_model(entry)
[docs]
def download_zoo_model(self, entry) -> bool:
"""Ask, then download a zoo Cellpose model and select it when it lands.
The download goes through :func:`spacr.model_zoo.install` on a worker
thread (:class:`spacr.qt.model_install.CheckpointDownload`), into the
folder the Model zoo picker uses, so either route finds the file the
other fetched. The bar under the Model row shows the bytes as they
arrive, and the screen stays usable. A model that publishes no
checksum is downloaded only after the user has been told spaCR
cannot then check it.
:param entry: the zoo's record of the model.
:returns: True when a download was started.
"""
from ..i18n import tr
from ..model_install import CheckpointDownload, human_bytes
from ..widgets.model_zoo_picker import remembered_model_dir
name = str(getattr(entry, "key", "") or getattr(entry, "name", ""))
running = self._cp_download
if running is not None and running.is_running():
self._status_label.setText(tr(
"A model is already downloading; wait for it to finish."))
return False
folder = remembered_model_dir()
size = human_bytes(getattr(entry, "size_bytes", 0))
unverified = not str(getattr(entry, "sha256", "") or "")
text = tr("Download {name}{size} into {folder}?", name=name,
size=f" ({size})" if size else "", folder=folder)
if unverified:
text += "\n\n" + tr(
"This model publishes no checksum, so spaCR cannot tell a "
"truncated or substituted file from the real one.")
if not self._confirm(tr("Download {name}?", name=name), text):
return False
try:
os.makedirs(folder, exist_ok=True)
except OSError as exc:
self._warn(tr("Download failed"), str(exc))
return False
job = CheckpointDownload(entry, folder, unverified=unverified)
self._cp_download = job
job.progressed.connect(self._on_model_download_progress)
job.finished.connect(self._on_model_downloaded)
self._cp_download_bar.setRange(0, 0)
self._cp_download_bar.setFormat("")
self._cp_download_bar.set_detail(tr("Downloading {name}…", name=name))
self._cp_download_bar.show()
self._status_label.setText(tr(
"Downloading {name} in the background.", name=name))
return job.start()
def _on_model_download_progress(self, done: int, total: int) -> None:
"""Move the download bar; a server that sent no size keeps it busy."""
bar = self._cp_download_bar
if total > 0:
from ... import model_zoo as zoo
bar.setRange(0, 1000)
bar.setValue(int(1000 * min(done, total) / total))
bar.setFormat(f"{zoo._human_bytes(min(done, total))} / "
f"{zoo._human_bytes(total)} (%p%)")
def _on_model_downloaded(self, worked: bool, message: str) -> None:
"""Select the model just downloaded, or say why it did not arrive."""
from ..i18n import tr
job, self._cp_download = self._cp_download, None
self._cp_download_bar.hide()
name = str(getattr(getattr(job, "entry", None), "key", "") or "")
if not worked:
if message != "cancelled":
self._warn(tr("Download failed"), message)
return
if name:
self._cp_fetched[name] = message
self._fill_zoo_models()
combo = self._cp_model
target = os.path.realpath(message)
index = next((row for row in range(combo.count())
if isinstance(combo.itemData(row), str)
and os.path.realpath(combo.itemData(row)) == target),
-1)
if index < 0:
combo.addItem(name or os.path.basename(message) or message,
message)
index = combo.count() - 1
combo.setItemData(index, message, Qt.ToolTipRole)
combo.setCurrentIndex(index)
self._status_label.setText(tr(
"{name} is downloaded and selected.", name=name or message))
def _choose_cellpose_model_from_zoo(self) -> Optional[str]:
"""Open the model zoo on its Cellpose models and select what is picked.
Cellpose-SAM and Cellpose 3 models, not the zoo's YOLO well detector,
which no Cellpose can load. A Cellpose 3 model comes back as
``cellpose3:<name or path>`` -- a bioimage.io Cellpose 3 checkpoint
among them -- and :func:`load_cellpose_model` runs it through the
Cellpose 3 backend, as Mask generation does. A picked model the list
does not hold is added to it, under its file name.
:returns: the model setting chosen, or None when the picker was
cancelled.
"""
from ..i18n import tr
from ..widgets import model_zoo_picker
from ... import model_zoo
from ..._segmentation_backends import (_SPECS, _cellpose3_choice,
_cellpose_dino_choice,
_prefixed_backend,
_prefixed_choice)
path = model_zoo_picker.choose_model(
self, kinds=model_zoo._mask_model_kinds())
if not path:
return None
path = str(path)
self._fill_zoo_models()
index = self._cp_model.findData(path)
if index < 0:
chosen = _cellpose3_choice(path)
dino = _cellpose_dino_choice(path)
if chosen is not None:
label = tr("Cellpose 3 · {model}",
model=os.path.basename(chosen) or chosen)
elif dino is not None:
label = tr("Cellpose-DINO · {model}",
model=os.path.basename(dino) or dino)
elif _prefixed_backend(path) is not None:
backend = _prefixed_backend(path)
model = _prefixed_choice(backend, path)
label = tr("{backend} · {model}",
backend=_SPECS[backend].label,
model=os.path.basename(model) or model)
else:
label = os.path.basename(path) or path
self._cp_model.addItem(label, path)
self._cp_model.setItemData(self._cp_model.count() - 1, path,
Qt.ToolTipRole)
index = self._cp_model.count() - 1
self._cp_model.setCurrentIndex(index)
return path
def _show_intermediates(self, cellprob, flow) -> None:
"""Put one run's probability map and flow field on their tabs."""
if cellprob is None:
self._prob_pane.clear_view()
else:
self._prob_pane.show_rgb(cellprob_heatmap(cellprob))
if flow is None:
self._flow_pane.clear_view()
else:
self._flow_pane.show_rgb(flow)
[docs]
def run_cellpose(self) -> int:
"""Segment the open field with the chosen model; objects found.
The two panes are filled BEFORE the mask is touched, and they are
filled even when the run found nothing at all. A run that returns
an empty mask is exactly the run whose probability map you need to
see: it says whether the network found nothing, or found plenty
and the threshold threw it away.
This programmatic method is synchronous and returns the object
count. The toolbar uses a background worker instead, taking a
snapshot and discarding results after field changes or mask edits.
If toolbar detection is already running, this method returns zero
without starting a second run.
With Invert on the model is given the INVERTED field
(:meth:`_detector_image`), and the status
line and the ledger entry both say so.
"""
if self._detection_request is not None:
return 0
if self._canvas.image is None or self._canvas.mask is None:
self._status_label.setText(
"Open a folder before running Object detection.")
return 0
model_name = self._cp_model.currentData() or "cpsam"
app = QApplication.instance()
self._btn_cellpose.setEnabled(False)
self._status_label.setText(
f"Object detection ({model_name}) running…")
if app is not None:
app.setOverrideCursor(Qt.WaitCursor)
app.processEvents()
try:
with _CELLPOSE_LOCK:
labels, cellprob, flow = cellpose_detect(
self._detector_image(),
self._cellpose_model(model_name),
diameter=int(self._cp_diameter.value()),
normalize=bool(self._cp_normalize.isChecked()),
flow_threshold=float(self._cp_flow.value()),
cellprob_threshold=float(self._cp_cellprob.value()),
min_size=self._detect_min_area(),
)
except Exception as exc:
LOG.exception("Object detection failed")
self._warn("Object detection failed", str(exc))
return 0
finally:
if app is not None:
app.restoreOverrideCursor()
self._btn_cellpose.setEnabled(True)
labels = detect_chain.finish(labels, self._detect_chain(),
intensity=self._detector_image())
details = dict(model=model_name, invert=bool(self._cp_invert.isChecked()),
cellprob_threshold=float(self._cp_cellprob.value()),
flow_threshold=float(self._cp_flow.value()),
diameter=int(self._cp_diameter.value()),
min_size=self._detect_min_area(), **self._chain_provenance())
return self._apply_detection((labels, cellprob, flow),
self._combine_mode.currentData(), details)
def _apply_detection(self, result, mode, details) -> int:
"""Commit one accepted result and its captured provenance on Qt's thread."""
labels, cellprob, flow = result
self._show_intermediates(cellprob, flow)
self._sync_model_choices()
found = int(labels.max()) if labels.size else 0
if not found:
self._status_label.setText(
"Object detection found no objects — the mask is "
"unchanged. The "
"Cell probability tab shows what it had to work with.")
return 0
try:
out = engine.combine_masks(self._canvas.mask, labels, mode)
except Exception as exc:
self._warn("Object detection failed", str(exc))
return 0
changed = self._pixels_changed(out)
self._canvas.mask = out
self._canvas.refresh()
self._record("detect", mode, changed, method="cellpose",
n_objects=found, **details)
self._history.push(out)
self._refresh_history_buttons()
inverted = (" from the INVERTED image"
if details['invert'] else "")
model_name = details['model']
self._status_label.setText(
f"Object detection ({model_name}){inverted} found {found} "
f"object(s) — {mode}d into the mask. See the Cell probability "
"and Flows tabs."
)
return found
def _on_detect_cellpose(self):
"""Capture the field/settings and start detection without blocking Qt."""
from ..i18n import tr
if self._detection_request is not None:
return
if self._canvas.image is None or self._canvas.mask is None:
self._status_label.setText(tr("Open a folder before running Object detection."))
return
parameters = dict(diameter=int(self._cp_diameter.value()),
normalize=bool(self._cp_normalize.isChecked()),
flow_threshold=float(self._cp_flow.value()),
cellprob_threshold=float(self._cp_cellprob.value()),
min_size=self._detect_min_area())
model = self._cp_model.currentData() or 'cpsam'
request = dict(image=np.array(self._canvas.image, copy=True),
image_reference=self._canvas.image, token=self._load_token,
mask=np.array(self._canvas.mask, copy=True), model=model,
parameters=parameters, chain=self._detect_chain(),
invert=bool(self._cp_invert.isChecked()),
percentiles=(float(self._canvas.norm_lo), float(self._canvas.norm_hi))
if self._canvas.detect_on_normalized else None,
mode=self._combine_mode.currentData(),
details=dict(model=model, invert=bool(self._cp_invert.isChecked()),
**{key: value for key, value in parameters.items() if key != 'normalize'},
**self._chain_provenance()))
self._detection_request = request
self._btn_cellpose.setEnabled(False)
if _diameter_zero_estimates(model) and not parameters.get('diameter'):
self._report(_cellpose3_auto_diameter_note(), "warning")
self._status_label.setText(tr("Object detection ({model}) running…", model=model))
if self._detection_worker is None:
self._detection_worker = _NewestRequestWorker(
partial(_detect_cellpose_snapshot, models=self._cp_loaded),
self._detection_done, name='spacr-object-detection')
self._detection_worker.submit(request)
def _detection_done(self, request, result, error) -> None:
"""Send completion to Qt while tolerating a screen already destroyed."""
try:
self._detection_delivered.emit((request, result, error))
except RuntimeError:
pass
def _take_detection(self, payload) -> None:
"""Reject stale field/mask results before changing any editor state."""
from ..i18n import tr
request, result, error = payload
if request is not self._detection_request:
return
self._detection_request = None
self._btn_cellpose.setEnabled(True)
if (request['token'] != self._load_token
or request['image_reference'] is not self._canvas.image
or not np.array_equal(request['image'], self._canvas.image, equal_nan=True)
or not np.array_equal(request['mask'], self._canvas.mask)):
self._status_label.setText(tr(
"Detection result discarded because the field or mask changed. Run detection again to use the current field."))
return
if error is not None:
self._status_label.setText(tr("Object detection failed"))
self._warn(tr("Object detection failed"), str(error))
return
try:
self._apply_detection(result, request['mode'], request['details'])
except Exception as exc:
LOG.exception("Object detection result could not be applied")
self._warn(tr("Object detection failed"), str(exc))
def _build_magnifier_card(self) -> Section:
"""The live magnifier's settings, and the toggle that turns it on.
The toggle goes in the tool row beside Object detection, because it
has to stay reachable with the settings hidden; the four settings the
request named -- mode, size, zoom, sensitivity -- and the overlap rule
go here, with what is segmented (the region under the mouse or the
whole image once), whether objects cut by the box are offered, and
the progress and Cancel of a whole-image run.
This subsection is nested inside Object detection. The shared method
selector above it drives the detect buttons and magnifier workers.
Min area is still Object operations', for the same reason it
always was. No value here persists between sessions, like every
other setting on this panel; only which categories are folded does.
"""
magnifier = self._magnifier
card = self._settings_category(
"Magnification settings",
"Segments the region under the mouse, or the whole image "
"once, and shows its objects magnified. A click adds "
"objects to the mask.",
)
form = QFormLayout()
#: Kept empty: the install sentence used to live on the panel, and
#: now the greyed Mode row offers the install itself.
self._mag_install_notes = {}
self._mag_scope = QComboBox()
self._mag_scope.addItem("Region under the mouse", "region")
self._mag_scope.addItem("Whole image", "image")
self._mag_scope.setToolTip(
"What the model segments. Region under the mouse runs it on the "
"box as the mouse moves, and a click adds every object the box "
"outlines. Whole image runs it once on the entire image in the "
"background; the box then shows those objects, a click adds the "
"object under it, and a right-click removes the mask object under "
"it. Changing a setting the model reads discards the whole-image "
"objects and segments the image again.")
self._mag_scope.currentIndexChanged.connect(
lambda _index: self._on_magnifier_scope(
self._mag_scope.currentData()))
form.addRow(QLabel("Segment"), self._mag_scope)
self._mag_size = QSpinBox()
self._mag_size.setRange(*magnifier.size_range())
self._mag_size.setSingleStep(16)
self._mag_size.setValue(_MAGNIFIER_SIZE)
self._mag_size.setToolTip(
"Side of the square box, in image pixels, up to the open image's "
"own height or width. Under Region under the mouse it is also the "
"region the model segments, so make it wider than the largest "
"object you want to add. Shift + mouse wheel changes it while the "
"magnifier is on.")
self._mag_size.valueChanged.connect(magnifier.set_size)
magnifier.size_changed.connect(self._mag_size.setValue)
magnifier.size_range_changed.connect(self._mag_size.setRange)
form.addRow("Size (px)", self._mag_size)
self._mag_exclude_border = Toggle(
"Exclude objects touching the box border")
self._mag_exclude_border.setChecked(True)
self._mag_exclude_border.setToolTip(
"When ticked, an object that touches an edge of the box inside the "
"image is not offered, because the box may have cut it off. Untick "
"it to offer and add such objects too, as far as the box sees "
"them. Objects at the image border are offered either way. It "
"applies to Region under the mouse; Whole image never cuts an "
"object.")
self._mag_exclude_border.toggled.connect(magnifier.set_exclude_border)
form.addRow(self._mag_exclude_border)
self._build_magnifier_save_mode(form)
self._mag_zoom = QDoubleSpinBox()
self._mag_zoom.setDecimals(2)
self._mag_zoom.setRange(*_MAGNIFIER_ZOOM_RANGE)
self._mag_zoom.setSingleStep(0.25)
self._mag_zoom.setValue(_MAGNIFIER_ZOOM)
self._mag_zoom.setToolTip(
"How many times larger than the canvas the box draws its region. "
"The mouse wheel changes it while the magnifier is on. It changes "
"only what you see: the model always segments the region at the "
"image's own resolution.")
self._mag_zoom.valueChanged.connect(magnifier.set_zoom)
magnifier.zoom_changed.connect(self._mag_zoom.setValue)
form.addRow("Zoom", self._mag_zoom)
from ..i18n import tr
self._mag_lock_hint = QLabel(tr(
"Hold Ctrl+L and right-click to lock the region and zoom. "
"Repeat to unlock."))
self._mag_lock_hint.setWordWrap(True)
form.addRow(self._mag_lock_hint)
magnifier.locked_changed.connect(
lambda locked: self._mag_size.setEnabled(not locked))
magnifier.locked_changed.connect(
lambda locked: self._mag_zoom.setEnabled(not locked))
self._mag_sensitivity = QDoubleSpinBox()
self._mag_sensitivity.setDecimals(2)
self._mag_sensitivity.setRange(*_MAGNIFIER_SENSITIVITY_RANGE)
self._mag_sensitivity.setSingleStep(0.25)
self._mag_sensitivity.setValue(_MAGNIFIER_SENSITIVITY)
self._mag_sensitivity.setToolTip(
"How readily the Otsu mode accepts an object. Raise it to "
"take in dimmer or less certain objects, lower it to keep only "
"clear ones; 0 is the default cut. The models read their "
"thresholds from the Object detection settings instead, so this "
"is "
"greyed out while another mode is chosen.")
self._mag_sensitivity.valueChanged.connect(magnifier.set_sensitivity)
form.addRow("Sensitivity", self._mag_sensitivity)
self._mag_sensitivity.setEnabled(
self._mag_mode.currentData() == "otsu")
self._mag_auto_accept = Toggle(tr("Instantly accept proposed objects"))
self._mag_auto_accept.setChecked(False)
self._mag_auto_accept.setToolTip(tr(
"Accept proposals without clicking. Region mode follows Objects added; "
"Whole image accepts only the object under the pointer. Undo is "
"available; Save or Save & Next writes the masks."))
self._mag_auto_accept.toggled.connect(magnifier.set_auto_accept)
form.addRow(self._mag_auto_accept)
self._mag_overlap = QComboBox()
self._mag_overlap.addItem(tr("Add non-overlapping pixels"), "clip")
self._mag_overlap.addItem(tr("Fuse with existing object"), "merge")
self._mag_overlap.addItem(tr("Replace overlapping object"), "replace")
self._mag_overlap.addItem(tr("Skip overlapping objects"), "skip")
self._mag_overlap.setToolTip(tr(
"Fuse retains and extends the existing ID. Add non-overlapping pixels "
"creates a new object from unlabelled pixels. Replace removes "
"overlapping old objects in full. Skip ignores overlapping "
"proposals. Secondary mode cannot fuse primary IDs."))
self._mag_overlap.currentIndexChanged.connect(
lambda _index: magnifier.set_overlap(
self._mag_overlap.currentData()))
form.addRow(tr("Overlap"), self._mag_overlap)
card.body_layout.addLayout(form)
progress = QHBoxLayout()
from ..widgets.eliding import ProgressLine
self._mag_progress = ProgressLine(detail=False, count_below=True)
self._mag_progress.setRange(0, 0)
self._mag_progress.setTextVisible(False)
self._mag_progress.hide()
#: Moves the bar's estimate while a whole-image run is on its way.
#: Half a second, because the number it writes is whole seconds.
self._mag_eta_timer = QTimer(self)
self._mag_eta_timer.setInterval(500)
self._mag_eta_timer.timeout.connect(self._tick_magnifier_eta)
self._mag_cancel = QPushButton("Cancel")
self._mag_cancel.hide()
self._mag_cancel.clicked.connect(
lambda _checked=False: magnifier.cancel_image())
progress.addWidget(self._mag_progress, 1)
progress.addWidget(self._mag_cancel)
card.body_layout.addLayout(progress)
magnifier.busy_changed.connect(self._on_magnifier_busy)
self._btn_magnifier = QPushButton("Magnifier")
self._btn_magnifier.setIcon(iconset.icon("search"))
self._btn_magnifier.setCheckable(True)
self._btn_magnifier.setMinimumHeight(32)
self._btn_magnifier.setCursor(Qt.PointingHandCursor)
self._btn_magnifier.setToolTip(
"Show a box under the mouse with the region around it magnified "
"and the objects a model finds in it outlined. A click adds them "
"to the mask as new objects — under Whole image, only the object "
"clicked — one undo step per click. While it is on, the mouse "
"wheel changes the box's zoom rather than the view's.")
self._btn_magnifier.toggled.connect(self._on_toggle_magnifier)
pin = self._tool_pin_layout
pin.insertWidget(pin.indexOf(self._btn_settings), self._btn_magnifier)
return card
def _build_magnifier_save_mode(self, form: QFormLayout) -> None:
"""Which objects a click or a drag adds.
A method of its own with one call from the Live magnifier card, so the
card can be rearranged without rewriting this row. The choice is read
when the button goes down, so changing it mid-drag applies to the next
press.
"""
box = self._mag_save = QComboBox()
box.addItem("All objects in the zoom area", "zoom")
box.addItem("Only objects touching the mouse", "touching")
box.setToolTip(
"Which objects a click or a drag adds. All objects in the zoom "
"area adds every object the box outlines. Only objects touching "
"the mouse adds just the object under the cursor and leaves the "
"rest of the box out. Press and drag to keep adding along the "
"path: the objects the cursor passes over become one object, "
"joined from the pieces found in each box where they lie in the "
"image. Whole image always adds only the objects under the mouse.")
box.currentIndexChanged.connect(
lambda _index: (setattr(self._magnifier, "save_mode", box.currentData()),
self._magnifier.refresh()))
form.addRow(QLabel("Objects added"), box)
def _magnifier_context(self) -> dict:
"""The settings the magnifier's models read from elsewhere on the panel.
The Object detection and Otsu categories' own controls, read the
moment a request is built: the detect buttons read the same boxes, so
there is one set of settings on the panel and not one per tool.
"""
selector = getattr(self, '_primary_selector', None)
source = selector.snapshot if selector is not None else None
return {
'primary_source': source,
'puncta_image': self._puncta_native_image,
'primary_token': source.identity if source is not None else (),
'primary_selection': selector.path.text() if selector is not None else '',
"model_name": self._cp_model.currentData() or "cpsam",
"diameter": int(self._cp_diameter.value()),
"flow_threshold": float(self._cp_flow.value()),
"cellprob_threshold": float(self._cp_cellprob.value()),
"normalize": bool(self._cp_normalize.isChecked()),
"otsu_correction": float(self._otsu_correction.value()),
"otsu_smoothing": float(self._otsu_smoothing.value()),
"otsu_fill_holes": bool(self._secondary_fill_holes.isChecked()
if self._mag_mode.currentData() == cpu_modes.SECONDARY
else self._otsu_fill_holes.isChecked()),
"otsu_split": bool(self._otsu_split.isChecked()),
"bright": bool(self._otsu_bright.isChecked()),
"min_area": self._detect_min_area(),
"invert": bool(self._cp_invert.isChecked()) and self._mag_mode.currentData() != cpu_modes.PUNCTA,
"chain": self._detect_chain(),
"method_params": self._method_params(),
"cpu_params": self._cpu_params(),
"otsu_window": int(self._otsu_window.value()),
"otsu_classes": int(self._otsu_classes.value()),
"otsu_foreground_class": int(self._otsu_foreground.value()),
}
def _on_magnifier_mode(self, mode) -> None:
"""Choose the magnifier's model; Sensitivity is the Otsu mode's.
The Object detections card follows the mode, so the parameters on
screen are the ones the mode just chosen reads and no others.
"""
name = canonical_magnifier_mode(mode)
self._mag_sensitivity.setEnabled(name == "otsu")
self._magnifier.set_mode(name)
self._sync_method_controls()
def _on_mode_row_changed(self, _index: int) -> None:
"""The Mode box's row changed: hand the mode on, if it can run.
A row whose package is missing never reaches the magnifier: the box
is put back on the mode the magnifier is running, and
:meth:`_on_magnifier_mode_activated`, which fires next for a click,
offers the install. Handing the mode over for the moment between the
two would start a model load that can only fail.
"""
mode = self._mag_mode.currentData()
if mode in getattr(self, "_mag_uninstalled", ()):
running = self._mag_mode.findData(
canonical_magnifier_mode(
getattr(self._magnifier, "mode", None)))
self._mag_mode.setCurrentIndex(
running if running >= 0
else self._mag_mode.findData("otsu"))
return
self._on_magnifier_mode(mode)
def _grey_uninstalled_modes(self) -> None:
"""Grey the Mode rows whose package is missing, and only those.
A model absent from the box teaches nobody it exists; a greyed row
that offers to install itself does. Greying is a colour and a
tooltip, not a disabled row, because a disabled row cannot be
chosen and choosing it is how the install is asked for.
"""
from ..i18n import tr
from ..model_install import UNINSTALLED_GREY
box = self._mag_mode
for index in range(box.count()):
mode = box.itemData(index)
if mode in self._mag_uninstalled:
box.setItemData(index, QBrush(UNINSTALLED_GREY),
Qt.ForegroundRole)
box.setItemData(index, tr(
"{name} is not installed. Choosing it offers to install "
"it.", name=box.itemText(index)), Qt.ToolTipRole)
elif mode in _MAGNIFIER_BACKENDS:
box.setItemData(index, None, Qt.ForegroundRole)
box.setItemData(index, None, Qt.ToolTipRole)
def _on_magnifier_mode_activated(self, index: int) -> None:
"""A person chose this mode: offer the install if it is missing.
The offer hangs off ``activated``, which fires only when a person
picks a row -- not when code calls ``setCurrentIndex`` -- so no
question opens while settings are being restored. The box goes back
to the mode it was on at once, because a model that is not installed
cannot segment anything; a finished install selects it.
"""
mode = self._mag_mode.itemData(index)
self._resync_magnifier_modes()
if mode not in getattr(self, "_mag_uninstalled", ()):
return
previous = self._mag_mode.findData(
canonical_magnifier_mode(getattr(self._magnifier, "mode", None)))
if previous < 0 or previous == index:
previous = self._mag_mode.findData("otsu")
self._mag_mode.setCurrentIndex(max(previous, 0))
self._offer_backend_install(mode)
def _refresh_alpha_visibility(self) -> None:
"""Refresh alpha model choices immediately after Preferences changes.
The app already calls this hook on open screens, and this passes it
on to the folded screens built here, such as the Model Zoo, which the
app does not hold. Hidden rows remain
in the model so an explicitly selected backend keeps its value and
remains executable; they cannot be chosen from the popup or keyboard.
"""
from ..._segmentation_backends import _prefixed_backend
from ..preferences import _apply_alpha_widgets, _get_show_alpha_features
if _get_show_alpha_features():
_offer_prefixed_modes()
_offer_cellpose_dino_modes()
self._mag_mode.blockSignals(True)
try:
for mode, (_backend, label) in _MAGNIFIER_BACKENDS.items():
if self._mag_mode.findData(mode) < 0:
self._mag_mode.addItem(label, mode)
finally:
self._mag_mode.blockSignals(False)
self._fill_zoo_models()
ensemble = getattr(self, '_uncertainty_ensemble', None)
if ensemble is not None:
ensemble.blockSignals(True)
try:
for index in range(self._cp_model.count()):
value = self._cp_model.itemData(index)
if value and ensemble.findData(value) < 0:
ensemble.addItem(self._cp_model.itemText(index), value)
finally:
ensemble.blockSignals(False)
for combo in (self._mag_mode, self._cp_model,
getattr(self, '_uncertainty_ensemble', None)):
if combo is None:
continue
for index in range(combo.count()):
value = str(combo.itemData(index) or '')
backend = _prefixed_backend(value)
if backend is None:
continue
model = value.partition(':')[2]
hidden = _prefixed_alpha_hidden(backend, model)
combo.view().setRowHidden(index, hidden)
item = combo.model().item(index)
if item is not None:
item.setEnabled(not hidden)
item.setSelectable(not hidden)
self._resync_magnifier_modes()
_apply_alpha_widgets(self)
for screen in list(self._fold_screens.values()):
refresh = getattr(screen, "_refresh_alpha_visibility", None)
if callable(refresh):
try:
refresh()
except Exception:
LOG.debug("could not apply the alpha gate to a folded "
"screen", exc_info=True)
def _resync_magnifier_modes(self) -> None:
"""Re-read where each backend stands and redraw the Mode box.
THE BOX USED TO LEARN THIS ONCE, WHEN THE SCREEN WAS BUILT. Uninstall
Cellpose 3 from the Model Zoo with Make Masks still open and its four
modes stayed un-greyed and out of ``_mag_uninstalled``, so choosing
one offered no install and went straight to a backend that was no
longer there. Installing one from the Model Zoo screen left the
reverse: four greyed modes and an install dialog that returned at
once.
File checks only (:func:`_state_ready`), so it is cheap enough to run
on every choice, which is the moment it has to be right. The drawing
is :meth:`_grey_uninstalled_modes`, so there is one
description of what a greyed row looks like and it stays translated.
"""
for mode in _MAGNIFIER_BACKENDS:
if _backend_ready(mode):
self._mag_uninstalled.discard(mode)
else:
self._mag_uninstalled.add(mode)
self._grey_uninstalled_modes()
def _offer_backend_install(self, mode) -> bool:
"""Install the backend ``mode`` needs, into an environment of its own.
A greyed row installs its backend when it is chosen, and each
backend gets an isolated environment of its own, so this goes
through the Model Zoo's own install dialog: off the GUI thread, with
progress and Cancel, into ~/.spacr/backends/<name>, and spaCR's own
environment is never touched. It is used instead of
:class:`spacr.qt.model_install.PackageInstall`, which ran
`pip install "spacr[<backend>]"` against the environment spaCR is
running in; that module is unchanged and still serves the Mask
settings' backend dropdown.
Every mode of that backend -- all four Cellpose 3 models at once --
stops being greyed when it lands, and the row that was chosen is
selected, as the earlier in-process install also did at its end.
:param mode: a key of :data:`_MAGNIFIER_BACKENDS`.
:returns: True when the backend can segment afterwards.
"""
from ..i18n import tr
from ..widgets import model_zoo_picker
backend, label = _MAGNIFIER_BACKENDS[mode]
if not model_zoo_picker.install_backend(self, backend):
return False
for other, (needs, _label) in _MAGNIFIER_BACKENDS.items():
if needs == backend:
self._mag_uninstalled.discard(other)
self._grey_uninstalled_modes()
self._mag_mode.setCurrentIndex(self._mag_mode.findData(mode))
self._status_label.setText(tr(
"{name} is installed and selected.", name=label))
return True
def _on_magnifier_scope(self, scope) -> None:
"""Segment the region under the mouse or the whole image.
The border option is greyed out for the whole image, whose objects
the box never cuts.
"""
self._mag_exclude_border.setEnabled(scope != "image")
self._magnifier.set_scope(scope)
def _on_magnifier_busy(self, busy: bool) -> None:
"""Show the whole-image run's progress and Cancel while it runs."""
busy = bool(busy)
self._mag_progress.setVisible(busy)
self._mag_cancel.setVisible(busy)
if busy:
self._mag_eta_timer.start()
self._tick_magnifier_eta()
else:
self._mag_eta_timer.stop()
self._show_indeterminate_magnifier_bar()
def _show_indeterminate_magnifier_bar(self) -> None:
"""Say only that something is happening, which is what a bar with no
measurement behind it can honestly say."""
self._mag_progress.setTextVisible(False)
self._mag_progress.setFormat("")
self._mag_progress.setRange(0, 0)
def _tick_magnifier_eta(self) -> None:
"""Put the time the whole-image run still has on the busy bar.
Only when there is a MEASUREMENT behind it -- the last run under this
mode and model, per megapixel. Before there is one, and once an
estimate has run out, the bar goes back to indeterminate rather than
counting down past zero or sitting at 99%.
A RUN THAT COUNTS ITS OWN TILES SAYS HOW FAR IT HAS GOT instead
(:meth:`_LiveMagnifier.image_progress`): Cellpose on a whole field,
which is the run that takes minutes on a CPU. The bar then fills by
tiles, from the first run of the session on, and the time left is
this run's own pace; after the last tile, while the network's
output is turned into objects, it holds its place and says no time,
because that step is not made of tiles and is not guessed at.
"""
from ..i18n import tr
counted = self._magnifier.image_progress()
if counted is not None:
done, total, tiles_left = counted
self._mag_progress.setRange(0, int(total))
self._mag_progress.setValue(max(0, int(done) - 1))
if tiles_left is None:
self._mag_progress.setFormat("%p%")
else:
self._mag_progress.setFormat(tr(
"about {seconds} s left", seconds=int(tiles_left) + 1))
self._mag_progress.setTextVisible(True)
return
left = self._magnifier.remaining_seconds()
estimate = self._magnifier.estimated_seconds()
if left is None or not estimate:
self._show_indeterminate_magnifier_bar()
return
done = max(0.0, min(0.99, 1.0 - left / float(estimate)))
self._mag_progress.setRange(0, 1000)
self._mag_progress.setValue(int(done * 1000))
self._mag_progress.setFormat(
tr("about {seconds} s left", seconds=int(left) + 1))
self._mag_progress.setTextVisible(True)
def _on_invert_toggled(self, on: bool) -> None:
"""Show or hide the Invert warning.
The magnifier is told separately, by the same signal reaching
:meth:`_on_magnifier_context_changed`, which throws away objects
found from the image the other way up.
"""
self._invert_warning.setVisible(bool(on))
def _puncta_native_image(self) -> np.ndarray:
"""Read the single channel without the editor's uint16 display scaling.
Cache by field and file revision; preview crops share this source.
Parent masks and display pixels are never changed.
"""
import hashlib
from ..i18n import tr
filename = self._image_files[self._current_index]
path = os.path.join(self._folder, filename)
revision = os.stat(path)
key = (path, revision.st_mtime_ns, revision.st_size, id(self._canvas.image))
cached = getattr(self, '_puncta_native_cache', None)
if cached is not None and cached[0] == key:
return cached[1]
if engine.is_seg_bundle(filename):
raise ValueError(tr('For puncta detection, open the original single-channel image rather than a segmentation bundle.'))
image = engine.read_image(path)
if image.ndim == 3 and image.shape[-1] == 1:
image = image[..., 0]
if image.ndim != 2 or image.shape != self._canvas.image.shape:
raise ValueError(tr('Puncta detection requires a single-channel, two-dimensional image.'))
if not np.all(np.isfinite(image)):
raise ValueError(tr('The puncta channel contains non-finite intensities.'))
with open(path, 'rb') as stream:
digest = hashlib.file_digest(stream, 'sha256').hexdigest()
after = os.stat(path)
if (revision.st_mtime_ns, revision.st_size) != (after.st_mtime_ns, after.st_size):
raise ValueError(tr('The source image changed while being read. Reload it and repeat detection.'))
image.setflags(write=False)
self._puncta_native_cache = (key, image, digest)
return image
def _detector_image(self) -> Optional[np.ndarray]:
"""The field as the detectors must read it: inverted when Invert is on.
The inversion is not a display trick, so the one
thing the detect buttons segment comes from here rather than from
the canvas directly, and Otsu detect and Object detection cannot end
up disagreeing about which way up the image was.
The canvas's own array is never changed: the hover readout and the
Filter category read that one and go on reporting the field's real
values -- a user filtering by
intensity would otherwise be judging inverted numbers.
:func:`mask_engine.invert_for_detection`, NOT
:func:`mask_engine.invert_intensity` -- the dtype complement, which
would leave the Otsu threshold correction
pointing at intensities the field does not contain. The measurement
is in the first function's docstring.
IT IS ONE CALL because the canvas already assembles exactly this
array for the Image enhancement card: the inversion is
:meth:`_MaskCanvas.displayed_source`, the percentile stretch is
:meth:`_MaskCanvas.detection_base`, and the chain is
:meth:`_MaskCanvas.detection_source`. This method used to repeat
the first two, which is how a detect button and the magnifier could
have ended up reading different arrays the day one of them changed.
"""
image = self._canvas.image
if image is None:
return None
if self._mag_mode.currentData() == cpu_modes.PUNCTA:
return np.array(self._puncta_native_image(), copy=True)
return self._canvas.detection_source()
def _on_min_area_changed(self, value) -> None:
"""Hand Min area to the canvas, for Ctrl + left click's seed spacing.
The same judgement about debris as the detectors read
(:meth:`_detect_min_area`), so an object the screen would not keep
is not one the split gesture cuts in two either.
"""
self._canvas.split_min_area = int(value)
def _on_magnifier_context_changed(self, *_args) -> None:
"""A setting a magnifier model reads changed elsewhere on the panel.
Whole-image objects found under the old value are discarded at once,
and the status line says so, rather than on the next mouse move.
"""
self._magnifier.refresh()
def _build_prompt_card(self) -> QWidget:
"""Prompt-based segmentation: click or box one object, micro-SAM
outlines it.
An ALPHA feature: the category sits in a holder registered as
``MakeMasksPromptCategory`` in :data:`spacr.settings.ALPHA_FEATURES`,
so it is shown only while Preferences -> "Show alpha features" is
on, and prompting runs only while it is shown. The holder, and not
the category, carries the name, because the category's own name is
the one every folding section is styled by.
:returns: the holder, with the category in it.
"""
from ..i18n import tr
from ..preferences import _is_alpha_visible
card = self._settings_category(
"Segment by prompt",
tr("Click on one object, or drag a box round it, and micro-SAM "
"outlines it. Right-click marks what is not the object. Enter "
"adds the outline as a new object."))
holder = QWidget()
holder.setObjectName("MakeMasksPromptCategory")
line = QVBoxLayout(holder)
line.setContentsMargins(0, 0, 0, 0)
line.addWidget(card)
self._prompt_section = card
self._prompt_card = holder
self._btn_prompt = QPushButton(tr("Prompt with micro-SAM"))
self._btn_prompt.setObjectName("MakeMasksPromptToggle")
self._btn_prompt.setCheckable(True)
self._btn_prompt.setCursor(Qt.PointingHandCursor)
self._btn_prompt.setToolTip(tr(
"While on, a left click on the image marks the object, a right "
"click marks what is not the object and a left drag draws a box "
"round it. micro-SAM runs in an environment of its own; the "
"first click on a field waits while it embeds the field, and "
"later clicks reuse that embedding. If micro-SAM is not "
"installed, turning this on offers to install it. The Live "
"magnifier is turned off while this is on. Default off."))
self._btn_prompt.toggled.connect(self._on_toggle_prompt)
card.body_layout.addWidget(self._btn_prompt)
form = QFormLayout()
self._prompt_overlap = QComboBox()
self._prompt_overlap.addItem(tr("Clip"), "clip")
self._prompt_overlap.addItem(tr("Skip"), "skip")
self._prompt_overlap.addItem(tr("Replace"), "replace")
self._prompt_overlap.setToolTip(tr(
"What the accepted object does where the mask already has an "
"object. Clip keeps only its unlabelled pixels, so no existing "
"object loses a pixel. Skip adds nothing if it touches an "
"existing object. Replace lets it take every pixel it covers, "
"which is how an object a model split into pieces is made one "
"again. Default Clip."))
form.addRow(tr("Overlap"), self._prompt_overlap)
card.body_layout.addLayout(form)
row = QHBoxLayout()
row.setSpacing(SPACING["sm"])
self._btn_prompt_accept = QPushButton(tr("Add object"))
self._btn_prompt_accept.setCursor(Qt.PointingHandCursor)
self._btn_prompt_accept.setToolTip(tr(
"Add the outline micro-SAM drew as one new object: one edit, "
"undone by one Ctrl+Z, written by Save mask and recorded in the "
"field's curation ledger with the prompt, the model and the "
"micro-SAM version. Enter on the image does the same. "
"Default unavailable until an outline is shown."))
self._btn_prompt_accept.clicked.connect(self._accept_prompt)
row.addWidget(self._btn_prompt_accept)
self._btn_prompt_discard = QPushButton(tr("Discard"))
self._btn_prompt_discard.setCursor(Qt.PointingHandCursor)
self._btn_prompt_discard.setToolTip(tr(
"Throw away the points, the box and the outline, and start "
"again on another object. Nothing had been added to the mask. "
"Escape on the image does the same. Default unavailable until "
"there is a prompt."))
self._btn_prompt_discard.clicked.connect(
lambda _checked=False: self._prompter.forget())
row.addWidget(self._btn_prompt_discard)
wrap = QWidget()
wrap.setLayout(row)
card.body_layout.addWidget(wrap)
self._sync_prompt_buttons()
if not _is_alpha_visible("widgets", holder.objectName()):
holder.setProperty("_spacr_alpha_hid", True)
holder.setVisible(False)
return holder
def _prompt_card_shown(self) -> bool:
"""Whether the prompt category is on screen, not hidden as alpha."""
card = getattr(self, "_prompt_card", None)
return card is not None and not card.isHidden()
def _current_field_name(self) -> str:
"""The file name of the field on screen, or ``''``."""
files = getattr(self, "_image_files", None) or []
index = getattr(self, "_current_index", 0)
return str(files[index]) if 0 <= index < len(files) else ""
def _sync_prompt_buttons(self) -> None:
"""Enable Add object and Discard only when they have something."""
prompter = self._prompter
accept = getattr(self, "_btn_prompt_accept", None)
if accept is not None:
accept.setEnabled(prompter.enabled and prompter.pending is not None)
discard = getattr(self, "_btn_prompt_discard", None)
if discard is not None:
discard.setEnabled(prompter.enabled and bool(
prompter.points or prompter.box is not None
or prompter.pending is not None))
def _on_prompt_said(self, text: str, kind: str) -> None:
"""Put what prompting says in the corner or the console."""
if kind == "status":
self._status_label.setText(text)
else:
self._report(text, kind)
def _prompt_ready(self) -> bool:
"""Whether micro-SAM can answer prompts now; file checks only."""
try:
return bool(self._prompter.client().readiness()[0])
except (OSError, ValueError):
return False
def _offer_prompt_install(self) -> bool:
"""Offer to install micro-SAM, the way the Model Zoo installs it.
Into an environment of its own under the backends folder, off the
GUI thread, with its progress in this screen's console.
:returns: True when micro-SAM can answer prompts afterwards.
"""
from ..i18n import tr
from ..widgets import model_zoo_picker
label = "micro-SAM"
def watch(dialog):
"""Put the install's progress and its ending in the console."""
dialog.job_started.connect(lambda: self._report(
tr("Installing {name}…", name=label), "progress"))
dialog.job_progressed.connect(lambda text: self._report(
"{}: {}".format(tr("Installing {name}…", name=label), text),
"stream"))
dialog.job_failed.connect(lambda message: self._report(
"{} {}".format(tr("Installing {name} failed. Nothing was "
"left half-built.", name=label), message),
"error"))
dialog.job_cancelled.connect(lambda: self._report(
tr("Cancelled. Nothing was left behind."), "info"))
installed = bool(model_zoo_picker.install_backend(
self, "microsam", watch=watch, why=tr(
"Prompt-based segmentation runs micro-SAM, which is not "
"installed yet.")))
if installed:
self._report(tr("{name} is installed", name=label), "info")
return installed and self._prompt_ready()
def _on_toggle_prompt(self, on: bool) -> None:
"""Turn prompting on or off; on offers the install when it is missing."""
from ..i18n import tr
if on and not self._prompt_ready() \
and not self._offer_prompt_install():
blocked = self._btn_prompt.blockSignals(True)
self._btn_prompt.setChecked(False)
self._btn_prompt.blockSignals(blocked)
self._status_label.setText(tr(
"Prompting needs micro-SAM, which is not installed."))
return
if on:
if self._btn_magnifier.isChecked():
self._btn_magnifier.setChecked(False)
if self._canvas.ruler.active or self._canvas.mode == MODE_BOX:
self._set_mode(MODE_NONE)
self._status_label.setText(tr(
"Prompting on: click the object, right-click what is not "
"it, or drag a box round it; Enter adds the outline."))
else:
self._status_label.setText(tr(
"Prompting off. The objects it added stay in the mask."))
self._prompter.set_enabled(on)
self._sync_prompt_buttons()
def _accept_prompt(self) -> List[int]:
"""Add the outline micro-SAM drew to the mask as one new object.
One edit: one ledger entry, ``prompt``, that names the new id and
keeps the prompt (points, box), the model, its score, the device
and the environment's package versions, and one undo step. Nothing
is written until Save mask. The overlap rule is read from the
category's Overlap box (:func:`spacr.qt.mask_engine.
_paste_region_objects`); an outline the rule leaves nothing of adds
nothing.
:returns: the ids added; empty when nothing was.
"""
from ..i18n import tr
pending = self._prompter.pending
mask = self._canvas.mask
if pending is None or mask is None:
return []
region = np.asarray(pending.get("mask"), dtype=bool)
if region.shape != tuple(mask.shape[:2]):
return []
rows = np.flatnonzero(region.any(axis=1))
cols = np.flatnonzero(region.any(axis=0))
if not rows.size:
self._status_label.setText(tr(
"micro-SAM outlined nothing; add a point or draw a box."))
return []
y0, y1 = int(rows[0]), int(rows[-1]) + 1
x0, x1 = int(cols[0]), int(cols[-1]) + 1
overlap = self._prompt_overlap.currentData() or "clip"
out, added = engine._paste_region_objects(
mask, region[y0:y1, x0:x1].astype(np.uint8), (x0, y0),
overlap=overlap, min_area=0)
if not added:
self._status_label.setText(tr(
"Nothing was added: under the Overlap rule the outline "
"leaves nothing that is not already an object."))
return []
changed = self._pixels_changed(out)
self._canvas.mask = out
self._canvas.refresh()
self._record(
"prompt", list(added), changed, tool="micro-SAM",
model=pending.get("model"), overlap=overlap,
points=[[int(y), int(x), bool(on)]
for y, x, on in pending.get("points") or ()],
box=(None if pending.get("box") is None
else [int(v) for v in pending["box"]]),
score=pending.get("score"), device=pending.get("device"),
versions=dict(pending.get("versions") or {}),
embedding=pending.get("key"), n_objects=len(added))
self._history.push(out)
self._refresh_history_buttons()
self._prompter.forget()
self._status_label.setText(tr(
"micro-SAM added object {ids} — Ctrl+Z to undo",
ids=", ".join(str(v) for v in added)))
return added
def _on_toggle_magnifier(self, on: bool) -> None:
"""Turn the live magnifier on or off from the tool row.
The status line is written before the magnifier is switched, so a
whole-image run the switch starts has the last word on it.
"""
from ..i18n import tr
if on and (self._canvas.ruler.active or self._canvas.mode == MODE_BOX):
self._set_mode(MODE_NONE)
button = getattr(self, "_btn_prompt", None)
if on and button is not None and button.isChecked():
button.setChecked(False)
if on and self._magnifier.scope == "image":
self._status_label.setText(tr(
"Magnifier on: a click adds the object under it and a "
"right-click removes the mask object under it; the mouse "
"wheel changes its zoom."))
elif on:
self._status_label.setText(tr(
"Magnifier on: a click adds the objects outlined in the box; "
"the mouse wheel changes its zoom."))
else:
self._status_label.setText(tr(
"Magnifier off. The objects it added stay in the mask."))
self._magnifier.set_enabled(on)
if on and self._canvas.underMouse():
where = self._canvas.mapFromGlobal(QCursor.pos())
self._magnifier.hover(QPointF(where))
self._canvas.update()
def _commit_magnifier_result(self, result) -> List[int]:
"""Paste the objects a magnifier click asked for into the mask.
One click is one edit: one ledger entry naming the new ids and one
undo step. The overlap rule is read now, from the Overlap box; new ids
start one past the mask's top id, and an object left smaller than
Min area by the rule is not added -- see
:func:`spacr.qt.mask_engine._paste_region_objects`.
A whole-image click arrives as that one object, cut to its bounding
box, and is recorded with ``scope="image"``.
Detector settings, CPU/organelle parameters and enhancement come
from the completed request, even if the panel has since changed.
``min_area`` records detection's filter; ``paste_min_area`` records
the current filter applied while pasting through the overlap rule.
:returns: the ids added; empty when nothing was.
"""
from ..i18n import tr
mask = self._canvas.mask
request = result.request
if mask is None or tuple(mask.shape[:2]) != tuple(request.shape):
return []
exact_ids = result.mode == cpu_modes.SECONDARY
if exact_ids:
try:
source = self._require_primary_source()
if source.identity != request.primary_token:
raise ValueError(tr('The primary mask changed. Wait for a new preview before accepting objects.'))
self._require_secondary_merge(source)
except ValueError as exc:
self._status_label.setText(str(exc))
return []
overlap = self._mag_overlap.currentData() or "clip"
if exact_ids and overlap == "merge":
self._status_label.setText(tr(
"Secondary mode preserves primary IDs; Fuse is unavailable."))
return []
try:
out, added = engine._paste_region_objects(
mask, result.labels, request.box[:2], overlap=overlap,
min_area=self._detect_min_area(), preserve_ids=exact_ids,
replace_whole=overlap == "replace")
except ValueError as exc:
self._status_label.setText(tr(
"Magnifier could not add objects: {error}", error=exc))
return []
if not added and request.scope == "image":
self._status_label.setText(tr(
"Magnifier: nothing was added — the Overlap rule or Min area "
"leaves nothing of the object under the click."))
return []
if not added:
self._status_label.setText(tr(
"Magnifier: nothing to add — the box outlines no object, or "
"every object it outlines overlaps one already in the mask."))
return []
changed = self._pixels_changed(out)
if not changed:
return []
if overlap == "merge":
self._manual_id_history[self._manual_mask_key(mask)] = self._canvas.manual_ids
self._canvas.manual_ids = True
self._canvas.mask = out
self._canvas.refresh()
if exact_ids:
self._retain_secondary_ids(request.primary_provenance)
self._record("magnifier", list(added), changed,
overlap=overlap, paste_min_area=self._detect_min_area(),
box=[int(v) for v in request.box],
n_objects=len(added),
**({"source_labels": [int(v) for v in np.unique(result.labels) if v > 0]}
if request.scope == "image" else {}),
**_magnifier_provenance(request, result.mode, result.note))
self._history.push(out)
self._refresh_history_buttons()
self._status_label.setText(tr(
"Magnifier added {n} object(s) — Ctrl+Z to undo", n=len(added)))
return added
def _remove_magnifier_object(self, x: int, y: int) -> int:
"""Remove the mask object at image ``(x, y)``: a whole-image right click.
Any object qualifies, whatever put it in the mask. One removal is one
edit: a ``delete`` ledger entry naming the id, with
``tool="magnifier"``, and one undo step. A click on background
changes nothing and records nothing.
:returns: the id removed, or 0.
"""
from ..i18n import tr
mask = self._canvas.mask
if mask is None:
return 0
height, width = mask.shape[:2]
label = (int(mask[y, x]) if 0 <= y < height and 0 <= x < width
else 0)
if label <= 0:
self._status_label.setText(tr(
"Magnifier: there is no mask object under the click — nothing "
"was removed."))
return 0
self._magnifier.hold_auto_accept(mask == label)
out = engine.erase_object_at(mask, x, y)
changed = self._pixels_changed(out)
self._canvas.mask = out
self._canvas.refresh()
self._record("delete", label, changed, tool="magnifier")
self._history.push(out)
self._refresh_history_buttons()
self._status_label.setText(tr(
"Magnifier removed object {label} — Ctrl+Z to undo", label=label))
return label
def _apply_magnifier_drag(self, payload) -> List[int]:
"""Show a magnifier drag's objects in the mask, or commit them (417).
While the button is down the mask shows the drag's objects pasted onto
the mask the drag started from, and nothing is recorded. The final
paste is ONE edit -- one ``magnifier`` ledger entry marked
``drag=True`` and one undo step -- through the Overlap rule and Min
area. Objects crossed by the path share one ID, including separated
detections. A stroke starting on an existing object extends that ID;
otherwise it allocates a new ID. Off-path detections remain separate
when the selected save mode keeps them. Grouping paints no background
pixels and survives saving and reopening. A mask another edit put on screen
during the drag becomes the mask it pastes onto.
:param payload: ``(outcome, final)`` from
:attr:`_LiveMagnifier.drag_ready`.
Outcome provenance supplies the cursor path, saved selection
rule and each accepted frame's detector request in delivery order.
A mixed-method stroke is marked ``mode="mixed"``; its per-frame
records retain the actual methods, settings and fallback notes.
:returns: the ids the final paste added; empty for a preview.
"""
from ..i18n import tr
found, final = payload
canvas = self._canvas
if canvas.mask is not self._drag_shown:
self._drag_base = canvas.mask
base = out = self._drag_base
overlap = self._mag_overlap.currentData() or "clip"
added: List[int] = []
if base is not None and found is not None and found.objects:
self._manual_id_history[self._manual_mask_key(base)] = canvas.manual_ids
incoming = np.asarray(found.labels)
mapped = np.zeros_like(incoming, dtype=np.int64)
provenance = found.provenance or {}
path = provenance.get('path') or []
anchor = 0
if path:
sx, sy = (int(v) for v in path[0])
if 0 <= sy < base.shape[0] and 0 <= sx < base.shape[1]:
anchor = int(base[sy, sx])
next_id = engine.next_label(base)
for value in (int(v) for v in np.unique(incoming) if int(v) > 0):
if value == 1 and anchor:
target = anchor
else:
target, next_id = next_id, next_id + 1
mapped[incoming == value] = target
pasted, added = engine._paste_region_objects(
base, mapped, found.origin, overlap=overlap,
min_area=self._detect_min_area(), preserve_ids=True,
replace_whole=overlap == "replace")
out = pasted if added else base
canvas.mask = out
canvas.refresh()
self._drag_shown = None if final else out
if not final:
return []
if not added:
self._status_label.setText(tr(
"Magnifier: nothing was added — there was no object under the "
"mouse, or the Overlap rule or Min area left nothing of it."))
return []
canvas.manual_ids = True
canvas._lookup = None
canvas._lookup_dirty = True
height, width = found.labels.shape[:2]
x0, y0 = found.origin
provenance = found.provenance or {}
frames = provenance.get("frame_requests", [])
def common(field, default):
"""Return a shared frame value, or the explicit mixed/unknown value."""
values = [frame.get(field, default) for frame in frames]
return values[0] if values and all(value == values[0] for value in values) else default
self._record("magnifier", list(added), self._pixels_changed(out),
mode=common("mode", "mixed" if frames else "unknown"), overlap=overlap,
paste_min_area=self._detect_min_area(),
box=[x0, y0, x0 + width, y0 + height],
sensitivity=common("sensitivity", None),
n_objects=len(added), scope=common("scope", "mixed" if frames else "unknown"),
drag=True, frames=found.frames, merged=found.merged,
**provenance)
self._history.push(out)
self._refresh_history_buttons()
self._status_label.setText(tr(
"Magnifier added {n} object(s) — Ctrl+Z to undo", n=len(added)))
return added
def _warn(self, title: str, text: str) -> None:
"""Report a non-fatal failure to the user.
Shows a modal warning when a display is attached; otherwise the
message goes to the status line and the log, because a modal box
under the offscreen/minimal platform plugin never returns.
"""
title, text = self._blind_text(title), self._blind_text(text)
self._status_label.setText(f"{title}: {text}")
if is_headless():
LOG.warning("%s: %s", title, text)
return
QMessageBox.warning(self, title, text)
def _confirm(self, title: str, text: str) -> bool:
"""Ask the user to approve a destructive action.
Returns False when headless: with nobody to answer, the safe
answer for an irreversible operation is "no".
"""
title, text = self._blind_text(title), self._blind_text(text)
if is_headless():
LOG.warning("%s: no display to confirm on — not proceeding", title)
self._status_label.setText(
f"{title} cancelled — no display to confirm on"
)
return False
return QMessageBox.question(self, title, text) == QMessageBox.Yes
def _on_pick_folder(self):
"""Ask for a folder of images and open it.
When blinded, the picker opens at the home folder, because in the
source folder its path bar would name the plate.
"""
start = (os.path.expanduser("~") if self._blind is not None
else self._folder or os.getcwd())
d = QFileDialog.getExistingDirectory(self, "Pick images folder", start)
if not d:
return
if self._offer_consolidation(d):
return
self._open_folder(d)
def _build_organize_button(self) -> QPushButton:
"""The "Organize for Measure…" button beside "Open folder…"."""
from ..i18n import tr
button = QPushButton(tr("Organize for Measure…"), self)
button.setObjectName("MakeMasksOrganizeButton")
button.setCursor(Qt.PointingHandCursor)
button.setToolTip(tr(
"Put images and their masks into the layout Measure reads: one "
"popup with the source folder, Mask generation's regex options "
"and a table with a column per channel and per mask, which takes "
"dropped files and folders. Apply moves them into Yokogawa-named "
"channel folders and merges them into merged/."))
button.clicked.connect(lambda _checked=False: self._on_organize())
self._btn_organize = button
return button
def _folder_job_running(self) -> bool:
"""Whether a consolidation or channel sort is still running."""
worker = getattr(self, "_folder_job", None)
return worker is not None and worker.isRunning()
def _offer_consolidation(self, folder: str) -> bool:
"""Ask whether to consolidate ``folder`` when its images sit in subfolders.
Folders in :data:`_NOT_CONSOLIDATED` are not counted or copied: they
hold masks, raw originals or an earlier sort, not images to edit.
:param folder: the folder dropped or picked.
:returns: True when a consolidation was started, and the new folder
will open when it ends; False to open ``folder`` as it is.
"""
from ..i18n import tr
from ... import folder_consolidation as fc
try:
files, folders = fc.nested_file_count(
folder, engine.IMAGE_EXTS, _NOT_CONSOLIDATED)
except OSError:
return False
if not files:
return False
if not self._confirm(
tr("Consolidate folders?"),
tr("{folder} has {n} image(s) in {m} subfolder(s). Copy them "
"into one new folder, each named after the folders it was "
"in (for example exp_nucleus_2.tif)? The originals are not "
"touched. No opens the folder as it is.",
folder=folder, n=files, m=folders)):
return False
return self._start_consolidation(folder)
def _start_consolidation(self, folder: str) -> bool:
"""Copy ``folder``'s tree into one new folder off the GUI thread.
:param folder: the folder to consolidate.
:returns: False when another folder job is still running.
"""
from ..i18n import tr
from ... import folder_consolidation as fc
if self._folder_job_running():
self._warn(tr("Busy"), tr("A folder job is still running."))
return False
post = self._masks_console.post
output = fc.default_output_folder(folder)
post(tr("Consolidating {folder} into {output}…", folder=folder,
output=str(output)))
worker = _FolderJobWorker(lambda: fc.consolidate_folder(
folder, output, extensions=engine.IMAGE_EXTS,
skip_dirs=_NOT_CONSOLIDATED,
log=post), self)
worker.finished.connect(lambda: self._on_consolidated(worker))
self._folder_job = worker
worker.start()
return True
def _on_consolidated(self, worker: _FolderJobWorker) -> None:
"""Open the consolidated folder, or say why there is none.
:param worker: the finished job.
"""
from ..i18n import tr
if worker.error is not None or worker.result is None:
self._warn(tr("Consolidation failed"), str(worker.error))
return
result = worker.result
self._masks_console.post(tr(
"Copied {n} image(s) into {output}; the mapping is in {manifest}.",
n=result.copied, output=str(result.output),
manifest=str(result.manifest)))
if result.failed:
self._masks_console.post(tr(
"{n} file(s) could not be copied; see the manifest.",
n=result.failed), "warning")
self._open_folder(str(result.output))
def _on_organize(self):
"""Open "Organize for Measure" on the open folder, if one is open.
:returns: the dialog.
"""
folder = self._folder if self._folder and not self._field_folders else ""
return self._open_organize(folder, masks_dir=self._masks_dir)
def _open_organize(self, source: str = "", channel_folders=None,
masks_dir: Optional[str] = None):
"""Open the "Organize for Measure" popup and apply what it plans.
:param source: the source folder to prefill, or ``""``.
:param channel_folders: folders to prefill as one channel column
each, from a drop.
:param masks_dir: the source's masks folder, when not its ``masks/``.
:returns: the dialog when it was accepted and its plan started, else
None. It is kept as :attr:`_organize_dialog` either way.
"""
from ..widgets.organize_for_measure import OrganizeForMeasureDialog
dialog = OrganizeForMeasureDialog(source, self,
channel_folders=channel_folders,
masks_dir=masks_dir)
self._organize_dialog = dialog
if not self._run_organize(dialog) or dialog.plan is None:
return None
self._start_channel_sort(dialog.plan)
return dialog
def _run_organize(self, dialog) -> bool:
"""Show the popup modally; headless nobody can, so it is not run.
:param dialog: the popup.
:returns: whether it was accepted.
"""
if is_headless():
return False
return dialog.exec() == QDialog.Accepted
def _start_channel_sort(self, plan) -> bool:
"""Move, rename and merge as ``plan`` says, off the GUI thread.
:param plan: a confirmed :class:`spacr.channel_sorting.SortPlan`.
:returns: False when another folder job is still running.
"""
from ..i18n import tr
from ... import channel_sorting as cs
if self._folder_job_running():
self._warn(tr("Busy"), tr("A folder job is still running."))
return False
post = self._masks_console.post
post(tr("Sorting {n} image(s) into channels under {dest}…",
n=len(plan.rows), dest=plan.dest))
worker = _FolderJobWorker(lambda: cs.apply_plan(plan, log=post), self)
worker.finished.connect(lambda: self._on_channels_sorted(worker))
self._folder_job = worker
worker.start()
return True
def _on_channels_sorted(self, worker: _FolderJobWorker) -> None:
"""Report the sort and open the first channel folder.
:param worker: the finished job.
Measure is pointed at the result, so features are one step away.
"""
from ..i18n import tr
if worker.error is not None or worker.result is None:
self._warn(tr("Sorting failed"), tr(
"{error}\nEvery move made before the failure is listed in "
"the manifest.", error=str(worker.error)))
return
result = worker.result
self._masks_console.post(tr(
"Moved {moved} file(s); {stacks} stack(s) and {merged} merged "
"array(s) written under {dest}. Every move is in {manifest}.",
moved=result.moved, stacks=len(result.stacks),
merged=len(result.merged), dest=result.dest,
manifest=result.manifest))
prefs.push_recent_source("measure", str(result.dest))
self._masks_console.post(tr(
"Ready for Measure: open Measure and use {dest} as its source (it "
"is first in Measure's recent sources). Its merged arrays hold the "
"images first, then the mask planes in the order cell, nucleus, "
"pathogen, organelle.", dest=result.dest))
first = os.path.join(result.dest, "C01")
if os.path.isdir(first):
self._open_folder(first)
[docs]
def open_paths(self, paths) -> bool:
"""Open a drop the way its contents say, asking where there is a choice.
:func:`spacr.drop_classification.classify_drop` says what
was dropped, and:
* image files (with or without folders) open as one queue, in drop
order, each field edited where it lies with its mask in its own
``<folder>/masks`` -- :meth:`_open_queue`;
* one folder of images opens as it is;
* one folder whose subfolders look like channels (DAPI/, GFP/..., or
the same fields in each) opens "Organize for Measure" with a
channel column per subfolder; any other folder whose images sit in
subfolders is offered for consolidation;
* several folders of images open "Organize for Measure" with a
channel column per folder;
* when that popup is cancelled (or nobody can see it), the drop
opens as it always did: the folder as it is, or a queue;
* images dropped with their masks open with those masks
(:meth:`_open_with_masks`);
* a folder spaCR wrote is named in the console and its images, if
any, open -- a ``.npy`` is never opened as an image.
Everything the drop held that is used for nothing is listed in the
console.
:param paths: the dropped files and folders, in the order dropped.
:returns: whether something was opened or a job started.
"""
from ..i18n import tr
from ... import drop_classification as dc
paths = [os.path.abspath(str(p)) for p in paths]
found = dc.classify_drop(paths)
for line in found.unrecognised:
self._masks_console.post(
tr("Not used from this drop: {item}", item=line), "warning")
if found.description:
self._masks_console.post(found.description)
if found.kind == "spacr_output":
if found.open_folder:
return self._open_folder(found.open_folder)
self._warn(tr("Nothing to open"), found.description)
return False
if found.kind == "images_with_masks":
return self._open_with_masks(found)
if found.kind in ("folder", "nested"):
folder = found.folders[0]
if found.channel_like and len(found.channel_folders) > 1:
if self._open_organize(folder, found.channel_folders):
return True
elif self._offer_consolidation(folder):
return True
return self._open_folder(folder)
if found.kind == "folders":
if self._open_organize("", found.folders):
return True
wanted = set(found.images) | set(found.folders)
return self._open_queue([p for p in paths if p in wanted])
def _open_with_masks(self, found) -> bool:
"""Open dropped images with the masks dropped with them.
When the images share one folder and the masks one folder, named as
Make Masks names masks (``<stem>.tif``), that masks folder is used
as it is. Otherwise the user is asked to copy each mask into
``masks/`` beside its image under that name -- a mask already there
is kept -- and the images open either way.
:param found: an ``images_with_masks``
:class:`spacr.drop_classification.DropClassification`.
:returns: whether the images were opened.
"""
from ..i18n import tr
from ... import channel_sorting as cs
post = self._masks_console.post
for mask in found.unpaired_masks:
post(tr("Not used from this drop: {item}",
item=tr("{path} (no dropped image has this name)",
path=mask)), "warning")
images, masks = list(found.images), dict(found.masks)
folders = {os.path.dirname(image) for image in images}
mask_dirs = {os.path.dirname(mask) for mask in masks.values()}
named = all(os.path.basename(mask)
== cs.split_extension(os.path.basename(image))[0] + ".tif"
for image, mask in masks.items())
if len(folders) == 1 and len(mask_dirs) == 1 and named:
folder, masks_dir = folders.pop(), mask_dirs.pop()
if os.path.normpath(masks_dir) == os.path.normpath(
os.path.join(folder, "masks")):
masks_dir = None
post(tr("Opening {n} image(s) with {k} dropped mask(s).",
n=len(images), k=len(masks)))
return self._open_folder(
folder, files=[os.path.basename(i) for i in images],
masks_dir=masks_dir)
copies = []
for image, mask in masks.items():
target = os.path.join(
os.path.dirname(image), "masks",
cs.split_extension(os.path.basename(image))[0] + ".tif")
if os.path.normpath(target) != os.path.normpath(mask):
copies.append((mask, target))
kept = [target for _mask, target in copies if os.path.exists(target)]
if copies and self._confirm(
tr("Use the dropped masks?"),
tr("{n} dropped mask(s) belong to dropped images. Copy them "
"into masks/ beside their images, named as Make Masks "
"names masks, so they open with them? {k} image(s) already "
"have a mask there, which is kept. No opens the images "
"without them.", n=len(copies), k=len(kept))):
copied = 0
for mask, target in copies:
if os.path.exists(target):
continue
try:
_copy_mask_as_tiff(mask, target)
copied += 1
except (OSError, ValueError) as exc:
post(tr("Could not copy {mask}: {error}", mask=mask,
error=str(exc)), "warning")
post(tr("Copied {n} mask(s) beside their images.", n=copied))
return self._open_queue(images)
def _open_queue(self, paths) -> bool:
"""Open image files and folders as one queue, in the order given.
One folder alone opens as a folder; anything else becomes a queue of
the fields named, a folder standing for its images, each field
edited where it lies. Nothing is copied.
:param paths: absolute image files and folders.
:returns: whether a queue was opened.
"""
from ..i18n import tr
if len(paths) == 1 and os.path.isdir(paths[0]):
return self._open_folder(paths[0])
fields: list = []
for path in paths:
if os.path.isdir(path):
found = [(path, name) for name in engine.list_images(path)]
elif (os.path.isfile(path)
and path.lower().endswith(engine.IMAGE_EXTS)):
found = [(os.path.dirname(path), os.path.basename(path))]
else:
found = []
for field in found:
if field not in fields:
fields.append(field)
if not fields:
self._warn(tr("No images"),
tr("None of the dropped items is an image Make Masks "
"can open."))
return False
folders = [folder for folder, _name in fields]
spans = list(dict.fromkeys(folders))
if not self._open_folder(
folders[0], files=[name for _folder, name in fields],
field_folders=folders if len(spans) > 1 else None):
return False
if len(spans) > 1:
self._src_label.setText(tr(
"{n} images from {k} folders, in the order dropped",
n=len(fields), k=len(spans)))
return True
def _open_folder(self, folder: str,
files: Optional[List[str]] = None,
masks_dir: Optional[str] = None,
field_folders: Optional[List[str]] = None) -> bool:
"""List the folder's images and load the first.
:param folder: the folder to open.
:param files: the file names to offer, in the order to offer them.
``None`` -- every caller but :meth:`open_queue` -- lists the
folder itself, which is what a file dialog or a dropped folder
means. A session built by ``spacr-make-masks`` passes its own
list, because the queue has already dropped what is reviewed and
sorted what is left.
:param masks_dir: the masks folder when it is not ``<folder>/masks``
-- a sibling session's. Set BEFORE the first field loads, so the
first draft shown is the set's own.
:param field_folders: the folder of each of ``files``, when a drop
queued fields from more than one folder; see :meth:`open_paths`.
:returns: whether a folder was opened. ``False`` means there was
nothing in it to edit, which the user has been told about.
"""
files = list(files) if files is not None else engine.list_images(folder)
if not files:
self._warn("No images", f"Found no image files in: {folder}")
return False
if not self._save_boxes_if_needed():
return False
self._leave_blind_unopened("another folder was opened")
self._queue = None
self._session_notice = ""
self._masks_dir = masks_dir
self._folder = folder
self._image_files = files
self._field_folders = (list(field_folders)
if field_folders is not None else None)
self._current_index = 0
self._src_label.setText(f"{folder} — {len(files)} images")
self._load_current()
self._sync_button_states()
prefs.push_recent_source("make_masks", folder)
self._body_stack.setCurrentWidget(self._body_splitter)
return True
def _load_current(self):
"""Show the current field and whatever mask it already has."""
self._sync_queue_tally()
if not self._image_files:
return
if not self._save_boxes_if_needed():
if self._box_field is not None:
folder, filename = self._box_field
for index, name in enumerate(self._image_files):
owner = self._field_folders[index] if self._field_folders else self._folder
if (owner, name) == (folder, filename):
self._current_index = index
self._folder = folder
break
return
if self._field_folders:
self._folder = self._field_folders[self._current_index]
self._primary_selector.clear_field()
self._load_token += 1
token = self._load_token
filename = self._image_files[self._current_index]
image_path = os.path.join(self._folder, filename)
if self._should_background_load(image_path):
request = (self._folder, filename, token)
if self._load_worker is not None:
self._pending_load = request
self._status_label.setText(
f"Waiting to load {self._blind_label(image_path)}…")
return
self._start_background_load(*request)
return
self._load_pair(self._folder, filename, token)
@staticmethod
def _should_background_load(path: str) -> bool:
"""Return True when decoding ``path`` is large enough to stall Qt.
File size catches ordinary uncompressed microscopy TIFFs. A quick PIL
header read also catches highly compressed large images without
decoding their pixels.
"""
threshold = 8 * 1024 * 1024
try:
if os.path.getsize(path) >= threshold:
return True
from PIL import Image
with Image.open(path) as probe:
bands = max(1, len(probe.getbands()))
bytes_per_sample = 2 if "16" in probe.mode else 1
return (
probe.width * probe.height * bands * bytes_per_sample
>= threshold
)
except (OSError, ValueError):
return False
def _start_background_load(
self, folder: str, filename: str, token: int
) -> None:
"""Start one retained image loader and disable edit controls."""
self._loading = True
self._status_label.setText(
f"Loading {self._blind_label(os.path.join(folder, filename))}…")
self._sync_button_states()
worker = _MaskLoadWorker(folder, filename, token, self,
layout=self._layout_kwargs())
self._load_worker = worker
worker.finished.connect(self._on_background_load_finished)
worker.start()
def _on_background_load_finished(self) -> None:
"""Apply the newest background result and start any pending request."""
worker = self._load_worker
if worker is None:
return
self._load_worker = None
self._loading = False
if worker.token == self._load_token:
if worker.error is not None:
self._handle_load_failure(worker.error)
elif worker.result is not None:
self._apply_loaded_pair(
worker.filename, worker.token, *worker.result
)
worker.deleteLater()
pending, self._pending_load = self._pending_load, None
if pending is not None:
self._start_background_load(*pending)
else:
self._sync_button_states()
[docs]
def closeEvent(self, event):
"""Drain the background image loader before Qt destroys this screen.
``_MaskLoadWorker`` is parented to this widget, so without this the
screen's destructor deletes a QThread that is still decoding a large
TIFF, and Qt answers that with ``qFatal("QThread: Destroyed while
thread is still running")`` — a core dump, not an exception. The
window is exactly as wide as one image decode, which is why it shows
up in a loaded test shard and almost never by hand.
Any folded module still open goes with it: each one is a window of
its own, and several of them own worker threads and viewers that must
be told to stop rather than be collected out from under Qt. A model
download is cancelled; a backend install is left to finish, because
``pip`` stopped half way can leave the environment broken.
:param event: the close event; it is passed on to the base class once
the workers and folded modules have been stopped.
"""
from ..bridge import drain_thread
if not self._save_boxes_if_needed():
event.ignore()
return
if self._blind is not None:
try:
from ...run_journal import _close_blinding
_close_blinding(self._blind["key_id"],
reason="the screen was closed")
except Exception:
LOG.debug("could not log the end of a blinded session",
exc_info=True)
self._blind = None
download, self._cp_download = self._cp_download, None
if download is not None:
download.cancel()
self._magnifier.close()
self._prompter.close()
self._primary_selector.shutdown()
self._psf_controls._shutdown()
self._restoration_controls._shutdown()
self._canvas.close_enhancer()
self._cancel_comparison()
if self._comparison_worker is not None:
self._comparison_worker.close(timeout=0)
self.close_folded()
self._pending_load = None
worker, self._load_worker = self._load_worker, None
if worker is not None:
try:
worker.requestInterruption()
except (AttributeError, RuntimeError):
pass
if not drain_thread(worker, timeout_ms=5000):
worker.setParent(None)
self._loading = False
super().closeEvent(event)
def _load_pair(self, folder: str, filename: str, token: int) -> None:
"""Decode and apply a small pair synchronously."""
try:
image, mask = engine.load_image_and_mask(
folder, filename, **self._layout_kwargs())
except Exception as exc:
self._handle_load_failure(exc)
return
self._apply_loaded_pair(filename, token, image, mask)
def _handle_load_failure(self, error: Exception) -> None:
"""Clear stale canvas state and visibly report an image-load error."""
self._primary_selector.clear_field()
self._canvas.preserve_ids = False
self._canvas.manual_ids = False
self._manual_id_history = {}
self._paired_source = None
self._canvas.image = None
self._canvas.mask = None
self._canvas.boxes = []
self._box_field = None
self._boxes_dirty = False
self._box_history.clear()
self._sync_button_states()
self._canvas.reset_zoom(silent=True)
self._canvas.clear()
self._history.clear()
self._log = None
self._loaded_mask = None
self._loaded_from_save_path = False
self._last_saved_mask = None
self._refresh_history_buttons()
self._btn_reset_zoom.setEnabled(False)
self._warn("Load failed", str(error))
def _apply_loaded_pair(
self,
filename: str,
token: int,
image: np.ndarray,
mask: np.ndarray,
) -> None:
"""Install a decoded pair if it still represents the selected field.
The magnifier is told which field this is BEFORE the pair reaches the
canvas: the canvas makes it forget the last one on the way in, and
the name is what its whole-image objects are kept under, so coming
back to this field offers them rather than segmenting it again.
"""
if token != self._load_token:
return
self._canvas.preserve_ids = False
self._paired_source = None
self._magnifier.set_field(os.path.join(self._folder or "", filename))
self._close_levels()
self._canvas.set_image_and_mask(image, mask)
self._load_box_annotations(filename, image)
self._canvas.ruler.calibrate_from_file(
os.path.join(self._folder or "", filename), image.shape)
self._loaded_mask = np.array(mask, copy=True)
self._last_saved_mask = self._loaded_mask
self._loaded_from_save_path = os.path.isfile(engine.mask_save_path(
self._folder, filename, **self._layout_kwargs()))
self._recrop_children = []
self._reset_flow_panes()
self._history.clear()
self._history.push(mask)
self._refresh_history_buttons()
self._btn_reset_zoom.setEnabled(False)
self._log = self._open_ledger(filename)
manual = next((edit.detail for edit in reversed(self._log.edits)
if 'manual_ids' in edit.detail), {})
key = self._manual_mask_key(mask)
self._canvas.manual_ids = bool(manual.get('manual_ids')
and manual.get('manual_mask_sha256') == key)
self._manual_id_history = {key: self._canvas.manual_ids}
self._canvas._lookup = None
self._canvas._lookup_dirty = True
record = next((edit.detail.get('primary_source') for edit in reversed(self._log.edits)
if edit.detail.get('preserve_ids')), None)
if record:
self._canvas.preserve_ids = True
self._canvas._lookup = None
self._canvas._lookup_dirty = True
self._paired_source = record
self._primary_selector.bind_field(os.path.join(self._folder, filename), mask.shape,
engine.mask_save_path(self._folder, filename, **self._layout_kwargs()))
if record:
self._primary_selector.restore_source(record)
self._status_label.setText(
f"{self._blind_label(os.path.join(self._folder or '', filename))} "
f"({self._current_index + 1}/{len(self._image_files)})"
)
self.apply_object_filter(on_load=True)
self._refresh_curation_buttons()
self._show_session_notice()
self._magnifier.refresh()
def _open_ledger(self, filename: str) -> CurationLog:
"""The ledger for one field, ready to be appended to.
Read from beside the mask so this session continues the record
rather than starting a new one — the log is written back whole, and
a fresh one would erase what an earlier session, or the napari
round-trip, recorded about the same mask. A ledger that already
names a source keeps it: the tool that made each edit is recorded on
the edit, not on the file.
"""
artifact = engine.mask_save_path(self._folder, filename,
**self._layout_kwargs())
try:
log = CurationLog.read_beside(artifact)
except Exception as exc:
LOG.warning("Unreadable curation ledger beside %s: %s",
artifact, exc)
log = CurationLog()
if not log.artifact:
log.artifact = artifact
log.source = engine.CURATION_SOURCE
return log
def _on_recrop_requested(self, x0: int, y0: int, x1: int, y1: int) -> None:
"""Handle a box dragged with the Recrop tool."""
self.recrop(x0, y0, x1, y1)
[docs]
def recrop(self, x0: int, y0: int, x1: int, y1: int) -> Optional[str]:
"""Extract one selected region into a separate training field.
Accepted crops are written immediately, inserted after the source
field in the queue, and marked on the canvas. Rejected selections are
reported in the status label without modifying the queue.
:param x0: x of the first selection corner, in image pixels.
:param y0: y of the first selection corner, in image pixels.
:param x1: x of the opposite corner, in image pixels. The corners may
come in either order and are clipped to the image.
:param y1: y of the opposite corner, in image pixels.
:returns: Filename of the recropped field, or ``None`` if the
selection was rejected or could not be written.
"""
if self._canvas.boxes:
from ..i18n import tr
self._status_label.setText(tr("Remove this image's box annotations before recropping it."))
return None
if self._blind is not None:
from ..i18n import tr
self._status_label.setText(tr(
"Recrop is off while blinded, because the new fields are "
"named after the field they are cut from."))
return None
if getattr(self._canvas, 'preserve_ids', False):
from ..i18n import tr
self._status_label.setText(tr('Recrop requires a matching crop of both primary and secondary masks. Save this paired field before creating a separate crop.'))
return None
if not self._image_files or self._canvas.mask is None \
or self._canvas.image is None:
self._status_label.setText("Recrop: no field open to cut.")
return None
filename = self._image_files[self._current_index]
try:
box = engine.recrop_box(self._canvas.mask.shape, (x0, y0), (x1, y1),
existing=self._canvas.recrop_boxes)
except engine.RecropRefused as refusal:
self._status_label.setText(f"Recrop: {refusal}")
return None
try:
written = engine.write_recrop(
self._folder, filename, self._canvas.image,
self._canvas.mask, box, **self._layout_kwargs())
except Exception as exc:
self._warn("Recrop failed", str(exc))
return None
name = engine.field_stem(written.name)
self._canvas.recrop_boxes.append((*box, name))
self._canvas.update()
self._image_files.insert(
self._current_index + len(self._recrop_children) + 1, written.name)
if self._field_folders:
self._field_folders.insert(
self._current_index + len(self._recrop_children) + 1,
self._folder)
self._recrop_children.append(written.name)
area = (box[2] - box[0]) * (box[3] - box[1])
self._record(engine.RECROP_KIND, written.name, area,
box=[int(v) for v in box],
n_objects=int(written.n_objects))
self._status_label.setText(
f"Recrop {name}: {box[2] - box[0]}x{box[3] - box[1]} px, "
f"{written.n_objects} whole object(s), queued next "
f"({len(self._recrop_children)} so far). "
f"{filename} is retired when you move on."
)
return written.name
[docs]
def finish_recrop(self) -> bool:
"""Archive a recropped source field and advance to its first child.
The source image, mask, and ledger move to
``recropped_originals/`` and remain recoverable through the recrop
manifest.
:returns: ``True`` if a field was archived.
"""
if not self._recrop_children or not self._image_files:
return False
if not self._save_boxes_if_needed():
return False
filename = self._image_files[self._current_index]
children = list(self._recrop_children)
boxes = [tuple(int(v) for v in box[:4])
for box in self._canvas.recrop_boxes]
if self._canvas.mask is not None:
try:
engine.save_mask(self._folder, filename, self._canvas.mask,
log=self._log, preserve_ids=getattr(self._canvas, 'preserve_ids', False),
**self._layout_kwargs())
except Exception as exc:
LOG.warning("Could not save %s before retiring it: %s",
filename, exc)
try:
engine.retire_recropped_original(
self._folder, filename, children=children, boxes=boxes,
**self._layout_kwargs())
except Exception as exc:
self._warn("Recrop failed", str(exc))
return False
self._image_files.pop(self._current_index)
if self._field_folders:
self._field_folders.pop(self._current_index)
self._recrop_children = []
self._canvas.recrop_boxes = []
if not self._image_files:
self._current_index = 0
self._canvas.image = None
self._canvas.mask = None
self._canvas.clear()
self._status_label.setText(
f"{filename} retired to {engine.RECROP_ARCHIVE_DIRNAME}/ — "
f"{len(children)} crop(s) written, queue empty.")
self._sync_button_states()
return True
self._current_index = min(self._current_index,
len(self._image_files) - 1)
self._load_current()
self._status_label.setText(
f"{filename} retired to {engine.RECROP_ARCHIVE_DIRNAME}/ — "
f"{len(children)} crop(s) next.")
self._sync_button_states()
return True
def _on_prev(self):
"""Go to the previous field, retiring this one if it was cut up."""
if not self._save_mask_if_needed() or not self._save_boxes_if_needed():
return
self.finish_recrop()
if not self._image_files or self._current_index <= 0:
return
self._current_index -= 1
self._load_current()
def _on_next(self):
"""Go to the next field, retiring this one if it was cut up."""
self._move_next(save_mask=True)
def _move_next(self, *, save_mask: bool):
"""Advance after optionally saving mask edits; Skip never writes one."""
if save_mask and not self._save_mask_if_needed():
return
if not self._save_boxes_if_needed():
return
if self.finish_recrop():
return
if not self._image_files or self._current_index >= len(self._image_files) - 1:
return
self._current_index += 1
self._load_current()
def _save_would_change_nothing(self) -> bool:
"""Whether the file a save would write already holds the canvas mask.
True only when the field's mask was read from that very file, the
file is still there, and the canvas holds exactly the labels it
opened with. Writing anyway is not neutral: every save goes through
:func:`spacr.qt.mask_engine.canonical_labels`, which splits a label
lying in two separated pieces, so "open, look, save" used to add an
object the curator never drew.
:returns: whether the save may leave the file untouched.
"""
loaded = self._loaded_mask
mask = self._canvas.mask
if loaded is None or mask is None or not self._loaded_from_save_path:
return False
path = engine.mask_save_path(
self._folder, self._image_files[self._current_index],
**self._layout_kwargs())
return (os.path.isfile(path) and loaded.shape == mask.shape
and bool(np.array_equal(loaded, mask)))
def _save_mask_if_needed(self) -> bool:
"""Save edited mask pixels before navigation when the user opted in."""
if not self._btn_save_on_navigation.isChecked():
return True
if not self._image_files or self._canvas.mask is None:
return True
if self._loading or self._last_saved_mask is None:
return False
mask = self._canvas.mask
saved = self._last_saved_mask
if saved.shape == mask.shape and np.array_equal(saved, mask):
return True
return self._save_mask() is not None
def _on_skip(self) -> None:
"""Record the field on screen as skipped and move to the next one.
Skip is not a lesser done. It records that this field cannot be
curated, so the next session does not offer it again, and it writes
no mask: whatever is on disk for the field stays exactly as it was.
Only a queue has a record to write it to, so the control does
nothing for a folder opened from the file dialog.
"""
from ..i18n import tr
if self._queue is None or not self._image_files:
return
if not self._save_boxes_if_needed():
return
from ...curation_queue import mark_state
filename = self._image_files[self._current_index]
stem = engine.field_stem(filename)
try:
mark_state(self._queue.folder, stem, "skip")
except Exception as exc: # noqa: BLE001
LOG.warning("could not record %s as skipped", stem, exc_info=True)
self._warn(tr("Skip failed"), str(exc))
return
judged = os.path.basename(filename)
was = self._current_index
self._move_next(save_mask=False)
if self._current_index != was:
now = os.path.basename(self._image_files[self._current_index])
self._status_label.setText(
tr("{judged} skipped, and will not be offered again — now on {now}").format(
judged=judged, now=now))
else:
self._status_label.setText(
tr("{judged} skipped, and will not be offered again — that was the last field in this session").format(
judged=judged))
def _on_save(self):
"""Write the mask for the field on screen.
A save that changed nothing writes nothing: the field is still
recorded as done, with the object count of the file already on disk,
but that file is left byte for byte as it was. An edited save
records the object count of the labels as written, after
:func:`spacr.qt.mask_engine.canonical_labels` has split any label
lying in separated pieces, so the count matches the file. Deliberate
manual groupings and paired secondary masks retain their exact IDs
instead, including disconnected regions sharing one identity.
"""
if self._canvas.mode == MODE_BOX:
return self._on_save_boxes()
return self._save_mask()
def _save_mask(self):
"""Save the visible mask regardless of the selected editing tool."""
if not self._image_files or self._canvas.mask is None:
return None
if self._save_would_change_nothing():
from ..i18n import tr
filename = self._image_files[self._current_index]
objects = int(np.count_nonzero(np.unique(self._canvas.mask)))
self._note_curated(filename, n_objects=objects)
path = engine.mask_save_path(self._folder, filename,
**self._layout_kwargs())
self._save_puncta_measurements(path)
self._export_vvvv_on_save()
self._status_label.setText(
tr("Unchanged, nothing rewritten → {path}").format(path=path))
self._last_saved_mask = np.array(self._canvas.mask, copy=True)
return path
preserve_ids = self._canvas.retain_label_ids()
try:
self._validate_secondary_save()
path = engine.save_mask(
self._folder,
self._image_files[self._current_index],
self._canvas.mask,
log=self._log,
preserve_ids=preserve_ids,
**self._layout_kwargs(),
)
except Exception as e:
self._warn("Save failed", str(e))
return None
edits = len(self._log) if self._log is not None else 0
note = f" ({edits} edit(s) recorded)" if edits else ""
written = engine.canonical_labels(self._canvas.mask,
preserve_ids=preserve_ids)
objects = int(np.count_nonzero(np.unique(written)))
self._note_curated(self._image_files[self._current_index],
n_objects=objects)
self._status_label.setText(f"Saved → {path}{note}")
self._save_puncta_measurements(path)
self._export_vvvv_on_save()
self._last_saved_mask = np.array(self._canvas.mask, copy=True)
return path
def _save_puncta_measurements(self, mask_path):
"""Save source-bound centre measurements only for an unchanged full detection.
Editing or combining labels never reattaches old centre values to
new objects. Existing tables retain their original hash receipts.
:param mask_path: mask file just saved, or its unchanged existing file.
:returns: the CSV path when exported, otherwise None.
"""
import hashlib
import json
import tempfile
from pathlib import Path
from ..i18n import tr
from ...tabular import write_table
snapshot = getattr(self, '_puncta_measurement_snapshot', None)
if snapshot is None or not os.path.isfile(mask_path):
return None
image_path = os.path.realpath(os.path.join(self._folder, self._image_files[self._current_index]))
if (snapshot['image_path'] != image_path
or not np.array_equal(snapshot['labels'], self._canvas.mask)):
return None
pending = []
base = os.path.splitext(mask_path)[0] + '.puncta'
try:
for path, expected in ((image_path, snapshot['image_sha256']),
(snapshot['parent']['path'], snapshot['parent']['sha256'])):
with open(path, 'rb') as stream:
if hashlib.file_digest(stream, 'sha256').hexdigest() != expected:
self._warn(tr('Centre measurements not saved'), tr('The source image or parent mask changed. Reload and repeat detection.'))
return None
with open(mask_path, 'rb') as stream:
mask_sha256 = hashlib.file_digest(stream, 'sha256').hexdigest()
for suffix in ('.csv', '.json'):
descriptor, path = tempfile.mkstemp(prefix='.puncta-', suffix=suffix, dir=os.path.dirname(mask_path))
pending.append(path)
os.close(descriptor)
write_table(snapshot['candidates'], pending[0], canonicalise=False)
with open(pending[0], 'rb') as stream:
csv_sha256 = hashlib.file_digest(stream, 'sha256').hexdigest()
receipt = {key: value for key, value in snapshot.items() if key not in ('labels', 'candidates')}
receipt.update(schema=1, mask_path=os.path.realpath(mask_path), mask_sha256=mask_sha256,
csv_path=os.path.realpath(base + '.csv'), csv_sha256=csv_sha256,
intensity_units='native AU', center_overlap_policy='nearest centre; peak reserved; stronger-first ties',
display_normalization_applied=False, post_detection_editing=False)
Path(pending[1]).write_text(json.dumps(receipt, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
os.replace(pending[0], base + '.csv')
os.replace(pending[1], base + '.json')
except (OSError, ValueError) as error:
self._warn(tr('Centre measurements not saved'),
tr('The mask was saved, but its centre-measurement export failed: {error}').format(error=error))
return None
finally:
for path in pending:
if os.path.exists(path):
os.unlink(path)
return base + '.csv'
def _apply_op(self, op, kind: str = "edit", **detail):
"""Run a mask -> mask function, refresh, record it, push to history.
:param kind: the verb this operation goes into the ledger under.
One word per button, so the ledger's own summary counts the
buttons the user pressed.
:param detail: anything about the operation worth keeping with the
entry, such as the threshold a removal used.
"""
if self._canvas.mask is None:
return
out = op(self._canvas.mask)
changed = self._pixels_changed(out)
self._canvas.mask = out
self._canvas.refresh()
self._record(kind, None, changed, **detail)
self._history.push(self._canvas.mask)
self._refresh_history_buttons()
def _on_fill_holes(self):
"""Fill enclosed holes in every object."""
self._apply_op(lambda mask: engine.fill_holes(
mask, preserve_ids=self._canvas.retain_label_ids()), 'fill_holes')
def _on_relabel(self):
"""Renumber the objects so the labels are consecutive."""
if self._canvas.retain_label_ids():
from ..i18n import tr
self._status_label.setText(
tr('Paired secondary objects retain primary IDs; consecutive relabeling would break that association.')
if getattr(self._canvas, 'preserve_ids', False) else
tr('Merged objects retain their shared IDs. Undo the merge before relabeling objects.'))
return
self._apply_op(engine.relabel_objects, "relabel")
def _on_invert(self):
"""Swap object and background in the MASK, and say what that gave.
Not the picture invert -- that is :meth:`_on_invert_display`, which
is what a curator expecting Invert to change the image wants.
This one is kept under the name that describes it, and it now
reports its own result, because the thing that made it look broken
is that its result is invisible: a field whose background is one
connected region comes back as a single object covering the frame,
which the overlay draws as one flat wash.
"""
if self._canvas.mask is None:
return
self._apply_op(engine.invert_mask, "invert")
found = self._objects_now()
self._status_label.setText(
f"Swapped object and background — {found} object(s) now, and "
f"what was an object is background. Ctrl+Z to undo."
)
def _on_remove_small(self):
"""Delete objects below the minimum area."""
area = int(self._min_area.value())
if self._canvas.retain_label_ids():
self._apply_op(lambda mask: engine.filter_report(
mask, self._canvas.image, min_area=area, preserve_ids=True)[0],
'remove_small', min_area=area)
return
self._apply_op(lambda m: engine.remove_small_objects(m, area),
"remove_small", min_area=area)
def _objects_now(self) -> int:
"""How many objects the open mask holds, by distinct id.
Not ``mask.max()``: a mask that has had objects deleted out of the
middle of it has ids with gaps, and the largest id is then a count
of what has ever been there rather than of what is there.
:func:`_object_count` is the same count, and is what the magnifier
reports its own results with.
"""
field = self._canvas.mask
if field is None or not np.asarray(field).size:
return 0
return _object_count(field)
def _on_dilate(self):
"""Grow every object by the step, into background only."""
if self._canvas.mask is None:
return
step = int(self._grow_step.value())
before = self._objects_now()
self._apply_op(lambda m: engine.dilate_objects(m, step),
"dilate", step=step)
self._status_label.setText(
f"Dilated {before} object(s) by {step} px — no object took a "
f"pixel from another.")
def _on_shrink(self):
"""Pull every object in by the step, and say what that cost.
THE COUNT IS THE POINT OF THE MESSAGE. Erosion deletes anything
thinner than twice the step, and a curator who has just lost eleven
objects to a step of 3 needs to be told so while the undo is still
the obvious thing to do.
BOTH NUMBERS ARE COUNTED BEFORE THE EDIT, and the first of them is
deliberately the count the button was PRESSED on rather than the
count that survived. Reporting the survivors read as an arithmetic
puzzle -- ten objects, seven erased, "Shrank 3 object(s) ... 7
object(s) are gone" -- and erasing every object reported "Shrank 0
object(s)" over a field that had just been emptied. Every object
was eroded; some of them did not survive it, which is the second
clause.
"""
if self._canvas.mask is None:
return
step = int(self._grow_step.value())
before = self._objects_now()
self._apply_op(lambda m: engine.shrink_objects(m, step),
"shrink", step=step)
gone = max(0, before - self._objects_now())
lost = (f" — {gone} of them were thinner than {2 * step} px and "
f"are gone; Undo brings them back" if gone else "")
self._status_label.setText(
f"Shrank {before} object(s) by {step} px{lost}.")
def _on_clear_mask(self):
"""Throw the whole mask away, after confirming.
The confirmation says how many objects are about to go: "Zero out
the current mask?" alone did not, and the count is the one fact that
decides the question.
"""
from ..i18n import tr
if self._canvas.mask is None:
return
count = self._objects_now()
if not self._confirm(
tr("Clear mask"),
tr("Remove all {count} object(s) from this field? The field "
"itself is untouched, and Undo brings the objects back.", count=count)):
return
self.clear_mask()
[docs]
def clear_mask(self) -> None:
"""Zero the current mask *without* asking, recording it in history.
The confirmation lives in :meth:`_on_clear_mask`; this is the
scriptable entry point (and what the undo stack sees).
"""
self._apply_op(engine.clear_mask, "clear")
def _on_stroke_started(self):
"""Remember identity policy before a stroke mutates labels in place.
MaskHistory already holds the prior array. Its exact label digest
also binds the manual grouping policy that undo must restore.
"""
if self._canvas.mask is not None:
self._manual_id_history[self._manual_mask_key(self._canvas.mask)] = self._canvas.manual_ids
def _on_stroke_finished(self):
"""Record and commit the gesture the canvas just finished.
Recorded BEFORE the history push, because the entry's size is
measured against the snapshot the gesture started from and pushing
would make that snapshot the gesture's own result — every edit would
then be recorded as having changed nothing.
"""
if self._canvas.mask is None:
return
edit = self._canvas.last_edit or {}
self._record(str(edit.get("kind") or "paint"), edit.get("target"),
self._pixels_changed(self._canvas.mask),
**dict(edit.get("detail") or {}))
self._history.push(self._canvas.mask)
self._refresh_history_buttons()
def _sync_button_states(self):
"""Enable each control only when it has something to act on."""
has_files = bool(self._image_files)
editable = has_files and not self._loading
for b in (self._btn_prev, self._btn_next, self._btn_save,
self._box_class_combo, self._btn_add_box_class, self._btn_export_yolo,
self._btn_save_boxes,
self._btn_discard, self._btn_keep,
self._btn_filter, self._btn_otsu, self._btn_magnifier,
self._btn_dilate, self._btn_shrink, self._btn_clear,
self._btn_levels,
*self._mode_buttons.values()):
b.setEnabled(editable)
self._btn_skip.setEnabled(editable and self._queue is not None)
self._btn_prompt.setEnabled(editable)
self._box_controls.setEnabled(editable and self._box_load_error is None
and self._box_field is not None
and self._canvas.image is not None)
self._btn_export_yolo.setEnabled(editable and self._blind is None
and self._box_load_error is None)
self._mode_buttons[MODE_BOX].setEnabled(editable and self._box_load_error is None)
self._sync_queue_tally()
class _Sam2ClickLabel(QLabel):
"""A label showing one frame that reports clicks in image pixels."""
clicked = Signal(int, int, bool)
def __init__(self, parent=None):
"""Start at a display scale of one screen pixel per image pixel."""
super().__init__(parent)
self.scale = 1.0
def mousePressEvent(self, event):
"""Emit the clicked pixel and whether it was a right click."""
pos = event.position()
self.clicked.emit(int(pos.x() / self.scale), int(pos.y() / self.scale),
event.button() == Qt.RightButton)
class _Sam2SeedDialog(QDialog):
"""Seed objects in a movie by clicking and follow them with SAM2.
A left click on a frame puts a small disc of the current object there; a
right click removes that object's seed from the frame. Propagate sends
every seed to SAM2 (:func:`spacr._segmentation_backends._sam2_propagate`)
in the background and shows the result over the frames. To correct a
frame, click the object there again and propagate once more: a label
seeded on several frames is corrected on each of them. Save writes the
labels, the tracks table (with division parents) and the seeds and
settings that made them into a ``sam2`` folder beside the movie.
"""
finished_run = Signal(object, object)
def __init__(self, frames, propagate=None, radius=4, parent=None,
source_path=None):
"""Show ``frames`` for seeding; ``propagate`` defaults to SAM2."""
from PySide6.QtWidgets import QCheckBox
from ..i18n import tr
super().__init__(parent)
self.setWindowTitle(tr("SAM2 tracking"))
self.source_path = source_path
self.frames = np.asarray(frames, dtype=np.uint8)
self.seeds: dict = {}
self.labels = None
self.radius = int(radius)
self._propagate = propagate
self._thread = None
layout = QVBoxLayout(self)
self.view = _Sam2ClickLabel(self)
self.view.clicked.connect(self._on_click)
layout.addWidget(self.view)
self.slider = QSlider(Qt.Horizontal, self)
self.slider.setRange(0, len(self.frames) - 1)
self.slider.valueChanged.connect(self._redraw)
layout.addWidget(self.slider)
row = QHBoxLayout()
row.addWidget(QLabel(tr("Object"), self))
self.object_box = QSpinBox(self)
self.object_box.setRange(1, 65535)
row.addWidget(self.object_box)
self.new_button = QPushButton(tr("New object"), self)
self.new_button.clicked.connect(self._new_object)
row.addWidget(self.new_button)
self.backward_box = QCheckBox(tr("Also follow backward"), self)
row.addWidget(self.backward_box)
self.run_button = QPushButton(tr("Propagate"), self)
self.run_button.clicked.connect(self.run)
row.addWidget(self.run_button)
self.save_button = QPushButton(tr("Save"), self)
self.save_button.setToolTip(tr(
"Write the propagated labels (TIFF), the tracks table with each "
"daughter's parent track (CSV) and the seeds and settings used "
"into a sam2 folder beside the movie."))
self.save_button.setEnabled(False)
self.save_button.clicked.connect(lambda: self.save())
row.addWidget(self.save_button)
layout.addLayout(row)
self.status = QLabel(tr("Click an object to seed it."), self)
layout.addWidget(self.status)
self.finished_run.connect(self._on_finished)
self._redraw()
def _new_object(self):
"""Move to an object id not seeded yet."""
used = [int(s.max()) for s in self.seeds.values()] or [0]
self.object_box.setValue(max(used) + 1)
def _on_click(self, x, y, remove):
"""Seed or unseed the current object at a pixel of this frame."""
t = self.slider.value()
h, w = self.frames.shape[1:3]
if not (0 <= x < w and 0 <= y < h):
return
obj = self.object_box.value()
seed = self.seeds.setdefault(t, np.zeros((h, w), np.int32))
if remove:
seed[seed == obj] = 0
else:
yy, xx = np.ogrid[:h, :w]
seed[(yy - y) ** 2 + (xx - x) ** 2 <= self.radius ** 2] = obj
if not seed.any():
del self.seeds[t]
self._redraw()
def run(self):
"""Propagate every seed through the movie in the background."""
import threading
from ..i18n import tr
if not self.seeds:
self.status.setText(tr("Click an object to seed it."))
return
propagate = self._propagate
if propagate is None:
from ..._segmentation_backends import _sam2_propagate as propagate
seeds = {t: s.copy() for t, s in self.seeds.items()}
backward = self.backward_box.isChecked()
self.run_button.setEnabled(False)
self.status.setText(tr("Propagating…"))
def work():
"""Propagate the seeds off the GUI thread and report the labels."""
try:
labels, _reply = propagate(self.frames, seeds,
backward=backward)
self.finished_run.emit(labels, None)
except Exception as exc:
self.finished_run.emit(None, exc)
self._thread = threading.Thread(target=work, daemon=True)
self._thread.start()
def _on_finished(self, labels, error):
"""Show the propagated labels, or why propagation failed."""
from ..i18n import tr
self.run_button.setEnabled(True)
if error is not None:
self.status.setText(tr("SAM2 failed: {error}").format(error=error))
return
self.labels = np.asarray(labels)
self._last_backward = self.backward_box.isChecked()
self._last_seeds = {t: s.copy() for t, s in self.seeds.items()}
self.save_button.setEnabled(True)
self.status.setText(tr(
"Done. Click an object on a wrong frame and propagate again."))
self._redraw()
def save(self, folder=None):
"""Write the propagated labels, tracks, seeds and settings.
Files are named after the movie: ``<stem>_sam2_masks.tif`` (the
labels, one id per object across frames), ``<stem>_sam2_tracks.csv``
(spaCR's tracks table with ``parent_track_id``),
``<stem>_sam2_seeds.npz`` (the seeds of the last propagation) and
``<stem>_sam2_settings.json`` (movie, backward pass, seeded frames
and objects).
:param folder: where to write; ``sam2`` beside the movie when None,
or a folder the user picks when the movie has no path.
:returns: the folder written to, or None when nothing was saved.
"""
import json
from ...tabular import write_table
from ...tiff_io import write_tiff
from ...timelapse import (_native_lineage_columns, _relabelled_stack_to_tracks_df,
_sam2_parent_links)
from ..i18n import tr
if self.labels is None:
self.status.setText(tr("Propagate before saving."))
return None
if folder is None:
if self.source_path:
folder = os.path.join(os.path.dirname(self.source_path), "sam2")
else:
folder = QFileDialog.getExistingDirectory(
self, tr("Choose a folder for the SAM2 results"))
if not folder:
return None
os.makedirs(folder, exist_ok=True)
stem = (os.path.splitext(os.path.basename(self.source_path))[0]
if self.source_path else "movie")
labels = np.asarray(self.labels).astype(np.int32)
write_tiff(os.path.join(folder, f"{stem}_sam2_masks.tif"), labels,
photometric="minisblack")
tracks = _relabelled_stack_to_tracks_df(labels)
if not tracks.empty:
tracks = _native_lineage_columns(tracks, _sam2_parent_links(labels), "sam2")
write_table(tracks, os.path.join(folder, f"{stem}_sam2_tracks.csv"))
seeds = getattr(self, "_last_seeds", self.seeds)
np.savez_compressed(os.path.join(folder, f"{stem}_sam2_seeds.npz"),
**{f"frame_{t}": s for t, s in seeds.items()})
settings = {
"movie": self.source_path,
"frames": int(labels.shape[0]),
"backward": bool(getattr(self, "_last_backward",
self.backward_box.isChecked())),
"seed_radius": self.radius,
"seeds": {str(t): sorted(int(v) for v in np.unique(s) if v)
for t, s in sorted(seeds.items())},
}
with open(os.path.join(folder, f"{stem}_sam2_settings.json"), "w",
encoding="utf-8") as handle:
json.dump(settings, handle, indent=2)
self.status.setText(tr("Saved to {folder}").format(folder=folder))
return folder
def _redraw(self, *_args):
"""Draw the frame on screen with its labels and seeds over it."""
t = self.slider.value()
rgb = np.repeat(self.frames[t][..., None], 3, axis=2).copy()
if self.labels is not None:
rgb[self.labels[t] > 0, 1] = 255
if t in self.seeds:
rgb[self.seeds[t] > 0] = (255, 0, 0)
h, w = rgb.shape[:2]
image = QImage(rgb.data, w, h, 3 * w, QImage.Format_RGB888).copy()
self.view.scale = min(1.0, 512.0 / max(h, w)) if max(h, w) > 512 \
else float(max(1, 512 // max(h, w)))
self.view.setPixmap(scaled_for(
QPixmap.fromImage(image), self.view,
int(w * self.view.scale), int(h * self.view.scale),
mode=Qt.FastTransformation))