Source code for spacr.qt.screens.make_masks

"""
MakeMasksScreen — Qt widget replacing the Tk ModifyMaskApp.

Load a folder of images and their masks (in `<folder>/masks/`), draw
brush/erase strokes on the mask, run object-level operations (fill,
relabel, invert, remove small), zoom into a region for detailed edits,
use a magic-wand flood-fill by intensity, undo/redo, and save the
edited mask back to `<folder>/masks/<name>.tif` as a labeled uint16
mask.

Advanced features still deferred (noted in the toolbar):
dividing line and free-form polygon draw.
"""
from __future__ import annotations

import os
from typing import List, Optional

import numpy as np
from PySide6.QtCore import QPoint, QPointF, QRect, QRectF, Qt, Signal
from PySide6.QtGui import (
    QAction,
    QColor,
    QCursor,
    QImage,
    QKeySequence,
    QPainter,
    QPen,
    QPixmap,
    QShortcut,
    QWheelEvent,
)
from PySide6.QtWidgets import (
    QCheckBox,
    QDoubleSpinBox,
    QFileDialog,
    QFormLayout,
    QFrame,
    QHBoxLayout,
    QLabel,
    QMessageBox,
    QPushButton,
    QScrollArea,
    QSizePolicy,
    QSlider,
    QSpinBox,
    QSplitter,
    QStackedWidget,
    QVBoxLayout,
    QWidget,
)

from .. import iconset
from .. import mask_engine as engine
from .. import prefs
from ..theme import PALETTE, SPACING
from ..widgets import Card, Divider, EmptyState, Section


# ---------------------------------------------------------------------------
# Canvas — image + mask overlay with brush/erase mouse handling
# ---------------------------------------------------------------------------

