Source code for rapidtools.processing.image_segmenters

# Copyright (c) 2025 The University of Washington
#
# This file is part of rapidtools.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
#
# 3. Neither the name of the copyright holder nor the names of its contributors
# may be used to endorse or promote products derived from this software without
# specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
#
# You should have received a copy of the BSD 3-Clause License along with
# rapidtools. If not, see <http://www.opensource.org/licenses/>.
#
# Contributors:
# Barbaros Cetiner
#
# Last updated:
# 09-22-2026

"""
Batched, prompt-guided image segmentation for asset imagery.

This module provides :class:`SAM3ImageSegmenter`, a pipeline component that
runs a locally hosted SAM 3 (Segment Anything Model 3) over every downloaded
image attached to the assets of a
:class:`~rapidtools.core.PhysicalAssetCollection`. Masks and bounding boxes
are written back into each asset's ``attributes`` so that downstream steps
(e.g., :class:`~rapidtools.processing.BuildingRegularizer`) can georeference
them.

Example:
    >>> from rapidtools.processing import (
    ...     AerialImageryExtractor, Pipeline, SAM3ImageSegmenter
    ... )
    >>> pipeline = Pipeline([
    ...     AerialImageryExtractor('ortho.tif', save_directory='crops'),
    ...     SAM3ImageSegmenter(prompt=['building', 'roof'], batch_size=8),
    ... ])
    >>> result = pipeline.run(my_collection)
    >>> result['bldg_01'].attributes['sam3_masks'].keys()
    dict_keys(['bldg_01_aerial'])
"""

import logging
import threading
from collections.abc import Callable
from typing import TYPE_CHECKING, Any

from tqdm import tqdm

from rapidtools.core import (
    ImageAsset,
    PhysicalAsset,
    PhysicalAssetCollection,
    raise_if_cancelled,
)

from .step import Stage

logger = logging.getLogger(__name__)

if TYPE_CHECKING:
    from rapidtools.models import SAM3Inference


def _sam3_inference_class() -> 'type[SAM3Inference]':
    """
    Return :class:`~rapidtools.models.SAM3Inference`, importing it on first use.

    The SAM 3 wrapper pulls in PyTorch and Transformers, so it is not imported
    with the module; ``import rapidtools.processing`` stays light and the GUI
    can validate settings without loading the model stack. Tests may inject a
    stand-in by setting ``SAM3Inference`` on this module.
    """
    injected = globals().get('SAM3Inference')
    if injected is not None:
        return injected
    from rapidtools.models import SAM3Inference

    return SAM3Inference


def __getattr__(name: str) -> Any:
    """Expose ``SAM3Inference`` lazily so ``monkeypatch.setattr`` finds it."""
    if name == 'SAM3Inference':
        return _sam3_inference_class()
    raise AttributeError(f'module {__name__!r} has no attribute {name!r}')


