garmentiq.matting.matte

Producing an alpha matte and compositing with it.

Dispatches on the model family: ViTMatte is guided by a trimap, which can be supplied directly or derived from a segmentation mask, while Matting Anything is guided by a SAM prompt and needs no trimap at all.

  1"""Producing an alpha matte and compositing with it.
  2
  3Dispatches on the model family: ViTMatte is guided by a trimap, which can be supplied
  4directly or derived from a segmentation mask, while Matting Anything is guided by a SAM
  5prompt and needs no trimap at all.
  6"""
  7from PIL import Image
  8import numpy as np
  9import torch
 10import warnings
 11from typing import Optional, Union
 12
 13from garmentiq.utils.device import resolve_device, empty_cache, inputs_to_device
 14from garmentiq.matting.trimap import generate_trimap
 15
 16# Above roughly this many pixels the MPS backend has been observed to diverge from CPU
 17# for ViTMatte, silently producing a degenerate matte. Verified identical up to
 18# 2048x1536 (3.15 MP) and divergent at 2400x1800 (4.32 MP) on Apple Silicon.
 19_MPS_PIXEL_WARN_THRESHOLD = 3_500_000
 20
 21
 22def _detect_matting_family(model):
 23    """
 24    Identifies which matting approach a loaded model implements.
 25
 26    Detection walks the class hierarchy by name rather than importing the transformers
 27    classes, so a model is recognised without requiring every backend to be installed.
 28    """
 29    names = {cls.__name__ for cls in type(model).__mro__}
 30    if "MattingAnything" in names:
 31        return "mam"
 32    if {"VitMatteForImageMatting", "VitMattePreTrainedModel"} & names:
 33        return "vitmatte"
 34    return None
 35
 36
 37def matte(
 38    model,
 39    image_path: Union[str, np.ndarray, Image.Image],
 40    processor=None,
 41    mask: Optional[np.ndarray] = None,
 42    trimap: Optional[np.ndarray] = None,
 43    prompt: Optional[dict] = None,
 44    trimap_args: Optional[dict] = None,
 45    device: Union[str, torch.device] = "cpu",
 46):
 47    """
 48    Extracts a soft alpha matte for an image, refining a hard segmentation into soft edges.
 49
 50    Segmentation answers "which pixels belong to the garment" with a yes/no decision, which
 51    leaves stair-stepped borders and loses semi-transparent detail such as loose fabric,
 52    lace, and stray fibres. Matting instead predicts a continuous alpha value per pixel, so
 53    compositing onto a new background looks natural.
 54
 55    The two supported approaches need different guidance:
 56        - **ViTMatte** requires a trimap marking definite foreground, definite background,
 57          and the uncertain band between them. Pass `trimap` directly, or pass `mask` and a
 58          trimap will be derived from it via `generate_trimap`.
 59        - **Matting Anything (MAM)** needs no trimap. It prompts a frozen SAM with `prompt`
 60          and refines the resulting coarse mask into an alpha matte.
 61
 62    Args:
 63        model: A loaded matting model, either `VitMatteForImageMatting` or `MattingAnything`.
 64        image_path (Union[str, numpy.ndarray, PIL.Image.Image]): The image to matte, given as
 65                                                                 a file path, RGB array, or
 66                                                                 PIL image.
 67        processor (VitMatteImageProcessor, optional): Required for ViTMatte, which uses it to
 68                                                      stack the image and trimap into a
 69                                                      four-channel input. Default is None.
 70        mask (numpy.ndarray, optional): A binary segmentation mask used to derive a trimap
 71                                        when `trimap` is not supplied. Default is None.
 72        trimap (numpy.ndarray, optional): An explicit trimap with values `0`, `128`, and
 73                                          `255`. Takes precedence over `mask`. Default is None.
 74        prompt (dict, optional): Prompt for MAM's internal SAM, accepting `"points"`,
 75                                 `"labels"`, and/or `"boxes"` exactly as
 76                                 `garmentiq.segmentation.extract` does. Default is None.
 77        trimap_args (dict, optional): Options forwarded to `generate_trimap`, such as
 78                                      `"erode_size"` and `"dilate_size"`. Default is None.
 79        device (Union[str, torch.device], optional): The device to run inference on, e.g.
 80                                                     `"cpu"`, `"cuda"`, or `"mps"`. Hardware
 81                                                     acceleration is opt-in. Default is `"cpu"`.
 82
 83    Raises:
 84        ValueError: If the model is not a recognised matting model, if ViTMatte is used
 85                    without a processor or without either `trimap` or `mask`, if MAM is used
 86                    without a prompt, or if the requested `device` is unavailable.
 87        FileNotFoundError: If `image_path` points to a file that does not exist.
 88
 89    Returns:
 90        tuple (numpy.ndarray, numpy.ndarray): The original image as an RGB array, and the
 91                                              alpha matte as a `uint8` array in `[0, 255]`
 92                                              matching the image's height and width.
 93    """
 94    device = resolve_device(device)
 95    family = _detect_matting_family(model)
 96    if family is None:
 97        raise ValueError(
 98            "Unrecognised matting model. Expected a ViTMatte model "
 99            "(VitMatteForImageMatting) or a Matting Anything model (MattingAnything)."
100        )
101
102    if isinstance(image_path, str):
103        image = Image.open(image_path).convert("RGB")
104    elif isinstance(image_path, np.ndarray):
105        image = Image.fromarray(image_path.astype(np.uint8)).convert("RGB")
106    else:
107        image = image_path.convert("RGB")
108
109    image_np = np.array(image)
110    model = model.to(device)
111
112    if (
113        device.type == "mps"
114        and image_np.shape[0] * image_np.shape[1] > _MPS_PIXEL_WARN_THRESHOLD
115    ):
116        warnings.warn(
117            f"Matting a {image_np.shape[1]}x{image_np.shape[0]} image on the MPS backend. "
118            f"Above roughly {_MPS_PIXEL_WARN_THRESHOLD / 1e6:.1f} megapixels MPS has been "
119            f"observed to return a degenerate alpha matte that differs substantially from "
120            f"CPU. Consider device='cpu' for images this large, or downscale first.",
121            RuntimeWarning,
122            stacklevel=2,
123        )
124
125    if family == "vitmatte":
126        if processor is None:
127            raise ValueError(
128                "ViTMatte requires a processor. Pass processor=load_vitmatte_processor(...)."
129            )
130        if trimap is None:
131            if mask is None:
132                raise ValueError(
133                    "ViTMatte is a trimap-based model, so it needs either an explicit "
134                    "trimap= or a segmentation mask= to derive one from. Run segmentation "
135                    "first, then pass its mask here."
136                )
137            trimap = generate_trimap(mask, **(trimap_args or {}))
138
139        trimap_arr = np.asarray(trimap)
140        if trimap_arr.shape[:2] != image_np.shape[:2]:
141            raise ValueError(
142                f"trimap shape {trimap_arr.shape[:2]} does not match image shape "
143                f"{image_np.shape[:2]}."
144            )
145
146        # The processor rescales trimaps by its own rescale_factor (1/255), so hand it
147        # the raw 0-255 trimap. Pre-normalising here would rescale twice and collapse
148        # the trimap to near-zero, making the model see everything as background.
149        inputs = inputs_to_device(
150            processor(
151                images=image, trimaps=trimap_arr.astype(np.uint8), return_tensors="pt"
152            ),
153            device,
154        )
155
156        with torch.no_grad():
157            outputs = model(**inputs)
158
159        alpha = outputs.alphas
160        # ViTMatte pads to a multiple of its patch size; crop back to the true size.
161        alpha = alpha[..., : image_np.shape[0], : image_np.shape[1]]
162        alpha_np = alpha[0, 0].detach().cpu().numpy()
163        del inputs, outputs
164
165    else:
166        from garmentiq.matting.model_definition.mam.mam import fuse_alpha
167
168        if not prompt:
169            raise ValueError(
170                "Matting Anything prompts a frozen SAM, so it needs a prompt. Pass "
171                "prompt={'points': [[[x, y]]]} or prompt={'boxes': [[[x0, y0, x1, y1]]]}."
172            )
173
174        sam_processor = model.sam_processor
175        processor_kwargs = {}
176        if prompt.get("points") is not None:
177            processor_kwargs["input_points"] = prompt["points"]
178        if prompt.get("labels") is not None:
179            processor_kwargs["input_labels"] = prompt["labels"]
180        if prompt.get("boxes") is not None:
181            processor_kwargs["input_boxes"] = prompt["boxes"]
182
183        inputs = inputs_to_device(
184            sam_processor(images=image, return_tensors="pt", **processor_kwargs),
185            device,
186        )
187
188        pixel_values = inputs.pop("pixel_values")
189        original_sizes = inputs.pop("original_sizes")
190        reshaped_sizes = inputs.pop("reshaped_input_sizes")
191
192        with torch.no_grad():
193            pred, post_mask = model(
194                pixel_values=pixel_values,
195                reshaped_size=reshaped_sizes[0].tolist(),
196                original_size=original_sizes[0].tolist(),
197                **inputs,
198            )
199            alpha = fuse_alpha(
200                pred,
201                reshaped_size=reshaped_sizes[0].tolist(),
202                original_size=original_sizes[0].tolist(),
203                post_mask=post_mask,
204            )
205
206        alpha_np = alpha[0, 0].detach().cpu().numpy()
207        del inputs, pred
208
209    alpha_np = np.clip(alpha_np, 0.0, 1.0)
210    alpha_u8 = (alpha_np * 255).astype(np.uint8)
211
212    empty_cache(device)
213    return image_np, alpha_u8
214
215
216def composite(
217    image_np: np.ndarray,
218    alpha_np: np.ndarray,
219    background_color=(255, 255, 255),
220):
221    """
222    Composites a matted foreground onto a solid background colour.
223
224    Unlike hard mask replacement, this blends each pixel by its alpha value, so soft edges
225    stay soft instead of showing a cut-out outline.
226
227    Args:
228        image_np (numpy.ndarray): The original RGB image, shape `(H, W, 3)`.
229        alpha_np (numpy.ndarray): The alpha matte, shape `(H, W)`, `uint8` in `[0, 255]`
230                                  or float in `[0, 1]`.
231        background_color (tuple[int, int, int], optional): RGB colour to composite onto.
232                                                           Default is white.
233
234    Raises:
235        ValueError: If the image and alpha shapes do not match.
236
237    Returns:
238        numpy.ndarray: The composited RGB image as `uint8`, shape `(H, W, 3)`.
239    """
240    image_np = np.asarray(image_np)
241    alpha_np = np.asarray(alpha_np)
242
243    if image_np.shape[:2] != alpha_np.shape[:2]:
244        raise ValueError(
245            f"image shape {image_np.shape[:2]} does not match alpha shape "
246            f"{alpha_np.shape[:2]}."
247        )
248
249    alpha = alpha_np.astype(np.float32)
250    if alpha.max() > 1.0:
251        alpha = alpha / 255.0
252    alpha = alpha[..., None]
253
254    background = np.array(background_color, dtype=np.float32).reshape(1, 1, 3)
255    out = alpha * image_np.astype(np.float32) + (1.0 - alpha) * background
256    return np.clip(out, 0, 255).astype(np.uint8)
257
258
259__all__ = ["matte", "composite"]
def matte( model, image_path: Union[str, numpy.ndarray, PIL.Image.Image], processor=None, mask: Optional[numpy.ndarray] = None, trimap: Optional[numpy.ndarray] = None, prompt: Optional[dict] = None, trimap_args: Optional[dict] = None, device: Union[str, torch.device] = 'cpu'):
 38def matte(
 39    model,
 40    image_path: Union[str, np.ndarray, Image.Image],
 41    processor=None,
 42    mask: Optional[np.ndarray] = None,
 43    trimap: Optional[np.ndarray] = None,
 44    prompt: Optional[dict] = None,
 45    trimap_args: Optional[dict] = None,
 46    device: Union[str, torch.device] = "cpu",
 47):
 48    """
 49    Extracts a soft alpha matte for an image, refining a hard segmentation into soft edges.
 50
 51    Segmentation answers "which pixels belong to the garment" with a yes/no decision, which
 52    leaves stair-stepped borders and loses semi-transparent detail such as loose fabric,
 53    lace, and stray fibres. Matting instead predicts a continuous alpha value per pixel, so
 54    compositing onto a new background looks natural.
 55
 56    The two supported approaches need different guidance:
 57        - **ViTMatte** requires a trimap marking definite foreground, definite background,
 58          and the uncertain band between them. Pass `trimap` directly, or pass `mask` and a
 59          trimap will be derived from it via `generate_trimap`.
 60        - **Matting Anything (MAM)** needs no trimap. It prompts a frozen SAM with `prompt`
 61          and refines the resulting coarse mask into an alpha matte.
 62
 63    Args:
 64        model: A loaded matting model, either `VitMatteForImageMatting` or `MattingAnything`.
 65        image_path (Union[str, numpy.ndarray, PIL.Image.Image]): The image to matte, given as
 66                                                                 a file path, RGB array, or
 67                                                                 PIL image.
 68        processor (VitMatteImageProcessor, optional): Required for ViTMatte, which uses it to
 69                                                      stack the image and trimap into a
 70                                                      four-channel input. Default is None.
 71        mask (numpy.ndarray, optional): A binary segmentation mask used to derive a trimap
 72                                        when `trimap` is not supplied. Default is None.
 73        trimap (numpy.ndarray, optional): An explicit trimap with values `0`, `128`, and
 74                                          `255`. Takes precedence over `mask`. Default is None.
 75        prompt (dict, optional): Prompt for MAM's internal SAM, accepting `"points"`,
 76                                 `"labels"`, and/or `"boxes"` exactly as
 77                                 `garmentiq.segmentation.extract` does. Default is None.
 78        trimap_args (dict, optional): Options forwarded to `generate_trimap`, such as
 79                                      `"erode_size"` and `"dilate_size"`. Default is None.
 80        device (Union[str, torch.device], optional): The device to run inference on, e.g.
 81                                                     `"cpu"`, `"cuda"`, or `"mps"`. Hardware
 82                                                     acceleration is opt-in. Default is `"cpu"`.
 83
 84    Raises:
 85        ValueError: If the model is not a recognised matting model, if ViTMatte is used
 86                    without a processor or without either `trimap` or `mask`, if MAM is used
 87                    without a prompt, or if the requested `device` is unavailable.
 88        FileNotFoundError: If `image_path` points to a file that does not exist.
 89
 90    Returns:
 91        tuple (numpy.ndarray, numpy.ndarray): The original image as an RGB array, and the
 92                                              alpha matte as a `uint8` array in `[0, 255]`
 93                                              matching the image's height and width.
 94    """
 95    device = resolve_device(device)
 96    family = _detect_matting_family(model)
 97    if family is None:
 98        raise ValueError(
 99            "Unrecognised matting model. Expected a ViTMatte model "
100            "(VitMatteForImageMatting) or a Matting Anything model (MattingAnything)."
101        )
102
103    if isinstance(image_path, str):
104        image = Image.open(image_path).convert("RGB")
105    elif isinstance(image_path, np.ndarray):
106        image = Image.fromarray(image_path.astype(np.uint8)).convert("RGB")
107    else:
108        image = image_path.convert("RGB")
109
110    image_np = np.array(image)
111    model = model.to(device)
112
113    if (
114        device.type == "mps"
115        and image_np.shape[0] * image_np.shape[1] > _MPS_PIXEL_WARN_THRESHOLD
116    ):
117        warnings.warn(
118            f"Matting a {image_np.shape[1]}x{image_np.shape[0]} image on the MPS backend. "
119            f"Above roughly {_MPS_PIXEL_WARN_THRESHOLD / 1e6:.1f} megapixels MPS has been "
120            f"observed to return a degenerate alpha matte that differs substantially from "
121            f"CPU. Consider device='cpu' for images this large, or downscale first.",
122            RuntimeWarning,
123            stacklevel=2,
124        )
125
126    if family == "vitmatte":
127        if processor is None:
128            raise ValueError(
129                "ViTMatte requires a processor. Pass processor=load_vitmatte_processor(...)."
130            )
131        if trimap is None:
132            if mask is None:
133                raise ValueError(
134                    "ViTMatte is a trimap-based model, so it needs either an explicit "
135                    "trimap= or a segmentation mask= to derive one from. Run segmentation "
136                    "first, then pass its mask here."
137                )
138            trimap = generate_trimap(mask, **(trimap_args or {}))
139
140        trimap_arr = np.asarray(trimap)
141        if trimap_arr.shape[:2] != image_np.shape[:2]:
142            raise ValueError(
143                f"trimap shape {trimap_arr.shape[:2]} does not match image shape "
144                f"{image_np.shape[:2]}."
145            )
146
147        # The processor rescales trimaps by its own rescale_factor (1/255), so hand it
148        # the raw 0-255 trimap. Pre-normalising here would rescale twice and collapse
149        # the trimap to near-zero, making the model see everything as background.
150        inputs = inputs_to_device(
151            processor(
152                images=image, trimaps=trimap_arr.astype(np.uint8), return_tensors="pt"
153            ),
154            device,
155        )
156
157        with torch.no_grad():
158            outputs = model(**inputs)
159
160        alpha = outputs.alphas
161        # ViTMatte pads to a multiple of its patch size; crop back to the true size.
162        alpha = alpha[..., : image_np.shape[0], : image_np.shape[1]]
163        alpha_np = alpha[0, 0].detach().cpu().numpy()
164        del inputs, outputs
165
166    else:
167        from garmentiq.matting.model_definition.mam.mam import fuse_alpha
168
169        if not prompt:
170            raise ValueError(
171                "Matting Anything prompts a frozen SAM, so it needs a prompt. Pass "
172                "prompt={'points': [[[x, y]]]} or prompt={'boxes': [[[x0, y0, x1, y1]]]}."
173            )
174
175        sam_processor = model.sam_processor
176        processor_kwargs = {}
177        if prompt.get("points") is not None:
178            processor_kwargs["input_points"] = prompt["points"]
179        if prompt.get("labels") is not None:
180            processor_kwargs["input_labels"] = prompt["labels"]
181        if prompt.get("boxes") is not None:
182            processor_kwargs["input_boxes"] = prompt["boxes"]
183
184        inputs = inputs_to_device(
185            sam_processor(images=image, return_tensors="pt", **processor_kwargs),
186            device,
187        )
188
189        pixel_values = inputs.pop("pixel_values")
190        original_sizes = inputs.pop("original_sizes")
191        reshaped_sizes = inputs.pop("reshaped_input_sizes")
192
193        with torch.no_grad():
194            pred, post_mask = model(
195                pixel_values=pixel_values,
196                reshaped_size=reshaped_sizes[0].tolist(),
197                original_size=original_sizes[0].tolist(),
198                **inputs,
199            )
200            alpha = fuse_alpha(
201                pred,
202                reshaped_size=reshaped_sizes[0].tolist(),
203                original_size=original_sizes[0].tolist(),
204                post_mask=post_mask,
205            )
206
207        alpha_np = alpha[0, 0].detach().cpu().numpy()
208        del inputs, pred
209
210    alpha_np = np.clip(alpha_np, 0.0, 1.0)
211    alpha_u8 = (alpha_np * 255).astype(np.uint8)
212
213    empty_cache(device)
214    return image_np, alpha_u8