[docs] MODE_NONE = "none"
[docs] MODE_BRUSH = "brush"
[docs] MODE_ERASE = "erase"
[docs] MODE_ERASE_OBJECT = "erase_object"
[docs] MODE_WAND_ADD = "wand_add"
[docs] MODE_WAND_ERASE = "wand_erase"
[docs] MODE_ZOOM = "zoom"
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 / zoom-rectangle 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). """ stroke_started = Signal() # emitted just before self.mask is mutated stroke_finished = Signal() # emitted after a stroke completes zoom_changed = Signal(bool) # emitted with True when zoom entered / False on reset def __init__(self, parent: Optional[QWidget] = None): super().__init__(parent) self.image: Optional[np.ndarray] = None # uint16 grayscale self.mask: Optional[np.ndarray] = None # uint8 labels self.mode: str = MODE_NONE self.brush_radius: int = 10 self.norm_lo: float = 1.0 self.norm_hi: float = 99.9 self.wand_tolerance: float = 1000.0 self.wand_max_pixels: int = 100_000 # Zoom viewport in image coords; None = full-image view. self._zoom_x0: Optional[int] = None self._zoom_y0: Optional[int] = None self._zoom_x1: Optional[int] = None self._zoom_y1: Optional[int] = None # Zoom-rectangle drag state (widget-local pixel coords) self._zoom_drag_start: Optional[QPoint] = None self._zoom_drag_end: Optional[QPoint] = None self.setAlignment(Qt.AlignCenter) self.setStyleSheet(f"background: {PALETTE['bg']};") self.setMouseTracking(True) self.setMinimumSize(600, 400) self._last_pt: Optional[QPoint] = None self._stroke_in_progress = False # ------------------------------------------------------------------ # Data # ------------------------------------------------------------------ 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.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 refresh(self) -> None: """Recompose image + mask overlay and repaint the canvas pixmap.""" if self.image is None or self.mask is None: return img = engine.normalize_uint16(self.image, 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 = pixmap.scaled(avail_w, avail_h, Qt.KeepAspectRatio, Qt.SmoothTransformation) self.setPixmap(pixmap) # ------------------------------------------------------------------ # Coordinate mapping (widget-local px ↔ full image px) # ------------------------------------------------------------------ def _canvas_to_image(self, x: float, y: float) -> Optional[tuple]: if self.mask is None or self.pixmap() is None: return None p = self.pixmap() pw, ph = p.width(), p.height() if pw == 0 or ph == 0: return None 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) # Clamp to image bounds 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 _mask_radius_for_brush(self) -> int: """Scale the brush radius (in screen px) to full-image px, taking the current zoom into account.""" if self.mask is None or self.pixmap() is None: return self.brush_radius p = self.pixmap() if p.width() == 0: return self.brush_radius x0, _, x1, _ = self._viewport_bounds() sub_w = max(1, x1 - x0) return max(1, int(self.brush_radius * sub_w / p.width())) # ------------------------------------------------------------------ # Painting (adds a zoom-rectangle overlay while dragging) # ------------------------------------------------------------------ def paintEvent(self, event): """Draw the base pixmap plus a dashed zoom-rectangle when dragging.""" super().paintEvent(event) if self.mode != MODE_ZOOM: return if self._zoom_drag_start is None or self._zoom_drag_end is None: return painter = QPainter(self) pen = QPen(QColor(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) # ------------------------------------------------------------------ # Mouse events # ------------------------------------------------------------------ def _emit_stroke_start(self): if not self._stroke_in_progress: self._stroke_in_progress = True self.stroke_started.emit() def _emit_stroke_end(self): if self._stroke_in_progress: self._stroke_in_progress = False self.stroke_finished.emit() def mousePressEvent(self, event): """Dispatch a click to the current tool (brush/erase/wand/zoom/…).""" if self.mode == MODE_NONE or self.mask is None: return super().mousePressEvent(event) if self.mode == MODE_ZOOM: 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 self._emit_stroke_start() if self.mode == MODE_ERASE_OBJECT: self.mask = engine.erase_object_at(self.mask, *pt) self.refresh() self._emit_stroke_end() return if self.mode in (MODE_WAND_ADD, MODE_WAND_ERASE): action = "add" if self.mode == MODE_WAND_ADD else "erase" self.mask = engine.magic_wand( self.image, self.mask, pt[0], pt[1], self.wand_tolerance, self.wand_max_pixels, action=action, ) self.refresh() self._emit_stroke_end() return # Brush / erase strokes radius = self._mask_radius_for_brush() value = 255 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 mouseMoveEvent(self, event): """Extend a brush/erase stroke or a zoom-rectangle drag.""" if self.mask is None: return if self.mode == MODE_ZOOM 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_BRUSH, MODE_ERASE) and event.buttons() & Qt.LeftButton: pt = self._canvas_to_image(event.position().x(), event.position().y()) if pt is None: return radius = self._mask_radius_for_brush() value = 255 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): """Commit a zoom-rectangle or finalize a brush/erase stroke.""" if self.mode == MODE_ZOOM and self._zoom_drag_start is not None \ and self._zoom_drag_end is not None: # Convert both endpoints to image coords and commit 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 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._last_pt is not None: self._last_pt = None self._emit_stroke_end() def resizeEvent(self, event): """Refit the composited pixmap to the new canvas size.""" super().resizeEvent(event) self.refresh() # --------------------------------------------------------------------------- # MakeMasksScreen # ---------------------------------------------------------------------------
[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. """ def __init__(self, parent: Optional[QWidget] = None): super().__init__(parent) self._folder: str = "" self._image_files: List[str] = [] self._current_index: int = 0 self._history = engine.MaskHistory(capacity=25) self._build_ui() self._install_shortcuts() self._sync_button_states() # ------------------------------------------------------------------ def _build_ui(self): outer = QVBoxLayout(self) outer.setContentsMargins(SPACING["lg"], SPACING["lg"], SPACING["lg"], SPACING["lg"]) outer.setSpacing(SPACING["md"]) # Header header = QVBoxLayout() header.setContentsMargins(0, 0, 0, 0) header.setSpacing(4) title = QLabel("Make Masks") title.setObjectName("TitleHeading") header.addWidget(title) self._src_label = QLabel("No folder selected — click Open folder…") self._src_label.setObjectName("SubtitleSmall") header.addWidget(self._src_label) header_wrap = QWidget(); header_wrap.setLayout(header) outer.addWidget(header_wrap) outer.addWidget(Divider()) # Body — a stack: EmptyState until a folder is opened, then splitter 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) self._body_splitter = QSplitter(Qt.Horizontal) self._body_splitter.setChildrenCollapsible(False) self._canvas = _MaskCanvas() self._canvas.stroke_started.connect(self._on_stroke_started) self._canvas.stroke_finished.connect(self._on_stroke_finished) self._canvas.zoom_changed.connect(self._on_zoom_changed) self._body_splitter.addWidget(self._canvas) tools_scroll = QScrollArea() tools_scroll.setWidgetResizable(True) tools_scroll.setFrameShape(QScrollArea.NoFrame) tools_scroll.setWidget(self._build_tools_panel()) self._body_splitter.addWidget(tools_scroll) self._body_splitter.setStretchFactor(0, 3) self._body_splitter.setStretchFactor(1, 1) self._body_splitter.setSizes([900, 380]) self._body_stack.addWidget(self._body_splitter) self._body_stack.setCurrentWidget(self._empty_state) outer.addWidget(self._body_stack, 1) # Bottom nav bar nav = QWidget() nav_row = QHBoxLayout(nav) nav_row.setContentsMargins(0, 0, 0, 0) nav_row.setSpacing(SPACING["sm"]) self._btn_open = QPushButton("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) self._btn_prev = QPushButton("Prev image") self._btn_prev.setIcon(iconset.icon("prev")) self._btn_prev.setCursor(Qt.PointingHandCursor) self._btn_prev.clicked.connect(self._on_prev) nav_row.addWidget(self._btn_prev) self._btn_next = QPushButton("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) nav_row.addWidget(self._btn_next) self._btn_save = QPushButton("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) nav_row.addWidget(self._btn_save) nav_row.addStretch(1) self._status_label = QLabel("Ready.") self._status_label.setObjectName("SubtitleSmall") nav_row.addWidget(self._status_label) outer.addWidget(nav) def _build_tools_panel(self) -> QWidget: wrap = QWidget() col = QVBoxLayout(wrap) col.setContentsMargins(0, 0, 0, 0) col.setSpacing(SPACING["md"]) # Mode buttons — arranged as a 2×3 grid so buttons keep their labels mode_card = Card(title="Tools") from PySide6.QtWidgets import QGridLayout grid = QGridLayout() grid.setSpacing(SPACING["sm"]) self._mode_buttons: dict[str, QPushButton] = {} modes = [ (MODE_BRUSH, "Brush", "brush"), (MODE_ERASE, "Erase", "erase"), (MODE_ERASE_OBJECT, "Erase object", "erase_object"), (MODE_WAND_ADD, "Wand +", "wand_add"), (MODE_WAND_ERASE, "Wand −", "wand_erase"), (MODE_ZOOM, "Zoom", "zoom"), ] for i, (m, label, icon_key) in enumerate(modes): btn = QPushButton(label) btn.setIcon(iconset.icon(icon_key)) btn.setCheckable(True) btn.setMinimumHeight(32) btn.setCursor(Qt.PointingHandCursor) btn.clicked.connect(lambda _c=False, key=m: self._set_mode(key)) grid.addWidget(btn, i // 3, i % 3) self._mode_buttons[m] = 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_erase = self._mode_buttons[MODE_WAND_ERASE] self._btn_zoom = self._mode_buttons[MODE_ZOOM] mode_wrap = QWidget(); mode_wrap.setLayout(grid) mode_card.body_layout.addWidget(mode_wrap) # Reset zoom / undo redo row history_row = QHBoxLayout() history_row.setSpacing(SPACING["sm"]) 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) history_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) history_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) history_row.addWidget(self._btn_redo) hist_wrap = QWidget(); hist_wrap.setLayout(history_row) mode_card.body_layout.addWidget(hist_wrap) col.addWidget(mode_card) # Brush size slider brush_card = Card(title="Brush") brush_form = QFormLayout() self._brush_slider = QSlider(Qt.Horizontal) self._brush_slider.setRange(1, 100) self._brush_slider.setValue(10) 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) # Magic wand card wand_card = Card(title="Magic wand") wand_form = QFormLayout() 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.valueChanged.connect(self._on_wand_tolerance_changed) wand_form.addRow("Tolerance", 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.valueChanged.connect(self._on_wand_max_changed) wand_form.addRow("Max pixels", self._wand_max) wand_card.body_layout.addLayout(wand_form) col.addWidget(wand_card) # Normalize card norm_card = Card(title="Normalize") norm_form = QFormLayout() self._norm_lo = QDoubleSpinBox() self._norm_lo.setRange(0.0, 100.0); self._norm_lo.setValue(1.0) self._norm_lo.valueChanged.connect(self._on_normalize_changed) self._norm_hi = QDoubleSpinBox() self._norm_hi.setRange(0.0, 100.0); self._norm_hi.setValue(99.9) self._norm_hi.valueChanged.connect(self._on_normalize_changed) norm_form.addRow("Lower %", self._norm_lo) norm_form.addRow("Upper %", self._norm_hi) norm_card.body_layout.addLayout(norm_form) col.addWidget(norm_card) # Object ops card obj_card = Card(title="Object operations") ops_col = QVBoxLayout() ops_col.setSpacing(SPACING["xs"]) for label, cb in ( ("Fill holes", self._on_fill_holes), ("Relabel", self._on_relabel), ("Invert mask", self._on_invert), ): btn = QPushButton(label) btn.clicked.connect(cb) ops_col.addWidget(btn) 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) 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) clear_btn = QPushButton("Clear mask") clear_btn.setObjectName("DangerButton") clear_btn.clicked.connect(self._on_clear_mask) ops_col.addWidget(clear_btn) obj_ops_wrap = QWidget(); obj_ops_wrap.setLayout(ops_col) obj_card.body_layout.addWidget(obj_ops_wrap) col.addWidget(obj_card) col.addStretch(1) return wrap def _install_shortcuts(self): QShortcut(QKeySequence(Qt.Key_Left), self, self._on_prev) QShortcut(QKeySequence(Qt.Key_Right), self, self._on_next) QShortcut(QKeySequence("Ctrl+S"), self, self._on_save) QShortcut(QKeySequence("B"), self, lambda: self._set_mode(MODE_BRUSH)) QShortcut(QKeySequence("E"), self, lambda: self._set_mode(MODE_ERASE)) QShortcut(QKeySequence("W"), self, lambda: self._set_mode(MODE_WAND_ADD)) QShortcut(QKeySequence("Z"), self, lambda: self._set_mode(MODE_ZOOM)) QShortcut(QKeySequence("Escape"), self, self._on_reset_zoom) QShortcut(QKeySequence("Ctrl+Z"), self, self._on_undo) QShortcut(QKeySequence("Ctrl+Y"), self, self._on_redo) QShortcut(QKeySequence("Ctrl+Shift+Z"), self, self._on_redo) # ------------------------------------------------------------------ # Mode / brush plumbing # ------------------------------------------------------------------ def _set_mode(self, mode: str): self._canvas.mode = mode for m, btn in self._mode_buttons.items(): btn.setChecked(m == mode) def _on_brush_size_changed(self, v: int): self._canvas.brush_radius = int(v) self._brush_size_label.setText(f"{v} px") def _on_normalize_changed(self, _v: float): self._canvas.norm_lo = float(self._norm_lo.value()) self._canvas.norm_hi = float(self._norm_hi.value()) self._canvas.refresh() def _on_wand_tolerance_changed(self, v: float): self._canvas.wand_tolerance = float(v) def _on_wand_max_changed(self, v: int): self._canvas.wand_max_pixels = int(v) def _on_reset_zoom(self): self._canvas.reset_zoom() def _on_zoom_changed(self, zoomed: bool): self._btn_reset_zoom.setEnabled(zoomed) self._status_label.setText("Zoomed — press Esc to reset" if zoomed else "Zoom reset") def _on_undo(self): prev = self._history.undo() if prev is None or self._canvas.mask is None: return self._canvas.mask = prev self._canvas.refresh() self._refresh_history_buttons() def _on_redo(self): nxt = self._history.redo() if nxt is None or self._canvas.mask is None: return self._canvas.mask = nxt self._canvas.refresh() self._refresh_history_buttons() def _refresh_history_buttons(self): self._btn_undo.setEnabled(self._history.can_undo()) self._btn_redo.setEnabled(self._history.can_redo()) # ------------------------------------------------------------------ # Actions # ------------------------------------------------------------------ def _on_pick_folder(self): d = QFileDialog.getExistingDirectory(self, "Pick images folder", self._folder or os.getcwd()) if not d: return self._open_folder(d) def _open_folder(self, folder: str): files = engine.list_images(folder) if not files: QMessageBox.warning(self, "No images", f"Found no image files in:\n{folder}") return self._folder = folder self._image_files = files 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) def _load_current(self): if not self._image_files: return try: image, mask = engine.load_image_and_mask( self._folder, self._image_files[self._current_index] ) except Exception as e: QMessageBox.warning(self, "Load failed", str(e)) return self._canvas.set_image_and_mask(image, mask) # Reset undo history for the new image and seed with the loaded mask self._history.clear() self._history.push(mask) self._refresh_history_buttons() self._btn_reset_zoom.setEnabled(False) self._status_label.setText( f"{self._image_files[self._current_index]} " f"({self._current_index + 1}/{len(self._image_files)})" ) def _on_prev(self): if not self._image_files or self._current_index <= 0: return self._current_index -= 1 self._load_current() def _on_next(self): if not self._image_files or self._current_index >= len(self._image_files) - 1: return self._current_index += 1 self._load_current() def _on_save(self): if not self._image_files or self._canvas.mask is None: return try: path = engine.save_mask( self._folder, self._image_files[self._current_index], self._canvas.mask, ) except Exception as e: QMessageBox.warning(self, "Save failed", str(e)) return self._status_label.setText(f"Saved → {path}") def _apply_op(self, op): """Run a mask -> mask function, refresh canvas, push to history.""" if self._canvas.mask is None: return self._canvas.mask = op(self._canvas.mask) self._canvas.refresh() self._history.push(self._canvas.mask) self._refresh_history_buttons() def _on_fill_holes(self): self._apply_op(engine.fill_holes) def _on_relabel(self): self._apply_op(engine.relabel_objects) def _on_invert(self): self._apply_op(engine.invert_mask) def _on_remove_small(self): area = int(self._min_area.value()) self._apply_op(lambda m: engine.remove_small_objects(m, area)) def _on_clear_mask(self): if self._canvas.mask is None: return ans = QMessageBox.question(self, "Clear mask", "Zero out the current mask?") if ans != QMessageBox.Yes: return self._apply_op(engine.clear_mask) def _on_stroke_started(self): # Brush/erase strokes mutate the mask in place; nothing to record # until the stroke ends. History already has the pre-stroke mask # from the previous op/load. pass def _on_stroke_finished(self): if self._canvas.mask is not None: self._history.push(self._canvas.mask) self._refresh_history_buttons() # ------------------------------------------------------------------ def _sync_button_states(self): has_files = bool(self._image_files) for b in (self._btn_prev, self._btn_next, self._btn_save, self._btn_brush, self._btn_erase, self._btn_del_obj, self._btn_wand_add, self._btn_wand_erase, self._btn_zoom): b.setEnabled(has_files)