[docs] class SAM3ImageSegmenter: """ Pipeline component that uses local SAM 3 to segment images attached to assets. This segmenter gathers filtered images across all ``PhysicalAsset`` objects, batches them together (to maximize GPU utilization without causing OOM errors), and stores the resulting segmentation masks directly in the corresponding asset's attributes under the following keys: - ``'sam3_masks'``: ``{image_id: masks}`` where ``masks`` is the per-image mask stack returned by the model (typically an ``(N, H, W)`` boolean array). - ``'sam3_bounding_boxes'``: ``{image_id: boxes}`` (only when the model returns boxes). - ``'ai_model_used'``: The Hugging Face model ID that was used. Args: prompt (str | list[str], optional): The text prompt (or list of prompts) to guide the segmentation (e.g., ``'building'`` or ``['building', 'tree']``). Lists are joined into a single period-separated prompt. Defaults to ``''``. model_id (str, optional): The Hugging Face repository ID for the SAM 3 model. Defaults to ``'facebook/sam3'``. device (str, optional): The compute device to use (``'cuda'``, ``'cpu'``, ``'auto'``). Defaults to ``'auto'``. load_in_4bit (bool, optional): Whether to load the model using 4-bit quantization. Defaults to ``True``. batch_size (int, optional): Number of images to process simultaneously across assets. Defaults to 4. threshold (float, optional): Confidence threshold for predictions. Defaults to 0.5. mask_threshold (float, optional): Threshold for binarizing the masks. Defaults to 0.5. image_filter (Callable[[ImageAsset], bool] | None, optional): A function that takes an ``ImageAsset`` and returns ``True`` if the image should be segmented. If ``None``, all downloaded images attached to the asset are processed. Defaults to ``None``. cancel_event (threading.Event | None, optional): Cooperative cancellation flag. When set, the segmenter raises :class:`~rapidtools.core.OperationCancelled` before starting the next batch. Defaults to ``None``. Example: Segment only the aerial crops of each asset: >>> from rapidtools.processing import SAM3ImageSegmenter >>> segmenter = SAM3ImageSegmenter( ... prompt='building', ... batch_size=8, ... image_filter=lambda img: img.id.endswith('_aerial'), ... ) >>> collection = segmenter(my_collection) >>> masks = collection['bldg_01'].attributes['sam3_masks'] >>> masks['bldg_01_aerial'].shape # (num_instances, height, width) (2, 512, 512) """ stage = Stage.SEGMENT def __init__( self, prompt: str | list[str] = '', model_id: str = 'facebook/sam3', device: str = 'auto', load_in_4bit: bool = True, batch_size: int = 4, threshold: float = 0.5, mask_threshold: float = 0.5, image_filter: Callable[[ImageAsset], bool] | None = None, cancel_event: threading.Event | None = None, ) -> None: """ Initialize the SAM 3 Image Segmenter and load the model once. Args: prompt (str | list[str], optional): The text prompt (or list of prompts) to guide the segmentation. Lists are joined with ``'. '``. model_id (str, optional): The Hugging Face repository ID for the SAM 3 model. device (str, optional): The compute device to use (``'cuda'``, ``'cpu'``, ``'auto'``). load_in_4bit (bool, optional): Whether to load the model using 4-bit quantization. batch_size (int, optional): Number of images to process simultaneously across assets. threshold (float, optional): Confidence threshold for predictions. mask_threshold (float, optional): Threshold for binarizing the masks. image_filter (Callable[[ImageAsset], bool] | None, optional): Predicate selecting which images of each asset to segment. cancel_event (threading.Event | None, optional): Cooperative cancellation flag checked between batches. """ # Cooperative cancellation (stops between batches): self.cancel_event = cancel_event if isinstance(prompt, list): self.prompt = '. '.join(prompt) else: self.prompt = prompt self.image_filter = image_filter self.batch_size = batch_size self.threshold = threshold self.mask_threshold = mask_threshold # Instantiate the underlying inference model ONCE to save load time: self.model = _sam3_inference_class()( model_id=model_id, device=device, load_in_4bit=load_in_4bit, ) def __call__( self, asset_collection: PhysicalAssetCollection, ) -> PhysicalAssetCollection: """ Execute the segmentation process on the provided asset collection. Images are collected from every asset (optionally filtered by ``image_filter``), restricted to those that exist on disk, and processed in batches of ``batch_size``. A batch that fails (model returns ``None`` or raises) is counted and logged, but does not stop the remaining batches. Args: asset_collection (PhysicalAssetCollection): The collection of physical assets to process. Returns: PhysicalAssetCollection: The mutated collection with ``'sam3_masks'``, ``'sam3_bounding_boxes'`` and ``'ai_model_used'`` written to the attributes of every asset that had at least one successfully segmented image. Raises: OperationCancelled: If ``cancel_event`` is set between two batches. Example: >>> segmenter = SAM3ImageSegmenter(prompt='utility pole') >>> collection = segmenter(my_collection) """ items_to_process: list[tuple[PhysicalAsset, ImageAsset]] = [] for asset in asset_collection: target_images = asset.image_assets if self.image_filter is not None: target_images = target_images.filter(self.image_filter) for img in target_images: if img.is_downloaded: items_to_process.append((asset, img)) if not items_to_process: logger.warning('No valid downloaded images found to segment.') return asset_collection logger.info( f'SAM 3: Segmenting {len(items_to_process)} images across ' f'assets in batches of {self.batch_size}...' ) failed_count = 0 for i in tqdm( range(0, len(items_to_process), self.batch_size), desc='Segmenting Image Batches', ): raise_if_cancelled(self.cancel_event, 'SAM 3 segmentation') batch = items_to_process[i : i + self.batch_size] image_paths = [str(img.path) for _, img in batch] try: # Run inference on the batch of images: result = self.model.run_inference( image_inputs=image_paths, prompt=self.prompt, threshold=self.threshold, mask_threshold=self.mask_threshold, ) if result is None or result.masks is None: failed_count += len(batch) continue # Ensure the outputs are lists (in case of batch_size=1 remaining): masks_list = ( result.masks if isinstance(result.masks, list) else [result.masks] ) boxes_list: list = [None] * len(batch) if result.bounding_boxes is not None: boxes_list = ( result.bounding_boxes if isinstance(result.bounding_boxes, list) else [result.bounding_boxes] ) # The model returns exactly one entry per input image (``None`` # for images it could not load); anything else would silently # shift masks onto the wrong images, so treat it as a failure. if len(masks_list) != len(batch) or len(boxes_list) != len(batch): logger.error( f'SAM 3 returned {len(masks_list)} mask and ' f'{len(boxes_list)} box entries for a batch of ' f'{len(batch)} images; skipping the batch.' ) failed_count += len(batch) continue # Map the results back to the original assets and images: for (asset, img), masks, boxes in zip( batch, masks_list, boxes_list, strict=True ): if masks is None: logger.warning(f'SAM 3 could not process image {img.id}.') failed_count += 1 continue asset.attributes.setdefault('sam3_masks', {}) asset.attributes.setdefault('sam3_bounding_boxes', {}) asset.attributes['sam3_masks'][img.id] = masks # Use 'is not None' to prevent ambiguous truth value crashes # with numpy arrays: if boxes is not None: asset.attributes['sam3_bounding_boxes'][img.id] = boxes asset.attributes['ai_model_used'] = self.model.model_id except Exception as e: logger.debug(f'Unhandled exception processing batch: {e}') failed_count += len(batch) if failed_count > 0: logger.error(f'SAM 3: {failed_count} images failed to process.') else: logger.info('SAM 3: All applicable images segmented successfully.') return asset_collection