Extracts a soft alpha matte for an image, refining a hard segmentation into soft edges.

Segmentation answers "which pixels belong to the garment" with a yes/no decision, which leaves stair-stepped borders and loses semi-transparent detail such as loose fabric, lace, and stray fibres. Matting instead predicts a continuous alpha value per pixel, so compositing onto a new background looks natural.

The two supported approaches need different guidance:
  • ViTMatte requires a trimap marking definite foreground, definite background, and the uncertain band between them. Pass trimap directly, or pass mask and a trimap will be derived from it via generate_trimap.
  • Matting Anything (MAM) needs no trimap. It prompts a frozen SAM with prompt and refines the resulting coarse mask into an alpha matte.
Arguments:
  • model: A loaded matting model, either VitMatteForImageMatting or MattingAnything.
  • image_path (Union[str, numpy.ndarray, PIL.Image.Image]): The image to matte, given as a file path, RGB array, or PIL image.
  • processor (VitMatteImageProcessor, optional): Required for ViTMatte, which uses it to stack the image and trimap into a four-channel input. Default is None.
  • mask (numpy.ndarray, optional): A binary segmentation mask used to derive a trimap when trimap is not supplied. Default is None.
  • trimap (numpy.ndarray, optional): An explicit trimap with values 0, 128, and 255. Takes precedence over mask. Default is None.
  • prompt (dict, optional): Prompt for MAM's internal SAM, accepting "points", "labels", and/or "boxes" exactly as garmentiq.segmentation.extract does. Default is None.
  • trimap_args (dict, optional): Options forwarded to generate_trimap, such as "erode_size" and "dilate_size". Default is None.
  • device (Union[str, torch.device], optional): The device to run inference on, e.g. "cpu", "cuda", or "mps". Hardware acceleration is opt-in. Default is "cpu".
