diff --git a/GUI/controllers/AnnotationClusteringController.py b/GUI/controllers/AnnotationClusteringController.py index eac8bc1..5ffb6de 100644 --- a/GUI/controllers/AnnotationClusteringController.py +++ b/GUI/controllers/AnnotationClusteringController.py @@ -70,7 +70,12 @@ def _extract_from_image(self, img: dict, idx: int) -> list[AnnotationBase]: if umap is None or logits is None or fname is None: return out - generated = self._generator.generate_annotations(uncertainty_map=umap, logits=logits) + image = img.get("image") + generated = self._generator.generate_annotations( + uncertainty_map=umap, + logits=logits, + image=image, + ) for ann in generated: if not ann.logit_features.any(): continue diff --git a/GUI/controllers/ImageProcessingController.py b/GUI/controllers/ImageProcessingController.py index 4f2b257..ebcfed0 100644 --- a/GUI/controllers/ImageProcessingController.py +++ b/GUI/controllers/ImageProcessingController.py @@ -125,6 +125,7 @@ def _display_processed_annotations(self, annotations_data: List[dict]): anno = data['annotation'] np_image = data['processed_crop'] coord_pos = data['coord_pos'] + mask_patch = data.get('mask_patch') if np_image is None or coord_pos is None: logging.warning(f"Missing image or coords for annotation: {anno}") @@ -135,7 +136,8 @@ def _display_processed_annotations(self, annotations_data: List[dict]): sampled_crops.append({ 'annotation': anno, 'processed_crop': q_pixmap, - 'coord_pos': coord_pos + 'coord_pos': coord_pos, + 'mask_patch': mask_patch, }) self.crops_ready.emit(sampled_crops) diff --git a/GUI/controllers/MainController.py b/GUI/controllers/MainController.py index 729b61e..776a749 100644 --- a/GUI/controllers/MainController.py +++ b/GUI/controllers/MainController.py @@ -17,6 +17,7 @@ LocalMaximaPointAnnotationGenerator, EquidistantPointAnnotationGenerator, CenterPointAnnotationGenerator, + SLICSuperpixelAnnotationGenerator, ) from GUI.models.UncertaintyPropagator import propagate_for_annotations from GUI.models.export.Options import ExportOptions @@ -231,6 +232,11 @@ def on_label_generator_method_changed(self, method: str): elif method == "Image Centre": self.annotation_generator = CenterPointAnnotationGenerator() self._use_greedy_nav = False + elif method == "Superpixels": + self.annotation_generator = SLICSuperpixelAnnotationGenerator( + n_segments=200, compactness=10.0 + ) + self._use_greedy_nav = True else: self.annotation_generator = LocalMaximaPointAnnotationGenerator( filter_size=48, gaussian_sigma=4.0, use_gaussian=False diff --git a/GUI/models/ImageProcessor.py b/GUI/models/ImageProcessor.py index b9a10a5..892b05e 100644 --- a/GUI/models/ImageProcessor.py +++ b/GUI/models/ImageProcessor.py @@ -238,8 +238,9 @@ def extract_crop_data( image: np.ndarray, coord: Tuple[int, int], crop_size: int = 256, - zoom_factor: int = 2 - ) -> Tuple[np.ndarray, Tuple[int, int]]: + zoom_factor: int = 2, + mask: np.ndarray | None = None, + ) -> Tuple[np.ndarray, Tuple[int, int], np.ndarray | None]: """ Extracts a zoomed-in crop from the RGB image at the specified coordinate without padding. Handles image data in float (0-1) or uint8 (0-255) formats. @@ -248,8 +249,9 @@ def extract_crop_data( :param coord: A tuple (row, column) indicating the center of the crop. :param crop_size: Desired size of the crop in pixels (crop_size x crop_size). :param zoom_factor: Factor by which to zoom the crop. - :return: A tuple containing the processed image as a NumPy array - and the (x, y) position of the coordinate within the zoomed crop. + :param mask: Optional segmentation mask aligned with ``image``. + :return: ``(zoomed_crop, coord_pos, zoomed_mask)`` where ``zoomed_mask`` + is ``None`` when *mask* is ``None``. """ # Generate a cache key using a hash of the image and other parameters cache_key = self._generate_cache_key(image, coord, crop_size, zoom_factor) @@ -283,6 +285,9 @@ def extract_crop_data( height_crop = int(height_crop) crop = image[y_start:y_start + height_crop, x_start:x_start + width_crop] + mask_crop = None + if mask is not None: + mask_crop = mask[y_start:y_start + height_crop, x_start:x_start + width_crop] # Ensure crop is uint8 for further processing if np.issubdtype(crop.dtype, np.floating): @@ -292,6 +297,11 @@ def extract_crop_data( new_size = (crop.shape[1] * zoom_factor, crop.shape[0] * zoom_factor) zoomed_pil = pil_image.resize(new_size, Image.BICUBIC) zoomed_crop = np.array(zoomed_pil) + zoomed_mask = None + if mask_crop is not None: + mask_pil = Image.fromarray(mask_crop.astype(np.uint8) * 255) + zoomed_mask = mask_pil.resize(new_size, Image.NEAREST) + zoomed_mask = (np.array(zoomed_mask) > 127).astype(np.uint8) # Calculate the position of the original coordinate within the zoomed crop arrow_rel_x = col - x_start @@ -299,7 +309,7 @@ def extract_crop_data( pos_x_zoomed = arrow_rel_x * zoom_factor pos_y_zoomed = arrow_rel_y * zoom_factor - result = (zoomed_crop, (int(pos_x_zoomed), int(pos_y_zoomed))) + result = (zoomed_crop, (int(pos_x_zoomed), int(pos_y_zoomed)), zoomed_mask) # Store the result in the cache self.cache.set(cache_key, result) diff --git a/GUI/models/PointAnnotationGenerator.py b/GUI/models/PointAnnotationGenerator.py index 749d77a..62c7808 100644 --- a/GUI/models/PointAnnotationGenerator.py +++ b/GUI/models/PointAnnotationGenerator.py @@ -1,7 +1,7 @@ import logging from typing import List, Tuple -from .annotations import AnnotationBase, PointAnnotation +from .annotations import AnnotationBase, PointAnnotation, MaskAnnotation import numpy as np from scipy.ndimage import gaussian_filter, maximum_filter @@ -64,9 +64,23 @@ def _extract_logit_features( # --------------------- public dispatcher -------------------------------- # def generate_annotations( - self, uncertainty_map: np.ndarray, logits: np.ndarray + self, + uncertainty_map: np.ndarray, + logits: np.ndarray, + image: np.ndarray | None = None, ) -> List[AnnotationBase]: - """Generate :class:`AnnotationBase` objects for the given inputs.""" + """Generate :class:`AnnotationBase` objects for the given inputs. + + Parameters + ---------- + uncertainty_map + Uncertainty heatmap for the image. + logits + Per-pixel class logits ``H×W×C``. + image + Optional RGB image. Subclasses that require it may override the + method and expect a non-``None`` value. + """ map2d = self._prepare_uncertainty_map(uncertainty_map) coords = self._generate_coords(map2d) @@ -211,3 +225,84 @@ def _generate_coords(self, uncertainty_map: np.ndarray) -> List[Tuple[int, int]] centre = (r // 2, c // 2) logger.debug("Centre point at %s", centre) return [centre] + + +# ----------------------------------------------------------------------------- +# +# SUPERPIXEL IMPLEMENTATION +# ----------------------------------------------------------------------------- +class SLICSuperpixelAnnotationGenerator(BasePointAnnotationGenerator): + """Generate mask annotations from image superpixels around high-uncertainty areas.""" + + def __init__(self, n_segments: int = 100, compactness: float = 10.0, edge_buffer: int = 64): + super().__init__(edge_buffer=edge_buffer) + if n_segments <= 0: + raise ValueError("n_segments must be positive") + if compactness <= 0: + raise ValueError("compactness must be positive") + self.n_segments = int(n_segments) + self.compactness = float(compactness) + logger.info( + "SLICSuperpixelAnnotationGenerator(segments=%d, compactness=%.1f)", + self.n_segments, + self.compactness, + ) + + # ------------------------------------------------------------------ # + def generate_annotations( + self, + uncertainty_map: np.ndarray, + logits: np.ndarray, + image: np.ndarray | None = None, + ) -> List[AnnotationBase]: + """Return mask annotations for superpixels covering local maxima.""" + if image is None: + raise ValueError("image must be provided for superpixel annotations") + + from skimage.segmentation import slic + + map2d = self._prepare_uncertainty_map(uncertainty_map) + + lm_gen = LocalMaximaPointAnnotationGenerator( + filter_size=48, + gaussian_sigma=4.0, + edge_buffer=self.edge_buffer, + use_gaussian=False, + ) + maxima = lm_gen._generate_coords(map2d) + + segments = slic( + image, + n_segments=self.n_segments, + compactness=self.compactness, + start_label=0, + channel_axis=-1 if image.ndim == 3 else None, + ) + + annos: List[AnnotationBase] = [] + seen: set[int] = set() + for r, c in maxima: + label = int(segments[r, c]) + if label in seen: + continue + seen.add(label) + mask = segments == label + feats = logits[mask].mean(axis=0) + uncert = float(map2d[mask].mean()) + annos.append( + MaskAnnotation( + image_index=-1, + filename="", + coord=(int(r), int(c)), + logit_features=feats, + uncertainty=uncert, + mask=mask.astype(np.uint8), + ) + ) + + logger.info( + "%s produced %d annotations.", + self.__class__.__name__, + len(annos), + ) + return annos diff --git a/GUI/models/export/ExportService.py b/GUI/models/export/ExportService.py index 9b25c6c..aba9593 100644 --- a/GUI/models/export/ExportService.py +++ b/GUI/models/export/ExportService.py @@ -8,6 +8,7 @@ from typing import Dict, Iterable, List, Tuple from GUI.models.annotations import AnnotationBase, PointAnnotation, MaskAnnotation +import numpy as np from .Options import ExportOptions __all__ = ["build_grouped_annotations"] @@ -15,6 +16,23 @@ Grouped = Dict[str, List[dict]] +def _rle_encode(mask: np.ndarray) -> List[int]: + """Return run-length encoding for a binary mask.""" + flat = mask.astype(np.uint8).ravel() + counts: List[int] = [] + prev = flat[0] + length = 1 + for val in flat[1:]: + if val == prev: + length += 1 + else: + counts.append(length) + length = 1 + prev = val + counts.append(length) + return counts + + def _should_include(anno: AnnotationBase, opts: ExportOptions) -> bool: """Return *True* when *anno* must be part of the export.""" if anno.class_id in {None, -1, -2}: # unlabeled or unsure @@ -53,7 +71,8 @@ def build_grouped_annotations( "cluster_id": int(cluster_id), } if isinstance(anno, MaskAnnotation) and anno.mask is not None: - entry["mask"] = anno.mask.tolist() + entry["mask_rle"] = _rle_encode(anno.mask) + entry["mask_shape"] = list(anno.mask.shape) entry["coord"] = [int(c) for c in anno.coord] else: entry["coord"] = [int(c) for c in anno.coord] diff --git a/GUI/unittests/test_clickable_pixmapitem.py b/GUI/unittests/test_clickable_pixmapitem.py index 40fec35..19675cd 100644 --- a/GUI/unittests/test_clickable_pixmapitem.py +++ b/GUI/unittests/test_clickable_pixmapitem.py @@ -14,7 +14,10 @@ from PyQt5.QtGui import QPixmap, QMouseEvent from PyQt5.QtCore import Qt, QPoint, QPointF -from GUI.views.ClickablePixmapItem import ClickablePixmapItem +from GUI.views.ClickablePixmapItem import ( + PointClickablePixmapItem, + MaskClickablePixmapItem, +) from GUI.models.annotations import PointAnnotation from GUI.configuration.configuration import CLASS_COMPONENTS @@ -27,7 +30,7 @@ def qapp(): return app -def make_item(qapp): +def make_item(qapp, cls=PointClickablePixmapItem, **kwargs): scene = QGraphicsScene() parent = QWidget() QGraphicsView(scene, parent) @@ -39,7 +42,10 @@ def make_item(qapp): uncertainty=0.5, ) pixmap = QPixmap(10, 10) - item = ClickablePixmapItem(annotation=ann, pixmap=pixmap, coord_pos=(2, 3)) + if cls is MaskClickablePixmapItem: + item = cls(annotation=ann, pixmap=pixmap, mask_patch=kwargs.get("mask_patch")) + else: + item = cls(annotation=ann, pixmap=pixmap, coord_pos=(2, 3)) scene.addItem(item) return item, ann, scene, parent @@ -174,6 +180,15 @@ def test_paint_draws_overlays_by_default(qapp): assert sum(1 for c in painter.calls if c[0] == "drawLine") == 4 +def test_paint_draws_mask_edges(qapp): + mask = np.zeros((10, 10), dtype=np.uint8) + mask[2:8, 2:8] = 1 + item, ann, scene, _ = make_item(qapp, cls=MaskClickablePixmapItem, mask_patch=mask) + painter = DummyPainter() + item.paint(painter, None, None) + assert sum(1 for c in painter.calls if c[0] == "drawPixmap") == 2 + + def test_paint_skips_overlays_when_hidden(qapp): item, _, scene, _ = make_item(qapp) scene.overlays_visible = False diff --git a/GUI/unittests/test_superpixel_generator.py b/GUI/unittests/test_superpixel_generator.py new file mode 100644 index 0000000..7da3b80 --- /dev/null +++ b/GUI/unittests/test_superpixel_generator.py @@ -0,0 +1,15 @@ +import numpy as np +from GUI.models.PointAnnotationGenerator import SLICSuperpixelAnnotationGenerator +from GUI.models.annotations import MaskAnnotation + + +def test_superpixel_generator_outputs_masks(): + gen = SLICSuperpixelAnnotationGenerator(n_segments=4, compactness=0.5, edge_buffer=0) + uncertainty = np.zeros((10, 10), dtype=np.float32) + uncertainty[2:7, 2:7] = 1.0 + logits = np.random.rand(10, 10, 2).astype(np.float32) + rgb = (np.random.rand(10, 10, 3) * 255).astype(np.uint8) + annos = gen.generate_annotations(uncertainty, logits, image=rgb) + assert annos + assert isinstance(annos[0], MaskAnnotation) + assert annos[0].mask.shape == (10, 10) diff --git a/GUI/views/AppMenuBar.py b/GUI/views/AppMenuBar.py index 4030179..a3c9fb6 100644 --- a/GUI/views/AppMenuBar.py +++ b/GUI/views/AppMenuBar.py @@ -76,6 +76,7 @@ def _build_actions_menu(self) -> None: "Local Uncertainty Maxima", "Equidistant Spots", "Image Centre", + "Superpixels", ]: act = QAction(label, self, checkable=True) grp.addAction(act) diff --git a/GUI/views/ClickablePixmapItem.py b/GUI/views/ClickablePixmapItem.py index 8c1d611..cb85c19 100644 --- a/GUI/views/ClickablePixmapItem.py +++ b/GUI/views/ClickablePixmapItem.py @@ -1,26 +1,25 @@ from functools import partial from typing import Tuple -from PyQt5.QtCore import QRectF, Qt, QPointF, pyqtSignal -from PyQt5.QtGui import QPixmap, QFont, QFontMetrics, QPainter, QPen, QImage -from PyQt5.QtWidgets import QGraphicsObject, QApplication, QMenu, QAction +import numpy as np + +from PyQt5.QtCore import QPointF, QRectF, Qt, pyqtSignal +from PyQt5.QtGui import QFont, QFontMetrics, QImage, QPainter, QPen, QPixmap +from PyQt5.QtWidgets import QApplication, QGraphicsObject, QMenu, QAction from GUI.configuration.configuration import CLASS_COMPONENTS -from GUI.models.annotations import AnnotationBase, MaskAnnotation +from GUI.models.annotations import AnnotationBase + +class BaseClickablePixmapItem(QGraphicsObject): + """Common functionality for crop pixmap items.""" -class ClickablePixmapItem(QGraphicsObject): - """ - A QGraphicsObject that displays a QPixmap and emits signals when interacted with. - It holds a reference to an Annotation instance. - """ class_label_changed = pyqtSignal(dict, int) - def __init__(self, annotation: AnnotationBase, pixmap: QPixmap, coord_pos: Tuple[int, int], *args, **kwargs): + def __init__(self, annotation: AnnotationBase, pixmap: QPixmap, *args, **kwargs): super().__init__(*args, **kwargs) self.annotation = annotation self.pixmap = pixmap - self.coord_pos = coord_pos self.class_id = annotation.class_id self.model_prediction = annotation.model_prediction @@ -56,43 +55,27 @@ def boundingRect(self): h = self.pixmap.height() * self.scale_factor return QRectF(0, -self.label_height, w, h + self.label_height) + # ----- overlays -------------------------------------------------- + def draw_overlay(self, painter: QPainter, pw: float, ph: float): + """Subclasses override to render annotation overlays.""" + pass + # ----- drawing --------------------------------------------------- def paint(self, painter: QPainter, option, widget): - """Draw pixmap, border, labels, and ✓/✗.""" + """Draw pixmap, overlay, border and labels.""" scene = self.scene() - # 1) Draw the scaled image + painter.setRenderHint(QPainter.SmoothPixmapTransform, True) painter.save() painter.scale(self.scale_factor, self.scale_factor) painter.drawPixmap(0, 0, self.pixmap) painter.restore() - # 2) Compute scaled dimensions pw = self.pixmap.width() * self.scale_factor ph = self.pixmap.height() * self.scale_factor if scene and getattr(scene, "overlays_visible", True): - if isinstance(self.annotation, MaskAnnotation) and self.annotation.mask is not None: - arr = (self.annotation.mask > 0).astype(np.uint8) * 255 - h, w = arr.shape - qimg = QImage(arr.data, w, h, QImage.Format_Grayscale8) - mask_pix = QPixmap.fromImage(qimg).scaled(pw, ph) - painter.setOpacity(0.5) - painter.drawPixmap(0, 0, mask_pix) - painter.setOpacity(1.0) - else: - x0 = self.coord_pos[0] * self.scale_factor - y0 = self.coord_pos[1] * self.scale_factor - r = max(8.0, min(pw, ph) // 40) - - painter.setPen(QPen(Qt.green, 2)) - painter.drawEllipse(QPointF(x0, y0), r, r) - - painter.setPen(QPen(Qt.black, 1)) - painter.drawLine(x0, 0, x0, y0 - r) - painter.drawLine(x0, y0 + r, x0, ph) - painter.drawLine(0, y0, x0 - r, y0) - painter.drawLine(x0 + r, y0, pw, y0) + self.draw_overlay(painter, pw, ph) pen = QPen(Qt.darkGray if self.hovered else Qt.black) pen.setWidth(4 if (self.selected or self.hovered or self.annotation.is_manual) else 1) @@ -190,3 +173,48 @@ def contextMenuEvent(self, event): def _view(self): views = self.scene().views() if self.scene() else [] return views[0] if views else None + + +class PointClickablePixmapItem(BaseClickablePixmapItem): + """Clickable item displaying point annotations.""" + + def __init__(self, annotation: AnnotationBase, pixmap: QPixmap, coord_pos: Tuple[int, int], *args, **kwargs): + super().__init__(annotation, pixmap, *args, **kwargs) + self.coord_pos = coord_pos + + def draw_overlay(self, painter: QPainter, pw: float, ph: float): + x0 = self.coord_pos[0] * self.scale_factor + y0 = self.coord_pos[1] * self.scale_factor + r = max(8.0, min(pw, ph) // 40) + + painter.setPen(QPen(Qt.green, 2)) + painter.drawEllipse(QPointF(x0, y0), r, r) + + painter.setPen(QPen(Qt.black, 1)) + painter.drawLine(x0, 0, x0, y0 - r) + painter.drawLine(x0, y0 + r, x0, ph) + painter.drawLine(0, y0, x0 - r, y0) + painter.drawLine(x0 + r, y0, pw, y0) + + +class MaskClickablePixmapItem(BaseClickablePixmapItem): + """Clickable item displaying mask annotation overlays.""" + + def __init__(self, annotation: AnnotationBase, pixmap: QPixmap, mask_patch: np.ndarray, *args, **kwargs): + super().__init__(annotation, pixmap, *args, **kwargs) + self.mask_patch = mask_patch + + def draw_overlay(self, painter: QPainter, pw: float, ph: float): + from skimage.segmentation import find_boundaries + + edges = find_boundaries(self.mask_patch, mode="inner") + edge_img = np.zeros((*edges.shape, 4), dtype=np.uint8) + edge_img[edges] = [0, 255, 0, 255] + h, w, _ = edge_img.shape + qimg = QImage(edge_img.data, w, h, QImage.Format_RGBA8888) + mask_pix = QPixmap.fromImage(qimg).scaled(int(pw), int(ph)) + painter.drawPixmap(0, 0, mask_pix) + + +# Backwards compatibility +ClickablePixmapItem = PointClickablePixmapItem diff --git a/GUI/views/ClusteredCropsView.py b/GUI/views/ClusteredCropsView.py index 22424c8..33d254c 100644 --- a/GUI/views/ClusteredCropsView.py +++ b/GUI/views/ClusteredCropsView.py @@ -17,7 +17,10 @@ from GUI.configuration.configuration import CLASS_COMPONENTS from GUI.models.annotations import AnnotationBase from GUI.models.export.ExportService import ExportOptions -from GUI.views.ClickablePixmapItem import ClickablePixmapItem +from GUI.views.ClickablePixmapItem import ( + PointClickablePixmapItem, + MaskClickablePixmapItem, +) from GUI.views.LabelSlider import LabeledSlider # --------------------------------------------------------------------------- @@ -669,7 +672,18 @@ def arrange_crops(self): logging.warning(f"Invalid QPixmap for image index {annotation.image_index}. Skipping.") continue - pixmap_item = ClickablePixmapItem(annotation=annotation, pixmap=pixmap, coord_pos=crop_data['coord_pos']) + if crop_data.get('mask_patch') is not None: + pixmap_item = MaskClickablePixmapItem( + annotation=annotation, + pixmap=pixmap, + mask_patch=crop_data['mask_patch'], + ) + else: + pixmap_item = PointClickablePixmapItem( + annotation=annotation, + pixmap=pixmap, + coord_pos=crop_data['coord_pos'], + ) pixmap_item.setFlag(QGraphicsItem.ItemIsSelectable, True) pixmap_item.class_label_changed.connect(self.crop_label_changed.emit) diff --git a/GUI/workers/ImageProcessingWorker.py b/GUI/workers/ImageProcessingWorker.py index 2003654..1682ab7 100644 --- a/GUI/workers/ImageProcessingWorker.py +++ b/GUI/workers/ImageProcessingWorker.py @@ -5,7 +5,7 @@ from PyQt5.QtCore import QRunnable, QObject, pyqtSignal -from GUI.models.annotations import AnnotationBase +from GUI.models.annotations import AnnotationBase, MaskAnnotation from GUI.models.CacheManager import CacheManager from GUI.models.ImageDataModel import BaseImageDataModel from GUI.models.ImageProcessor import ImageProcessor @@ -97,21 +97,26 @@ def _process_single_annotation(self, annotation: AnnotationBase) -> Optional[Dic # Check if already cached: cached_result = self.cache.get(cache_key) if cached_result: - processed_crop, coord_pos = cached_result + processed_crop, coord_pos, mask_crop = cached_result else: image_data = self.image_data_model.get_image_data(annotation.image_index) image_array = image_data.get('image') if image_array is None: logging.warning(f"No image data found for index {annotation.image_index}.") return None - - processed_crop, coord_pos = self.image_processor.extract_crop_data( - image_array, coord, crop_size=self.crop_size, zoom_factor=self.zoom_factor + mask = annotation.mask if isinstance(annotation, MaskAnnotation) else None + processed_crop, coord_pos, mask_crop = self.image_processor.extract_crop_data( + image_array, + coord, + crop_size=self.crop_size, + zoom_factor=self.zoom_factor, + mask=mask, ) - self.cache.set(cache_key, (processed_crop, coord_pos)) + self.cache.set(cache_key, (processed_crop, coord_pos, mask_crop)) return { 'annotation': annotation, 'processed_crop': processed_crop, - 'coord_pos': coord_pos + 'coord_pos': coord_pos, + 'mask_patch': mask_crop, } diff --git a/README.md b/README.md index 9a8e0f0..2e6023c 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ The typical workflow is to train a segmentation model, run inference to obtain p - Propagates uncertainty scores as labels are applied so that recommendations update in real time. - Autosave, project management and preview dialogs to inspect full size images with overlayed annotations. - Export of labelled points to JSON for further model training or analysis. +- Optional superpixel annotations using SLIC for region labelling. ## Getting Started ### Environment diff --git a/environment.yml b/environment.yml index b59bdf0..eba45bc 100644 --- a/environment.yml +++ b/environment.yml @@ -24,6 +24,7 @@ dependencies: - numba - joblib - PyQt5 + - scikit-image - pydantic - pytest - pyinstaller