diff --git a/examples/object_classifier.py b/examples/object_classifier.py index 2471e7ec..c3d598b8 100644 --- a/examples/object_classifier.py +++ b/examples/object_classifier.py @@ -220,7 +220,7 @@ def histopathology_annotator(): segmentations.append(segmentation) embedding_paths.append(embedding_path) - clf.image_series_object_classifier( + clf.batch_object_classifier( images, segmentations, output_folder="./clf-test-data/histo-results", embedding_paths=embedding_paths, model_type="vit_b_histopathology", ndim=2, ) diff --git a/micro_sam/napari.yaml b/micro_sam/napari.yaml index 0c147841..96c8db0d 100644 --- a/micro_sam/napari.yaml +++ b/micro_sam/napari.yaml @@ -38,7 +38,7 @@ contributions: - id: micro_sam.annotator_tracking python_name: micro_sam.sam_annotator.annotator_tracking:AnnotatorTracking title: Start the tracking annotator - - id: micro_sam.image_series_annotator + - id: micro_sam.batch_annotator python_name: micro_sam.sam_annotator.batch_annotator:BatchAnnotator title: Start the batch annotator - id: micro_sam.object_classifier @@ -79,7 +79,7 @@ contributions: display_name: Segmentation Annotator - command: micro_sam.annotator_tracking display_name: Tracking Annotator - - command: micro_sam.image_series_annotator + - command: micro_sam.batch_annotator display_name: Batch Annotator - command: micro_sam.object_classifier display_name: Object Classifier diff --git a/micro_sam/sam_annotator/__init__.py b/micro_sam/sam_annotator/__init__.py index 842cbf51..5b15843d 100644 --- a/micro_sam/sam_annotator/__init__.py +++ b/micro_sam/sam_annotator/__init__.py @@ -3,5 +3,5 @@ from .annotator import annotator, Annotator from .annotator_tracking import annotator_tracking -from .batch_annotator import image_folder_annotator, image_series_annotator +from .batch_annotator import batch_annotator, image_folder_annotator # from .object_classifier import object_classifier diff --git a/micro_sam/sam_annotator/_annotator.py b/micro_sam/sam_annotator/_annotator.py index b92b04c0..e6295dc2 100644 --- a/micro_sam/sam_annotator/_annotator.py +++ b/micro_sam/sam_annotator/_annotator.py @@ -428,8 +428,8 @@ def _resolve_anyup(self): image = self._viewer.layers["image"].data return image, upsampler, True - def accumulate_series_features(self): - """Add the current image's labeled features to the running training set (image series). + def accumulate_batch_features(self): + """Add the current image's labeled features to the running batch training set. Uses the per-tool feature and label hooks so it works for both classifiers. No-op when the features cannot be computed or when the current image has no annotations. diff --git a/micro_sam/sam_annotator/_series.py b/micro_sam/sam_annotator/_batch.py similarity index 86% rename from micro_sam/sam_annotator/_series.py rename to micro_sam/sam_annotator/_batch.py index 2e19613d..fee306ff 100644 --- a/micro_sam/sam_annotator/_series.py +++ b/micro_sam/sam_annotator/_batch.py @@ -1,8 +1,8 @@ -"""Shared navigation harness for the image series annotator. +"""Shared navigation harness for the batch annotator. Hosts any per-task annotator (segmentation, tracking, classification) and drives forward navigation, the skip-already-done check and per-item loading/saving -through a small task-adapter interface (`SeriesAnnotatorTask`). The harness is +through a small task-adapter interface (`BatchAnnotatorTask`). The harness is task-agnostic; everything task-specific lives in the concrete adapter. """ @@ -19,13 +19,13 @@ def _hide_embedding_widget(annotator): - """Hide the docked annotator's embedding section during a series session. + """Hide the docked annotator's embedding section during a batch session. The launcher's advanced settings are the single source of truth for the model / tiling / device / embedding-path / ndim, and the harness computes embeddings itself in 'start'/'advance', so the annotator's embedding panel (and its 'Compute Embeddings' button) is redundant here. The widget object is kept alive (just hidden) because '_sync_embedding_widget' and the classifier spec read - from it. Standalone (non-series) annotators never call this, so their panel stays visible. + from it. Standalone (non-batch) annotators never call this, so their panel stays visible. """ ew = getattr(annotator, "_embedding_widget", None) if ew is None: @@ -39,15 +39,15 @@ def _hide_embedding_widget(annotator): def _embed_navigation(viewer, annotator, nav_container): - """Add the navigation controls as a 'Series Navigation' section inside the docked annotator. + """Add the navigation controls as a 'Batch Navigation' section inside the docked annotator. Falls back to a standalone dock widget if the annotator has no embeddable inner layout. """ inner = getattr(annotator, "_annotator_widget", None) if inner is None or inner.layout() is None: - viewer.window.add_dock_widget(nav_container, name="Series Navigation") + viewer.window.add_dock_widget(nav_container, name="Batch Navigation") return - group = QtWidgets.QGroupBox("Series Navigation") + group = QtWidgets.QGroupBox("Batch Navigation") group_layout = QtWidgets.QVBoxLayout() # Add a top margin so the group title is not cramped against the navigation buttons. group_layout.setContentsMargins(8, 14, 8, 8) @@ -86,10 +86,10 @@ def _resize(): QTimer.singleShot(0, _resize) -class SeriesAnnotatorTask: - """Adapter encoding the task-specific parts of an image series session. +class BatchAnnotatorTask: + """Adapter encoding the task-specific parts of a batch annotation session. - The harness owns navigation, the skip-already-done check and the end-of-series dialog. + The harness owns navigation, the skip-already-done check and the end-of-batch dialog. The adapter owns precomputing the model and embeddings, loading an item into the viewer, deciding whether there is content worth saving, and saving the per-item result. Concrete tasks: segmentation, tracking, object/pixel classification. @@ -98,14 +98,14 @@ class SeriesAnnotatorTask: #: Folder where per-item results are written. Set by the harness before the session starts. output_folder = None - #: Whether the series inputs are in-memory arrays (True) or file paths (False). Set by the harness. + #: Whether the batch inputs are in-memory arrays (True) or file paths (False). Set by the harness. have_inputs_as_arrays = False def result_filename(self, entry, index: int) -> str: """Return the filename (relative to `output_folder`) of this item's saved result. - Used to skip items that are already done. `entry` is the raw series entry - (an array or a file path), `index` its position in the series. + Used to skip items that are already done. `entry` is the raw batch entry + (an array or a file path), `index` its position in the batch. """ raise NotImplementedError @@ -139,27 +139,27 @@ def on_leave_item(self, viewer, entry, index: int) -> None: empty_item_message = "Nothing is annotated yet. Do you wish to continue to the next image?" def nav_extra_widgets(self): - """Extra magicgui widgets to place next to the Next button in the Series Navigation container. + """Extra magicgui widgets to place next to the Next button in the Batch Navigation container. Task-specific (e.g. the classifiers' 'Forward Classifier State' checkbox); none by default. """ return [] -def run_image_series( +def run_batch( images, output_folder, - task: SeriesAnnotatorTask, + task: BatchAnnotatorTask, *, have_inputs_as_arrays: bool, viewer=None, return_viewer: bool = False, skip_done: bool = True, ): - """Drive an image series annotation session for any task. + """Drive a batch annotation session for any task. Args: - images: The series entries (in-memory arrays or file paths). + images: The batch entries (in-memory arrays or file paths). output_folder: The folder where per-item results are saved. task: The task adapter that encodes the task-specific behavior. have_inputs_as_arrays: Whether `images` holds arrays (True) or file paths (False). @@ -193,7 +193,11 @@ def _load_pixels(index): while current_index < n_images and _is_done(current_index): current_index += 1 if current_index == n_images: - print("All images have already been annotated and 'skip_done' is set. Nothing to do.") + # The batch launcher reports this in a dialog and stays open so its settings can be + # changed. Keep the terminal message for direct Python / CLI use, where no viewer was + # supplied to display that feedback. + if viewer is None: + print("All images have already been annotated and 'skip_done' is set. Nothing to do.") return if current_index != 0: print("The first image to annotate is image number", current_index) @@ -204,7 +208,7 @@ def _load_pixels(index): image = _load_pixels(current_index) annotator = task.start(viewer, images[current_index], image, embedding_paths[current_index], current_index) - # The launcher owns the model / embedding settings in a series session, so hide the annotator's + # The launcher owns the model / embedding settings in a batch session, so hide the annotator's # (now redundant) embedding section to avoid duplicating those controls. _hide_embedding_widget(annotator) @@ -241,14 +245,14 @@ def _do_next(*args): return _go_to(index) - # Embed the navigation controls in the docked annotator, so they travel with the image series + # Embed the navigation controls in the docked annotator, so they travel with the batch # annotator instead of as a separate floating dock widget. The action is also tracked in the # shared state, so it can be triggered programmatically (e.g. in tests) just like the annotator's # own widgets. state = AnnotatorState() next_button = PushButton(text="Next Image [N]") next_button.clicked.connect(lambda: _do_next()) - state.widgets["series_next"] = _do_next + state.widgets["batch_next"] = _do_next nav_buttons = [next_button] # Task-specific controls placed next to Next (e.g. the classifiers' 'Keep Classifier'). diff --git a/micro_sam/sam_annotator/_classification_series.py b/micro_sam/sam_annotator/_batch_classification.py similarity index 90% rename from micro_sam/sam_annotator/_classification_series.py rename to micro_sam/sam_annotator/_batch_classification.py index 38de7a37..bcee0c7c 100644 --- a/micro_sam/sam_annotator/_classification_series.py +++ b/micro_sam/sam_annotator/_batch_classification.py @@ -1,6 +1,6 @@ -"""Shared image-series task for the object and pixel classifiers. +"""Shared batch-annotation task for the object and pixel classifiers. -Both classifiers share the per-item series flow: (re)initialize the predictor, build the classifier +Both classifiers share the per-item batch flow: (re)initialize the predictor, build the classifier widget, accumulate the per-item labeled features into the running training set when leaving an item, and save the prediction plus the trained classifier. Subclasses bind the concrete classifier widget class, the cached state-attribute names and any extra per-item layers (the object classifier adds a @@ -14,14 +14,14 @@ from joblib import dump from magicgui.widgets import CheckBox -from ._series import SeriesAnnotatorTask +from ._batch import BatchAnnotatorTask from ._state import AnnotatorState from ._tooltips import get_tooltip from .util import _sync_embedding_widget -class ClassificationSeriesTask(SeriesAnnotatorTask): - """Series task base for the classifiers; subclasses bind the concrete widget and state attrs.""" +class ClassificationBatchTask(BatchAnnotatorTask): + """Batch task base for the classifiers; subclasses bind the concrete widget and state attrs.""" # Bound by subclasses. classifier_class = None # ObjectClassifier | PixelClassifier @@ -51,7 +51,7 @@ def result_filename(self, entry, index): return os.path.splitext(os.path.basename(entry))[0] + "_prediction.tif" def precompute(self, images): - # Start the series with a fresh running training set and no cached features/classifier, so a + # Start the batch with a fresh running training set and no cached features/classifier, so a # new session does not inherit accumulated state from a previous one (the state is a singleton). state = AnnotatorState() state.previous_features, state.previous_labels = None, None @@ -94,10 +94,10 @@ def start(self, viewer, entry, image, embedding_path, index): def nav_extra_widgets(self): # A checkbox next to the Next button (classification tasks only), on by default, to carry the - # classifier state forward across the series. Tracked in the state so it is reachable in tests. + # classifier state forward across the batch. Tracked in the state so it is reachable in tests. self._forward_state = CheckBox(value=True, text="Keep Classifier") self._forward_state.native.setToolTip(get_tooltip("classification", "forward_classifier_state")) - AnnotatorState().widgets["series_forward_state"] = self._forward_state + AnnotatorState().widgets["batch_forward_state"] = self._forward_state return [self._forward_state] def _forward_state_enabled(self): @@ -127,7 +127,7 @@ def on_leave_item(self, viewer, entry, index): state = AnnotatorState() if self._forward_state_enabled(): # Stack this image's annotated features into the running training set, forwarded to the next. - state.annotator.accumulate_series_features() + state.annotator.accumulate_batch_features() if state.previous_features is not None: np.save(os.path.join(self.output_folder, "features.npy"), state.previous_features) np.save(os.path.join(self.output_folder, "labels.npy"), state.previous_labels) diff --git a/micro_sam/sam_annotator/_state.py b/micro_sam/sam_annotator/_state.py index 1b1d82be..389e9cc9 100644 --- a/micro_sam/sam_annotator/_state.py +++ b/micro_sam/sam_annotator/_state.py @@ -124,7 +124,7 @@ class AnnotatorState(metaclass=Singleton): object_rf: Optional[Any] = None # TODO use proper class segmentation_selection: Optional[Any] = None - # For image_series_object_classifier + # For batch_object_classifier previous_features: Optional[np.ndarray] = None previous_labels: Optional[np.ndarray] = None diff --git a/micro_sam/sam_annotator/_tooltips.py b/micro_sam/sam_annotator/_tooltips.py index dd159aa8..d8a8b5a5 100644 --- a/micro_sam/sam_annotator/_tooltips.py +++ b/micro_sam/sam_annotator/_tooltips.py @@ -79,13 +79,13 @@ "track_state": "Select the state of the current annotation. Choose 'division' if the object is dviding in the current frame.", # noqa "export_button": "Export the committed tracking result in the chosen format (CTC, GEFF or TrackMate XML).", # noqa }, - "image_series_annotator": { + "batch_annotator": { "folder": "Select the folder with the images to annotate.", "output_folder": "Select the folder for saving the segmentation results.", "continue_annotation": "Resume at the first image without a saved result in the output folder. Uncheck to restart at the first image and load existing segmentations for review or editing.", # noqa "pattern": "Select a pattern for selecting files. E.g. '*.tif' to only select tif files. By default all files in the input folder are selected.", # noqa "ndim": "The spatial dimensionality of the data.", - "task": "The annotation task to run over the series: interactive segmentation, tracking (each file is a timeseries), or object / pixel classification.", # noqa + "task": "The annotation task to run over the batch: interactive segmentation, tracking (each file is a timeseries), or object / pixel classification.", # noqa "segmentation_folder": "Object classification only: a folder with one segmentation per image to classify. Leave empty to produce the segmentations in the tool.", # noqa }, "training": { @@ -113,7 +113,7 @@ }, "classification": { "settings": "Optional classifier settings: PCA feature reduction, AnyUp upsampling, the random seed, and loading or exporting a trained classifier.", # noqa - "forward_classifier_state": "Carry the classifier across images in the series: annotated features from previous images are stacked with the current one, a fresh random forest is trained on the combined set, and it is applied to the next image automatically (even without new annotations). Uncheck to classify each image independently.", # noqa + "forward_classifier_state": "Carry the classifier across images in the batch: annotated features from previous images are stacked with the current one, a fresh random forest is trained on the combined set, and it is applied to the next image automatically (even without new annotations). Uncheck to classify each image independently.", # noqa "segmentation": "Select the segmentation (labels) layer whose objects will be classified.", "train_button": "Train the random forest on all current annotations and predict on the image. Shortcut: Shift + T.", # noqa "clear_button": "Clear the annotation scribbles and the prediction (whole volume, or the current slice for 3d data when 'Apply to Volume' is unchecked). Shortcut: C.", # noqa diff --git a/micro_sam/sam_annotator/_widgets.py b/micro_sam/sam_annotator/_widgets.py index 057c94b5..4c27cb04 100644 --- a/micro_sam/sam_annotator/_widgets.py +++ b/micro_sam/sam_annotator/_widgets.py @@ -1794,7 +1794,7 @@ def _update_tiling_visibility(self, index=None): def _apply_default_tiling_for_shape(self, shape): # Enable tiling by default for large in-plane images, using the central v2 tiling defaults. # 'shape' is the spatial image shape (channel axis already removed). Shared by the layer-based - # auto-tiling and the image series launcher (which judges from the first file in the folder). + # auto-tiling and the batch launcher (which judges from the first file in the folder). from micro_sam.v2.util import needs_default_tiling, DEFAULT_TILE_SHAPE, DEFAULT_HALO if shape is not None and needs_default_tiling(shape): diff --git a/micro_sam/sam_annotator/annotator_tracking.py b/micro_sam/sam_annotator/annotator_tracking.py index 09f76c8a..de622b05 100644 --- a/micro_sam/sam_annotator/annotator_tracking.py +++ b/micro_sam/sam_annotator/annotator_tracking.py @@ -12,7 +12,7 @@ from . import _widgets as widgets from . import util as vutil from ._annotator import _AnnotatorBase -from ._series import SeriesAnnotatorTask, run_image_series +from ._batch import BatchAnnotatorTask, run_batch from ._state import AnnotatorState from ._tooltips import get_tooltip @@ -479,8 +479,8 @@ def annotator_tracking( napari.run() -class TrackingSeriesTask(SeriesAnnotatorTask): - """Series task for tracking: each item is a TYX timeseries tracked independently.""" +class TrackingBatchTask(BatchAnnotatorTask): + """Batch task for tracking: each item is a TYX timeseries tracked independently.""" empty_item_message = "Nothing is tracked yet. Do you wish to continue to the next timeseries?" @@ -540,7 +540,7 @@ def start(self, viewer, entry, image, embedding_path, index): annotator._update_image() state = AnnotatorState() - viewer.window.add_dock_widget(annotator, name="Segment Anything for Microscopy (Image Series Tracking)") + viewer.window.add_dock_widget(annotator, name="Segment Anything for Microscopy (Batch Tracking)") vutil._sync_embedding_widget( widget=state.widgets["embeddings"], model_type=self.model_type if self.checkpoint_path is None else state.predictor.model_type, @@ -563,7 +563,7 @@ def save_item(self, viewer, entry, index): imageio.imwrite(save_path, viewer.layers["committed_objects"].data, compression="zlib") -def image_series_tracking_annotator( +def batch_tracking_annotator( images: Union[List[Union[os.PathLike, str]], List[np.ndarray]], output_folder: str, *, @@ -579,7 +579,7 @@ def image_series_tracking_annotator( return_viewer: bool = False, skip_done: bool = True, ) -> Optional["napari.viewer.Viewer"]: - """Run the tracking annotation tool for a series of timeseries (each item is one TYX video). + """Run the tracking annotation tool for a batch of timeseries (each item is one TYX video). Args: images: List of timeseries (TYX arrays) or file paths, each tracked independently. @@ -602,12 +602,12 @@ def image_series_tracking_annotator( The napari viewer, only returned if `return_viewer=True`. """ have_inputs_as_arrays = isinstance(images[0], np.ndarray) - task = TrackingSeriesTask( + task = TrackingBatchTask( model_type=model_type, embedding_path=embedding_path, tile_shape=tile_shape, halo=halo, checkpoint_path=checkpoint_path, decoder_path=decoder_path, device=device, precompute_amg_state=precompute_amg_state, ) - return run_image_series( + return run_batch( images, output_folder, task, have_inputs_as_arrays=have_inputs_as_arrays, viewer=viewer, return_viewer=return_viewer, skip_done=skip_done, ) diff --git a/micro_sam/sam_annotator/batch_annotator.py b/micro_sam/sam_annotator/batch_annotator.py index 01fbda88..ff8a64a9 100644 --- a/micro_sam/sam_annotator/batch_annotator.py +++ b/micro_sam/sam_annotator/batch_annotator.py @@ -15,13 +15,13 @@ from ..v1.util import get_model_names from ..v2.util import DEFAULT_MODEL from . import _widgets as widgets -from ._series import SeriesAnnotatorTask, run_image_series +from ._batch import BatchAnnotatorTask, run_batch from ._tooltips import get_tooltip from ._state import AnnotatorState from .annotator import Annotator, detect_ndim from .util import _sync_embedding_widget -# The tasks the unified image series annotator can run over a series. +# The tasks supported by the unified batch annotator. TASKS = ["Segmentation", "Tracking", "Object Classification", "Pixel Classification"] @@ -39,8 +39,8 @@ def _get_input_shape(image, ndim): return image_shape -class SegmentationSeriesTask(SeriesAnnotatorTask): - """Series task for 2d/3d interactive segmentation (the original image series annotator).""" +class SegmentationBatchTask(BatchAnnotatorTask): + """Batch task for 2d/3d interactive segmentation.""" empty_item_message = "Nothing is segmented yet. Do you wish to continue to the next image?" @@ -117,7 +117,7 @@ def start(self, viewer, entry, image, embedding_path, index): annotator._update_image(segmentation_result=self._resolve_initial_result(entry, index)) state = AnnotatorState() - viewer.window.add_dock_widget(annotator, name="Segment Anything for Microscopy (Image Series Segmentation)") + viewer.window.add_dock_widget(annotator, name="Segment Anything for Microscopy (Batch Segmentation)") _sync_embedding_widget( widget=state.widgets["embeddings"], model_type=self.model_type if self.checkpoint_path is None else state.predictor.model_type, @@ -145,7 +145,7 @@ def save_item(self, viewer, entry, index): imageio.imwrite(save_path, viewer.layers["committed_objects"].data, compression="zlib") -def image_series_annotator( +def batch_annotator( images: Union[List[Union[os.PathLike, str]], List[np.ndarray]], output_folder: str, *, @@ -163,7 +163,7 @@ def image_series_annotator( prefer_decoder: bool = True, skip_segmented: bool = True, ) -> Optional["napari.viewer.Viewer"]: - """Run the segmentation annotation tool for a series of images (2d or 3d). + """Run the segmentation annotation tool for a batch of images (2d or 3d). Args: images: List of the file paths or list of (set of) slices for the images to be annotated. @@ -209,13 +209,13 @@ def image_series_annotator( first_image = images[0] if have_inputs_as_arrays else imageio.imread(images[0]) ndim = detect_ndim(first_image) - task = SegmentationSeriesTask( + task = SegmentationBatchTask( ndim=ndim, model_type=model_type, embedding_path=embedding_path, tile_shape=tile_shape, halo=halo, precompute_amg_state=precompute_amg_state, checkpoint_path=checkpoint_path, device=device, prefer_decoder=prefer_decoder, initial_segmentations=initial_segmentations, ) - return run_image_series( + return run_batch( images, output_folder, task, have_inputs_as_arrays=have_inputs_as_arrays, viewer=viewer, return_viewer=return_viewer, skip_done=skip_segmented, ) @@ -233,7 +233,7 @@ def image_folder_annotator( return_viewer: bool = False, **kwargs ) -> Optional["napari.viewer.Viewer"]: - """Run the segmentation annotation tool for a series of images (2d or 3d) in a folder. + """Run the segmentation annotation tool for a batch of images (2d or 3d) in a folder. Args: input_folder: The folder with the images to be annotated. @@ -248,7 +248,7 @@ def image_folder_annotator( This enables using a pre-initialized viewer. return_viewer: Whether to return the napari viewer to further modify it before starting the tool. By default, does not return the napari viewer. - kwargs: The keyword arguments for `micro_sam.sam_annotator.batch_annotator.image_series_annotator`. + kwargs: The keyword arguments for `micro_sam.sam_annotator.batch_annotator.batch_annotator`. Returns: The napari viewer, only returned if `return_viewer=True`. @@ -261,7 +261,7 @@ def image_folder_annotator( initial_segmentation_folder, initial_segmentation_pattern ))) - return image_series_annotator( + return batch_annotator( image_files, output_folder, ndim=ndim, initial_segmentations=initial_segmentations, viewer=viewer, return_viewer=return_viewer, **kwargs @@ -319,15 +319,15 @@ def _create_options(self): self._folder_textbox, layout = self._add_path_param( "folder", self.folder, "directory", title="Input Folder", placeholder="Folder with images ...", - tooltip=get_tooltip("image_series_annotator", "folder") + tooltip=get_tooltip("batch_annotator", "folder") ) self._content.layout().addLayout(layout) self._folder_label = layout.itemAt(0).widget() - # File pattern qualifying the input folder: which files form the series. + # File pattern qualifying the input folder: which files form the batch. self.pattern = "*" self._pattern_param, layout = self._add_string_param( - "pattern", self.pattern, tooltip=get_tooltip("image_series_annotator", "pattern") + "pattern", self.pattern, tooltip=get_tooltip("batch_annotator", "pattern") ) self._content.layout().addLayout(layout) self._pattern_label = layout.itemAt(0).widget() @@ -336,12 +336,12 @@ def _create_options(self): _, layout = self._add_path_param( "output_folder", self.output_folder, "directory", title="Output Folder", placeholder="Folder to save the results ...", - tooltip=get_tooltip("image_series_annotator", "output_folder") + tooltip=get_tooltip("batch_annotator", "output_folder") ) self.continue_annotation = True self.continue_annotation_checkbox = self._add_boolean_param( "continue_annotation", self.continue_annotation, title="Continue Annotation", - tooltip=get_tooltip("image_series_annotator", "continue_annotation"), + tooltip=get_tooltip("batch_annotator", "continue_annotation"), ) layout.addWidget(self.continue_annotation_checkbox) self._content.layout().addLayout(layout) @@ -356,7 +356,7 @@ def _create_options(self): self.task = "Segmentation" self.task_dropdown, task_layout = self._add_choice_param( - "task", self.task, TASKS, title="Task:", tooltip=get_tooltip("image_series_annotator", "task"), + "task", self.task, TASKS, title="Task:", tooltip=get_tooltip("batch_annotator", "task"), ) # Let the dropdown absorb the row's extra width so the 'Task:' label hugs it (otherwise the # label expands and leaves a gap between the text and the dropdown). @@ -374,7 +374,7 @@ def _create_options(self): _, path_layout = self._add_path_param( "segmentation_folder", self.segmentation_folder, "directory", title="Segmentation Folder", placeholder="Folder with segmentations (optional) ...", - tooltip=get_tooltip("image_series_annotator", "segmentation_folder"), + tooltip=get_tooltip("batch_annotator", "segmentation_folder"), ) seg_layout.addLayout(path_layout) self._seg_folder_container.setLayout(seg_layout) @@ -455,7 +455,7 @@ def _on_task_changed(self, *args): self._rebuild_embedding_widget() def _update_default_tiling(self, *args): - # Judge default tiling from the first image in the series, mirroring the embedding widget's + # Judge default tiling from the first image in the batch, mirroring the embedding widget's # per-image auto-tiling (which keys off a selected layer that the launcher does not have). ew = self._embedding_widget if ew is None or not self.folder: @@ -493,7 +493,7 @@ def _validate_inputs(self): return False def _embedding_paths_for(self, image_files): - # Per-item embedding zarr paths under the chosen folder (the classification series functions + # Per-item embedding zarr paths under the chosen folder (the batch classification functions # take an explicit list); 'None' when no embedding folder is set. save_path = self._embedding_widget.embeddings_save_path if not save_path: @@ -525,7 +525,7 @@ def __call__(self, skip_validate=False): ) if self.task == "Segmentation": - image_folder_annotator( + launched_viewer = image_folder_annotator( input_folder=self.folder, output_folder=self.output_folder, ndim=ndim, pattern=self.pattern, embedding_path=ew.embeddings_save_path, skip_segmented=bool(self.continue_annotation), **common, @@ -533,28 +533,42 @@ def __call__(self, skip_validate=False): else: image_files = sorted(glob(os.path.join(self.folder, self.pattern))) if self.task == "Tracking": - from .annotator_tracking import image_series_tracking_annotator - image_series_tracking_annotator( + from .annotator_tracking import batch_tracking_annotator + launched_viewer = batch_tracking_annotator( image_files, self.output_folder, embedding_path=ew.embeddings_save_path, **common, ) else: embedding_paths = self._embedding_paths_for(image_files) if self.task == "Pixel Classification": - from .pixel_classifier import image_series_pixel_classifier - image_series_pixel_classifier( + from .pixel_classifier import batch_pixel_classifier + launched_viewer = batch_pixel_classifier( image_files, self.output_folder, embedding_paths=embedding_paths, ndim=ndim, **common, ) else: # Object Classification: load the per-image segmentations if a folder is given. - from .object_classifier import image_series_object_classifier + from .object_classifier import batch_object_classifier seg_files = None if self.segmentation_folder: seg_files = sorted(glob(os.path.join(self.segmentation_folder, self.segmentation_pattern))) - image_series_object_classifier( + launched_viewer = batch_object_classifier( image_files, seg_files, self.output_folder, embedding_paths=embedding_paths, ndim=ndim, **common, ) + # A batch function returns None when all results already exist and continuing is enabled. + # Keep this launcher available so the user can restart for review or choose another output. + if launched_viewer is None: + message = "All images have already been annotated. " + if self.task == "Segmentation": + message += ( + "To review or edit them, uncheck 'Continue Annotation' and start the batch annotator again, " + "or choose a different output folder." + ) + else: + message += "Choose a different output folder to start another batch." + widgets._generate_message("info", message) + return + # The console has done its job (task + settings are locked in for this session); remove it so # the annotator has the screen to itself. self._dismiss() @@ -578,7 +592,7 @@ def main(): available_models = list(get_model_names()) available_models = ", ".join(available_models) - parser = argparse.ArgumentParser(description="Annotate a series of images from a folder.") + parser = argparse.ArgumentParser(description="Annotate a batch of images from a folder.") parser.add_argument( "-i", "--input_folder", required=True, help="The folder containing the image data. The data can be stored in any common format (tif, jpg, png, ...)." diff --git a/micro_sam/sam_annotator/object_classifier.py b/micro_sam/sam_annotator/object_classifier.py index 5928fe02..576bce16 100644 --- a/micro_sam/sam_annotator/object_classifier.py +++ b/micro_sam/sam_annotator/object_classifier.py @@ -19,8 +19,8 @@ from ..v2.util import DEFAULT_MODEL from ..object_classification import compute_object_features, project_prediction_to_segmentation from ._annotator import _ClassifierBase -from ._series import run_image_series -from ._classification_series import ClassificationSeriesTask +from ._batch import run_batch +from ._batch_classification import ClassificationBatchTask from ._state import AnnotatorState from ._tooltips import get_tooltip from . import _widgets as widgets @@ -279,10 +279,10 @@ def object_classifier( napari.run() -class ObjectClassificationSeriesTask(ClassificationSeriesTask): - """Series task for the object classifier: per-item segmentation layer + projected prediction.""" +class ObjectClassificationBatchTask(ClassificationBatchTask): + """Batch task for the object classifier: per-item segmentation layer + projected prediction.""" - dock_name = "Segment Anything for Microscopy (Image Series Object Classification)" + dock_name = "Segment Anything for Microscopy (Batch Object Classification)" classifier_class = ObjectClassifier features_attr = "object_features" aux_attr = "seg_ids" @@ -307,7 +307,7 @@ def _set_layers(self, viewer, index): viewer.add_labels(seg, name="segmentation") -def image_series_object_classifier( +def batch_object_classifier( images: List[np.ndarray], segmentations: List[np.ndarray], output_folder: str, @@ -324,7 +324,7 @@ def image_series_object_classifier( ) -> Optional["napari.viewer.Viewer"]: """Start the object classifier for a list of images and segmentations. - This function saves the features and labels for annotated objects across the series, so a random + This function saves the features and labels for annotated objects across the batch, so a random forest can be trained on multiple images, plus the per-image prediction and the trained classifier. Args: @@ -359,11 +359,11 @@ def image_series_object_classifier( first = images[0] if have_inputs_as_arrays else imageio.imread(images[0]) ndim = first.ndim - 1 if first.shape[-1] == 3 and first.ndim in (3, 4) else first.ndim - task = ObjectClassificationSeriesTask( + task = ObjectClassificationBatchTask( segmentations=segmentations, ndim=ndim, model_type=model_type, embedding_paths=embedding_paths, tile_shape=tile_shape, halo=halo, checkpoint_path=checkpoint_path, device=device, ) - return run_image_series( + return run_batch( images, output_folder, task, have_inputs_as_arrays=have_inputs_as_arrays, viewer=viewer, return_viewer=return_viewer, skip_done=skip_done, ) diff --git a/micro_sam/sam_annotator/pixel_classifier.py b/micro_sam/sam_annotator/pixel_classifier.py index a5597acd..adf98745 100644 --- a/micro_sam/sam_annotator/pixel_classifier.py +++ b/micro_sam/sam_annotator/pixel_classifier.py @@ -11,8 +11,8 @@ accumulate_pixel_labels, compute_pixel_features, project_prediction_to_image, train_pixel_classifier, ) from ._annotator import _ClassifierBase -from ._series import run_image_series -from ._classification_series import ClassificationSeriesTask +from ._batch import run_batch +from ._batch_classification import ClassificationBatchTask from ._state import AnnotatorState from . import _widgets as widgets from .util import _sync_embedding_widget @@ -137,17 +137,17 @@ def pixel_classifier( napari.run() -class PixelClassificationSeriesTask(ClassificationSeriesTask): - """Series task for the pixel classifier.""" +class PixelClassificationBatchTask(ClassificationBatchTask): + """Batch task for the pixel classifier.""" - dock_name = "Segment Anything for Microscopy (Image Series Pixel Classification)" + dock_name = "Segment Anything for Microscopy (Batch Pixel Classification)" classifier_class = PixelClassifier features_attr = "pixel_features" aux_attr = "pixel_grid_shape" rf_attr = "pixel_rf" -def image_series_pixel_classifier( +def batch_pixel_classifier( images: List[np.ndarray], output_folder: str, embedding_paths: Optional[List[Union[str, util.ImageEmbeddings]]] = None, @@ -163,7 +163,7 @@ def image_series_pixel_classifier( ) -> Optional["napari.viewer.Viewer"]: """Start the pixel classifier for a list of images. - This function saves the per-pixel features and labels across the series, so a random forest can be + This function saves the per-pixel features and labels across the batch, so a random forest can be trained on multiple images, plus the per-image prediction and the trained classifier. Args: @@ -192,11 +192,11 @@ def image_series_pixel_classifier( first = images[0] if have_inputs_as_arrays else imageio.imread(images[0]) ndim = first.ndim - 1 if first.shape[-1] == 3 and first.ndim in (3, 4) else first.ndim - task = PixelClassificationSeriesTask( + task = PixelClassificationBatchTask( ndim=ndim, model_type=model_type, embedding_paths=embedding_paths, tile_shape=tile_shape, halo=halo, checkpoint_path=checkpoint_path, device=device, ) - return run_image_series( + return run_batch( images, output_folder, task, have_inputs_as_arrays=have_inputs_as_arrays, viewer=viewer, return_viewer=return_viewer, skip_done=skip_done, ) diff --git a/micro_sam/sam_annotator/util.py b/micro_sam/sam_annotator/util.py index d23eaa33..742dce0a 100644 --- a/micro_sam/sam_annotator/util.py +++ b/micro_sam/sam_annotator/util.py @@ -147,7 +147,7 @@ def _initialize_parser(description, with_segmentation_result=True, with_instance parser.add_argument( "-k", "--key", help="The key for opening data with elf.io.open_file. This is the internal path for a hdf5 or zarr container, " - "for a image series it is a wild-card, e.g. '*.png' and for mrc it is 'data'." + "for an image batch it is a wild-card, e.g. '*.png' and for mrc it is 'data'." ) parser.add_argument( "-e", "--embedding_path", diff --git a/setup.cfg b/setup.cfg index 6982d68e..f9bbb27a 100644 --- a/setup.cfg +++ b/setup.cfg @@ -78,7 +78,7 @@ napari.manifest = console_scripts = micro_sam.annotator = micro_sam.sam_annotator.annotator:main micro_sam.annotator_tracking = micro_sam.sam_annotator.annotator_tracking:main - micro_sam.image_series_annotator = micro_sam.sam_annotator.batch_annotator:main + micro_sam.batch_annotator = micro_sam.sam_annotator.batch_annotator:main micro_sam.precompute_embeddings = micro_sam.precompute_state:main micro_sam.automatic_segmentation = micro_sam.v1.automatic_segmentation:main micro_sam.train = micro_sam.v1.training.training:main diff --git a/test/test_cli.py b/test/test_cli.py index 453ec4a6..315d8aa4 100644 --- a/test/test_cli.py +++ b/test/test_cli.py @@ -32,8 +32,8 @@ def test_annotator(self): def test_annotator_tracking(self): self._test_command("micro_sam.annotator_tracking") - def test_image_series_annotator(self): - self._test_command("micro_sam.image_series_annotator") + def test_batch_annotator(self): + self._test_command("micro_sam.batch_annotator") @pytest.mark.skipif(platform.system() == "Windows", reason="CLI test is not working on windows.") def test_precompute_embeddings(self): diff --git a/test/test_sam_annotator/conftest.py b/test/test_sam_annotator/conftest.py new file mode 100644 index 00000000..0f9c6def --- /dev/null +++ b/test/test_sam_annotator/conftest.py @@ -0,0 +1,13 @@ +import pytest + +from micro_sam.sam_annotator._state import AnnotatorState, Singleton + + +@pytest.fixture(autouse=True) +def reset_annotator_state(): + # 'AnnotatorState' is a process-wide singleton, so state set by one test leaks into the next and + # makes the GUI tests order-dependent. Drop the cached instance around each test so every test + # starts from a fresh state, with all dataclass fields back to their defaults. + Singleton._instances.pop(AnnotatorState, None) + yield + Singleton._instances.pop(AnnotatorState, None) diff --git a/test/test_sam_annotator/test_image_series_classifier.py b/test/test_sam_annotator/test_batch_annotator_classifier.py similarity index 86% rename from test/test_sam_annotator/test_image_series_classifier.py rename to test/test_sam_annotator/test_batch_annotator_classifier.py index 5a4c3309..79a54cbf 100644 --- a/test/test_sam_annotator/test_image_series_classifier.py +++ b/test/test_sam_annotator/test_batch_annotator_classifier.py @@ -10,8 +10,8 @@ from micro_sam.v2.util import DEFAULT_MODEL from micro_sam.sam_annotator._state import AnnotatorState -from micro_sam.sam_annotator.object_classifier import image_series_object_classifier -from micro_sam.sam_annotator.pixel_classifier import image_series_pixel_classifier +from micro_sam.sam_annotator.object_classifier import batch_object_classifier +from micro_sam.sam_annotator.pixel_classifier import batch_pixel_classifier MODEL_TYPE = DEFAULT_MODEL @@ -22,8 +22,8 @@ def _images(n, size=256): @pytest.mark.gui @pytest.mark.skipif(platform.system() in ("Windows",), reason="Gui test is not working on windows.") -def test_image_series_object_classifier_navigation(make_napari_viewer_proxy): - """Drive the object-classifier series harness: train, then advance to save the prediction, the +def test_batch_object_classifier_navigation(make_napari_viewer_proxy): + """Drive the object-classifier batch harness: train, then advance to save the prediction, the classifier and the accumulated features/labels, and load the next image. """ images = _images(2) @@ -37,12 +37,12 @@ def test_image_series_object_classifier_navigation(make_napari_viewer_proxy): with tempfile.TemporaryDirectory() as tmpdir: output_folder = os.path.join(tmpdir, "results") viewer = make_napari_viewer_proxy() - viewer = image_series_object_classifier( + viewer = batch_object_classifier( images, segmentations, output_folder, model_type=MODEL_TYPE, viewer=viewer, return_viewer=True, ) state = AnnotatorState() - # The series starts on the first image with image, segmentation, annotations and prediction layers. + # The batch starts on the first image with image, segmentation, annotations and prediction layers. for name in ("image", "segmentation", "annotations", "prediction"): assert name in viewer.layers @@ -52,7 +52,7 @@ def test_image_series_object_classifier_navigation(make_napari_viewer_proxy): assert state.object_rf is not None assert viewer.layers["prediction"].data.sum() > 0 - state.widgets["series_next"]() + state.widgets["batch_next"]() # The prediction, the classifier and the accumulated features/labels are saved. assert os.path.exists(os.path.join(output_folder, "prediction_00000.tif")) @@ -75,8 +75,8 @@ def test_image_series_object_classifier_navigation(make_napari_viewer_proxy): @pytest.mark.gui @pytest.mark.skipif(platform.system() in ("Windows",), reason="Gui test is not working on windows.") -def test_image_series_pixel_classifier_navigation(make_napari_viewer_proxy): - """Drive the pixel-classifier series harness through one train + advance cycle.""" +def test_batch_pixel_classifier_navigation(make_napari_viewer_proxy): + """Drive the pixel-classifier batch harness through one train + advance cycle.""" images = _images(2) ann = np.zeros((256, 256), dtype="uint32") ann[10:60, 10:60] = 1 @@ -85,7 +85,7 @@ def test_image_series_pixel_classifier_navigation(make_napari_viewer_proxy): with tempfile.TemporaryDirectory() as tmpdir: output_folder = os.path.join(tmpdir, "results") viewer = make_napari_viewer_proxy() - viewer = image_series_pixel_classifier( + viewer = batch_pixel_classifier( images, output_folder, model_type=MODEL_TYPE, viewer=viewer, return_viewer=True, ) @@ -99,7 +99,7 @@ def test_image_series_pixel_classifier_navigation(make_napari_viewer_proxy): assert state.pixel_rf is not None assert viewer.layers["prediction"].data.sum() > 0 - state.widgets["series_next"]() + state.widgets["batch_next"]() assert os.path.exists(os.path.join(output_folder, "prediction_00000.tif")) assert os.path.exists(os.path.join(output_folder, "rf.joblib")) @@ -126,18 +126,18 @@ def test_object_classifier_forwards_state_by_default(make_napari_viewer_proxy): with tempfile.TemporaryDirectory() as tmpdir: viewer = make_napari_viewer_proxy() - viewer = image_series_object_classifier( + viewer = batch_object_classifier( images, segmentations, os.path.join(tmpdir, "results"), model_type=MODEL_TYPE, viewer=viewer, return_viewer=True, ) state = AnnotatorState() - assert state.widgets["series_forward_state"].value is True # on by default + assert state.widgets["batch_forward_state"].value is True # on by default viewer.layers["annotations"].data = ann state.annotator._run_train_and_predict(True) # Advance without annotating image 1: the forwarded classifier predicts on it. - state.widgets["series_next"]() + state.widgets["batch_next"]() assert viewer.layers["annotations"].data.sum() == 0 # no annotations on image 1 assert viewer.layers["prediction"].data.sum() > 0 # ...but it is predicted assert state.previous_labels is not None and state.previous_labels.shape[0] == 2 @@ -160,7 +160,7 @@ def test_object_classifier_independent_when_forward_off(make_napari_viewer_proxy with tempfile.TemporaryDirectory() as tmpdir: viewer = make_napari_viewer_proxy() - viewer = image_series_object_classifier( + viewer = batch_object_classifier( images, segmentations, os.path.join(tmpdir, "results"), model_type=MODEL_TYPE, viewer=viewer, return_viewer=True, ) @@ -169,8 +169,8 @@ def test_object_classifier_independent_when_forward_off(make_napari_viewer_proxy state.annotator._run_train_and_predict(True) # Turn off forwarding, then advance. - state.widgets["series_forward_state"].value = False - state.widgets["series_next"]() + state.widgets["batch_forward_state"].value = False + state.widgets["batch_next"]() # The accumulated training set and the classifier are reset; image 1 is not auto-predicted. assert state.previous_features is None and state.previous_labels is None @@ -191,16 +191,16 @@ def test_pixel_classifier_forwards_state_by_default(make_napari_viewer_proxy): with tempfile.TemporaryDirectory() as tmpdir: viewer = make_napari_viewer_proxy() - viewer = image_series_pixel_classifier( + viewer = batch_pixel_classifier( images, os.path.join(tmpdir, "results"), model_type=MODEL_TYPE, viewer=viewer, return_viewer=True, ) state = AnnotatorState() - assert state.widgets["series_forward_state"].value is True + assert state.widgets["batch_forward_state"].value is True viewer.layers["annotations"].data = ann state.annotator._run_train_and_predict(True) - state.widgets["series_next"]() + state.widgets["batch_next"]() assert viewer.layers["annotations"].data.sum() == 0 assert viewer.layers["prediction"].data.sum() > 0 diff --git a/test/test_sam_annotator/test_image_series_launcher.py b/test/test_sam_annotator/test_batch_annotator_launcher.py similarity index 79% rename from test/test_sam_annotator/test_image_series_launcher.py rename to test/test_sam_annotator/test_batch_annotator_launcher.py index 214d0f2f..5560c163 100644 --- a/test/test_sam_annotator/test_image_series_launcher.py +++ b/test/test_sam_annotator/test_batch_annotator_launcher.py @@ -92,13 +92,13 @@ def _row_widgets(): assert widget._relocated_model_dropdown in _row_widgets() -# Each task must dispatch to its series function. The launcher imports these lazily from their home +# Each task must dispatch to its batch function. The launcher imports these lazily from their home # modules, so patching the module attribute intercepts the call. DISPATCH = [ ("Segmentation", "micro_sam.sam_annotator.batch_annotator", "image_folder_annotator"), - ("Tracking", "micro_sam.sam_annotator.annotator_tracking", "image_series_tracking_annotator"), - ("Pixel Classification", "micro_sam.sam_annotator.pixel_classifier", "image_series_pixel_classifier"), - ("Object Classification", "micro_sam.sam_annotator.object_classifier", "image_series_object_classifier"), + ("Tracking", "micro_sam.sam_annotator.annotator_tracking", "batch_tracking_annotator"), + ("Pixel Classification", "micro_sam.sam_annotator.pixel_classifier", "batch_pixel_classifier"), + ("Object Classification", "micro_sam.sam_annotator.object_classifier", "batch_object_classifier"), ] @@ -110,7 +110,12 @@ def test_launcher_dispatches_to_the_selected_task( ): module = importlib.import_module(module_path) calls = [] - monkeypatch.setattr(module, func_name, lambda *args, **kwargs: calls.append((args, kwargs))) + + def launch(*args, **kwargs): + calls.append((args, kwargs)) + return kwargs["viewer"] + + monkeypatch.setattr(module, func_name, launch) with tempfile.TemporaryDirectory() as tmpdir: for i in range(2): @@ -126,7 +131,7 @@ def test_launcher_dispatches_to_the_selected_task( widget(skip_validate=True) assert len(calls) == 1, f"expected exactly one dispatch for task '{task}'" - # The output folder is forwarded to the selected series function (positionally or by keyword). + # The output folder is forwarded to the selected batch function (positionally or by keyword). args, kwargs = calls[0] assert widget.output_folder in args or widget.output_folder in kwargs.values() if task == "Segmentation": @@ -138,7 +143,12 @@ def test_launcher_dispatches_to_the_selected_task( def test_launcher_can_restart_segmentation_from_first_image(make_napari_viewer_proxy, monkeypatch): isa = importlib.import_module("micro_sam.sam_annotator.batch_annotator") calls = [] - monkeypatch.setattr(isa, "image_folder_annotator", lambda *args, **kwargs: calls.append((args, kwargs))) + + def launch(*args, **kwargs): + calls.append((args, kwargs)) + return kwargs["viewer"] + + monkeypatch.setattr(isa, "image_folder_annotator", launch) with tempfile.TemporaryDirectory() as tmpdir: imageio.imwrite(os.path.join(tmpdir, "image.tif"), binary_blobs(64).astype(np.uint8) * 255) @@ -164,7 +174,11 @@ def test_launcher_removes_itself_after_launch(make_napari_viewer_proxy, monkeypa # the annotator has the screen to itself. from qtpy.QtWidgets import QApplication, QDockWidget isa = importlib.import_module("micro_sam.sam_annotator.batch_annotator") - monkeypatch.setattr(isa, "image_folder_annotator", lambda *args, **kwargs: None) + + def launch(*args, **kwargs): + return kwargs["viewer"] + + monkeypatch.setattr(isa, "image_folder_annotator", launch) with tempfile.TemporaryDirectory() as tmpdir: for i in range(2): @@ -184,3 +198,33 @@ def test_launcher_removes_itself_after_launch(make_napari_viewer_proxy, monkeypa QApplication.processEvents() assert dock not in viewer.window._qt_window.findChildren(QDockWidget) + + +@pytest.mark.gui +@pytest.mark.skipif(platform.system() in ("Windows",), reason="Gui test is not working on windows.") +def test_launcher_stays_open_when_all_images_are_annotated(make_napari_viewer_proxy, monkeypatch): + from qtpy.QtWidgets import QApplication, QDockWidget + isa = importlib.import_module("micro_sam.sam_annotator.batch_annotator") + messages = [] + monkeypatch.setattr(isa, "image_folder_annotator", lambda *args, **kwargs: None) + monkeypatch.setattr(isa.widgets, "_generate_message", lambda *args: messages.append(args)) + + with tempfile.TemporaryDirectory() as tmpdir: + imageio.imwrite(os.path.join(tmpdir, "image.tif"), binary_blobs(64).astype(np.uint8) * 255) + + viewer = make_napari_viewer_proxy() + widget = BatchAnnotator(viewer) + widget.folder = tmpdir + widget.output_folder = os.path.join(tmpdir, "out") + widget.pattern = "*.tif" + dock = viewer.window.add_dock_widget(widget, name="Batch Annotator") + + widget(skip_validate=True) + for _ in range(3): + QApplication.processEvents() + + assert dock in viewer.window._qt_window.findChildren(QDockWidget) + assert len(messages) == 1 + assert messages[0][0] == "info" + assert "All images have already been annotated" in messages[0][1] + assert "Continue Annotation" in messages[0][1] diff --git a/test/test_sam_annotator/test_image_series_annotator.py b/test/test_sam_annotator/test_batch_annotator_segmentation.py similarity index 79% rename from test/test_sam_annotator/test_image_series_annotator.py rename to test/test_sam_annotator/test_batch_annotator_segmentation.py index 25846d61..01e0ab70 100644 --- a/test/test_sam_annotator/test_image_series_annotator.py +++ b/test/test_sam_annotator/test_batch_annotator_segmentation.py @@ -10,7 +10,7 @@ import micro_sam.util as util from micro_sam.v2.util import DEFAULT_MODEL -from micro_sam.sam_annotator import image_series_annotator, image_folder_annotator +from micro_sam.sam_annotator import batch_annotator, image_folder_annotator from micro_sam.sam_annotator._state import AnnotatorState from micro_sam._test_util import check_layer_initialization @@ -27,8 +27,8 @@ def _create_images(tmpdir, n_images): @pytest.mark.gui @pytest.mark.skipif(platform.system() in ("Windows",), reason="Gui test is not working on windows.") -def test_image_series_annotator(make_napari_viewer_proxy): - """Integration test for `image_series_annotator`. +def test_batch_annotator(make_napari_viewer_proxy): + """Integration test for `batch_annotator`. """ n_images = 3 model_type = DEFAULT_MODEL @@ -39,7 +39,7 @@ def test_image_series_annotator(make_napari_viewer_proxy): viewer = make_napari_viewer_proxy() # test generating image embedding, then adding micro-sam dock widgets to the GUI - viewer = image_series_annotator( + viewer = batch_annotator( image_paths, output_folder, model_type=model_type, @@ -53,7 +53,7 @@ def test_image_series_annotator(make_napari_viewer_proxy): @pytest.mark.gui @pytest.mark.skipif(platform.system() in ("Windows",), reason="Gui test is not working on windows.") -def test_image_series_navigation(make_napari_viewer_proxy): +def test_batch_navigation(make_napari_viewer_proxy): """Drive the forward-only navigation harness: advancing saves and loads the next image.""" n_images = 3 model_type = DEFAULT_MODEL @@ -63,15 +63,15 @@ def test_image_series_navigation(make_napari_viewer_proxy): output_folder = os.path.join(tmpdir, "segmentation_results") viewer = make_napari_viewer_proxy() - viewer = image_series_annotator( + viewer = batch_annotator( image_paths, output_folder, model_type=model_type, viewer=viewer, return_viewer=True, ) state = AnnotatorState() - next_image = state.widgets["series_next"] - assert "series_prev" not in state.widgets + next_image = state.widgets["batch_next"] + assert "batch_prev" not in state.widgets - # In a series session the launcher owns the embedding settings, so the docked annotator's + # In a batch session the launcher owns the embedding settings, so the docked annotator's # embedding section is hidden (its wrapping group box is explicitly hidden). embedding_widget = AnnotatorState().annotator._embedding_widget frame = embedding_widget @@ -103,8 +103,8 @@ def _result_path(index): @pytest.mark.gui @pytest.mark.skipif(platform.system() in ("Windows",), reason="Gui test is not working on windows.") -def test_image_series_lazy_embeddings(make_napari_viewer_proxy): - """The segmentation series computes embeddings lazily per item (saved to a per-item zarr) and +def test_batch_lazy_embeddings(make_napari_viewer_proxy): + """The segmentation batch computes embeddings lazily per item (saved to a per-item zarr) and reuses the loaded model across items, rather than precomputing everything up front. """ model_type = "vit_t" if util.VIT_T_SUPPORT else "vit_b" @@ -116,7 +116,7 @@ def test_image_series_lazy_embeddings(make_napari_viewer_proxy): embedding_folder = os.path.join(tmpdir, "emb") viewer = make_napari_viewer_proxy() - viewer = image_series_annotator( + viewer = batch_annotator( image_paths, output_folder, model_type=model_type, embedding_path=embedding_folder, viewer=viewer, return_viewer=True, ) @@ -126,7 +126,7 @@ def _zarr(index): return os.path.join(embedding_folder, stem + ".zarr") state = AnnotatorState() - # Only the first item's embeddings are computed at launch (lazy), not the whole series. + # Only the first item's embeddings are computed at launch (lazy), not the whole batch. assert os.path.exists(_zarr(0)) assert not os.path.exists(_zarr(1)) @@ -135,7 +135,7 @@ def _zarr(index): # Advancing computes the next item's embeddings now, reusing the already-loaded model. viewer.layers["committed_objects"].data = np.ones((512, 512), dtype="uint32") - state.widgets["series_next"]() + state.widgets["batch_next"]() assert os.path.exists(_zarr(1)) assert state.predictor is predictor @@ -171,7 +171,7 @@ def test_image_folder_annotator(make_napari_viewer_proxy): @pytest.mark.gui @pytest.mark.skipif(platform.system() in ("Windows",), reason="Gui test is not working on windows.") -def test_image_series_continue_or_restart(make_napari_viewer_proxy): +def test_batch_annotator_continue_or_restart(make_napari_viewer_proxy): """Existing outputs are completion markers when continuing and editable inputs when restarting.""" with tempfile.TemporaryDirectory() as tmpdir: image_paths = _create_images(tmpdir, 3) @@ -183,7 +183,7 @@ def test_image_series_continue_or_restart(make_napari_viewer_proxy): # Continue mode skips the completed first image and starts at the first missing output. viewer = make_napari_viewer_proxy() - viewer = image_series_annotator( + viewer = batch_annotator( image_paths, output_folder, model_type=DEFAULT_MODEL, viewer=viewer, return_viewer=True, skip_segmented=True, ) @@ -193,10 +193,33 @@ def test_image_series_continue_or_restart(make_napari_viewer_proxy): # Restart mode begins at image 0 and loads its saved segmentation for review or editing. viewer = make_napari_viewer_proxy() - viewer = image_series_annotator( + viewer = batch_annotator( image_paths, output_folder, model_type=DEFAULT_MODEL, viewer=viewer, return_viewer=True, skip_segmented=False, ) np.testing.assert_array_equal(viewer.layers["image"].data, imageio.imread(image_paths[0])) np.testing.assert_array_equal(viewer.layers["committed_objects"].data, completed) viewer.close() + + +@pytest.mark.gui +@pytest.mark.skipif(platform.system() in ("Windows",), reason="Gui test is not working on windows.") +def test_batch_annotator_all_images_done(make_napari_viewer_proxy, capsys): + """A napari launch reports an exhausted batch to its caller instead of writing to the terminal.""" + with tempfile.TemporaryDirectory() as tmpdir: + image_paths = _create_images(tmpdir, 2) + output_folder = os.path.join(tmpdir, "segmentation_results") + os.makedirs(output_folder) + for image_path in image_paths: + result_path = os.path.join(output_folder, os.path.splitext(os.path.basename(image_path))[0] + ".tif") + imageio.imwrite(result_path, np.zeros((512, 512), dtype="uint32")) + + viewer = make_napari_viewer_proxy() + result = batch_annotator( + image_paths, output_folder, model_type=DEFAULT_MODEL, + viewer=viewer, return_viewer=True, skip_segmented=True, + ) + + assert result is None + assert capsys.readouterr().out == "" + viewer.close() diff --git a/test/test_sam_annotator/test_image_series_tracking.py b/test/test_sam_annotator/test_batch_annotator_tracking.py similarity index 80% rename from test/test_sam_annotator/test_image_series_tracking.py rename to test/test_sam_annotator/test_batch_annotator_tracking.py index 18c791cc..bed7542f 100644 --- a/test/test_sam_annotator/test_image_series_tracking.py +++ b/test/test_sam_annotator/test_batch_annotator_tracking.py @@ -9,13 +9,13 @@ from micro_sam.v2.util import DEFAULT_MODEL from micro_sam.sam_annotator._state import AnnotatorState -from micro_sam.sam_annotator.annotator_tracking import AnnotatorTracking, image_series_tracking_annotator +from micro_sam.sam_annotator.annotator_tracking import AnnotatorTracking, batch_tracking_annotator @pytest.mark.gui @pytest.mark.skipif(platform.system() in ("Windows",), reason="Gui test is not working on windows.") -def test_image_series_tracking_navigation(make_napari_viewer_proxy): - """Drive the tracking series harness: each item is a video, advancing saves the tracks and loads +def test_batch_tracking_navigation(make_napari_viewer_proxy): + """Drive the tracking batch harness: each item is a video, advancing saves the tracks and loads the next video. """ videos = [np.stack(3 * [binary_blobs(256)]).astype("float32") for _ in range(2)] @@ -23,7 +23,7 @@ def test_image_series_tracking_navigation(make_napari_viewer_proxy): with tempfile.TemporaryDirectory() as tmpdir: output_folder = os.path.join(tmpdir, "tracking_results") viewer = make_napari_viewer_proxy() - viewer = image_series_tracking_annotator( + viewer = batch_tracking_annotator( videos, output_folder, model_type=DEFAULT_MODEL, viewer=viewer, return_viewer=True, ) @@ -31,13 +31,13 @@ def test_image_series_tracking_navigation(make_napari_viewer_proxy): assert isinstance(state.annotator, AnnotatorTracking) assert "committed_objects" in viewer.layers # Only the Next control is registered. - assert "series_next" in state.widgets - assert "series_prev" not in state.widgets + assert "batch_next" in state.widgets + assert "batch_prev" not in state.widgets # Commit a fake tracking result for the first video and advance. tracks = np.ones((3, 256, 256), dtype="uint32") viewer.layers["committed_objects"].data = tracks - state.widgets["series_next"]() + state.widgets["batch_next"]() assert os.path.exists(os.path.join(output_folder, "tracks_00000.tif")) np.testing.assert_array_equal(imageio.imread(os.path.join(output_folder, "tracks_00000.tif")), tracks) diff --git a/test/test_sam_annotator/test_classifier_ndim.py b/test/test_sam_annotator/test_classifier_ndim.py index 5736f381..eed2c69d 100644 --- a/test/test_sam_annotator/test_classifier_ndim.py +++ b/test/test_sam_annotator/test_classifier_ndim.py @@ -36,9 +36,6 @@ def test_classifier_recreates_label_layers_for_3d(make_napari_viewer_proxy, clas # layers; loading a 3d image must recreate them at ndim=3 rather than reassigning 3d data + a # 3-element scale onto stale 2d layers (which crashes napari in Affine.set_slice). state = AnnotatorState() - state.image_shape = None - state.image_scale = None - state.skip_recomputing_embeddings = False viewer = make_napari_viewer_proxy() annotator = classifier_cls(viewer)