Raises:
  • ValueError: If the model is not a recognised matting model, if ViTMatte is used without a processor or without either trimap or mask, if MAM is used without a prompt, or if the requested device is unavailable.
  • FileNotFoundError: If image_path points to a file that does not exist.
Returns:

tuple (numpy.ndarray, numpy.ndarray): The original image as an RGB array, and the alpha matte as a uint8 array in [0, 255] matching the image's height and width.

def composite( image_np: numpy.ndarray, alpha_np: numpy.ndarray, background_color=(255, 255, 255)):
217def composite(
218    image_np: np.ndarray,
219    alpha_np: np.ndarray,
220    background_color=(255, 255, 255),
221):
222    """
223    Composites a matted foreground onto a solid background colour.
224
225    Unlike hard mask replacement, this blends each pixel by its alpha value, so soft edges
226    stay soft instead of showing a cut-out outline.
227
228    Args:
229        image_np (numpy.ndarray): The original RGB image, shape `(H, W, 3)`.
230        alpha_np (numpy.ndarray): The alpha matte, shape `(H, W)`, `uint8` in `[0, 255]`
231                                  or float in `[0, 1]`.
232        background_color (tuple[int, int, int], optional): RGB colour to composite onto.
233                                                           Default is white.
234
235    Raises:
236        ValueError: If the image and alpha shapes do not match.
237
238    Returns:
239        numpy.ndarray: The composited RGB image as `uint8`, shape `(H, W, 3)`.
240    """
241    image_np = np.asarray(image_np)
242    alpha_np = np.asarray(alpha_np)
243
244    if image_np.shape[:2] != alpha_np.shape[:2]:
245        raise ValueError(
246            f"image shape {image_np.shape[:2]} does not match alpha shape "
247            f"{alpha_np.shape[:2]}."
248        )
249
250    alpha = alpha_np.astype(np.float32)
251    if alpha.max() > 1.0:
252        alpha = alpha / 255.0
253    alpha = alpha[..., None]
254
255    background = np.array(background_color, dtype=np.float32).reshape(1, 1, 3)
256    out = alpha * image_np.astype(np.float32) + (1.0 - alpha) * background
257    return np.clip(out, 0, 255).astype(np.uint8)

Composites a matted foreground onto a solid background colour.

Unlike hard mask replacement, this blends each pixel by its alpha value, so soft edges stay soft instead of showing a cut-out outline.

Arguments:
  • image_np (numpy.ndarray): The original RGB image, shape (H, W, 3).
  • alpha_np (numpy.ndarray): The alpha matte, shape (H, W), uint8 in [0, 255] or float in [0, 1].
  • background_color (tuple[int, int, int], optional): RGB colour to composite onto. Default is white.
Raises:
  • ValueError: If the image and alpha shapes do not match.
Returns:

numpy.ndarray: The composited RGB image as uint8, shape (H, W, 3).