File size: 10,539 Bytes
1a73df3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5088a7f
9f3f261
 
 
 
 
 
 
918ffce
 
9f3f261
 
 
 
 
 
1a73df3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7d17d64
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1a73df3
 
 
 
 
 
7d17d64
1a73df3
 
 
 
 
 
 
 
 
 
 
 
4f650ab
 
 
 
1a73df3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9f3f261
 
 
fcba782
9f3f261
1a73df3
 
 
 
 
 
 
 
 
 
 
9f3f261
918ffce
 
 
8018ee3
1a73df3
 
 
 
 
 
 
5088a7f
1a73df3
 
 
7d17d64
8018ee3
 
 
1a73df3
 
 
 
 
 
 
 
 
 
 
 
 
9f3f261
1a73df3
 
7d17d64
1a73df3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9f3f261
1a73df3
918ffce
1a73df3
918ffce
 
8018ee3
1a73df3
 
 
 
 
 
 
 
9f3f261
1a73df3
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
"""
segment-everything-api β€” one call in, every mask out.

SAM 2.1 automatic mask generation, returned as a **label map** rather than a
picture of one. The reply is a lossless PNG where each segment is a distinct
index encoded in the red and green channels (`id = R + G*256`, 0 = unsegmented),
plus a JSON manifest of each segment's area and bounding box.

That shape is the point: a client can decode the PNG straight into a per-pixel
label array and do hit-testing, hover highlighting and selection locally, with no
further round trips. Overlay screenshots β€” what most segmentation demos return β€”
can't be decoded back into masks, so they're offered only as a human preview.

API:  /segment(image, points_per_crop, min_area_frac, pred_iou_thresh,
               stability_score_thresh) -> (labels.png, manifest, preview)
"""

import os
import tempfile

import spaces  # must precede torch β€” it patches torch.cuda before CUDA init
import gradio as gr
import numpy as np
import torch
from PIL import Image
from scipy import ndimage
from transformers import pipeline

# SAM's own defaults (32 points, IoU .88, stability .95) are tuned for photos and
# leave illustration badly under-segmented β€” measured on a busy anime piece they
# covered 13% of the frame, and merged unrelated things into single giant blobs.
# The defaults on `segment` below are what fixed that, measured on the same piece:
#
#   setting                     parts   biggest region
#   SAM defaults                  ~40   (13% of frame even covered)
#   64 pts / IoU .5 / stab .75     117   24%   <- one blob held a quarter of the art
#   96 pts / IoU .35 / stab .5     284   10%
#   96 pts / IoU .3  / stab .3     443    9%   <- current, finest that stays sane
#
# `stability_score_thresh` is the one that lets the finer, "less stable" masks
# through, and the DENSE GRID is what actually splits a big region: more sample
# points inside it means more competing fine masks. At 64 points the giants
# survive whatever else is loosened. The cost is ~45 s instead of ~15 s, paid once
# per version because the client caches the label map.
MODEL_ID = os.environ.get("SAM_MODEL", "facebook/sam2.1-hiera-large")
MAX_EDGE = 2048  # bounds VRAM and the returned payload
MAX_SEGMENTS = 65535  # what the two-channel label encoding holds

generator = pipeline("mask-generation", model=MODEL_ID, device=0)


def _fit(image: Image.Image) -> Image.Image:
    """Downscale so the long edge is at most MAX_EDGE; leave smaller art alone."""
    w, h = image.size
    scale = MAX_EDGE / max(w, h)
    if scale >= 1:
        return image
    return image.resize((max(1, round(w * scale)), max(1, round(h * scale))), Image.LANCZOS)


def _close_gaps(labels: np.ndarray, reach: int) -> np.ndarray:
    """
    Absorb the hairline gutters between neighbouring masks.

    SAM's masks don't tile the plane β€” adjacent ones leave a pixel or two of
    unlabelled space between them. Left alone those gutters shatter into
    thousands of thread-thin components downstream (measured: 199 segments became
    6072 clickable parts), which makes hovering useless.

    So each unlabelled pixel within `reach` of a segment joins its NEAREST
    segment. The distance cap is the point: gutters get closed, while a genuinely
    unsegmented expanse stays unsegmented instead of being annexed by whatever
    happened to border it.
    """
    empty = labels == 0
    if reach <= 0 or not empty.any() or empty.all():
        return labels
    dist, (iy, ix) = ndimage.distance_transform_edt(empty, return_indices=True)
    return np.where(empty & (dist <= reach), labels[iy, ix], labels)


def _paint_labels(masks, height: int, width: int, min_area: int, gap_reach: int = 4):
    """
    Flatten possibly-overlapping masks into one label map.

    Painted largest-first, so where masks overlap the SMALLER one wins β€” a big
    background mask never buries the little parts a user actually wants to click.
    Areas and boxes are measured on the finished map, not on the input masks, so
    they describe what's really there after the overwrites and the gap closing.
    """
    order = sorted(range(len(masks)), key=lambda i: -int(masks[i].sum()))
    raw = np.zeros((height, width), dtype=np.int32)
    for slot, i in enumerate(order[:MAX_SEGMENTS], start=1):
        raw[np.asarray(masks[i], dtype=bool)] = slot

    # Drop whatever ended up too small (or fully covered), then renumber densely.
    areas = np.bincount(raw.ravel(), minlength=int(raw.max()) + 1)
    keep = [i for i in range(1, len(areas)) if areas[i] >= min_area]
    remap = np.zeros(len(areas), dtype=np.int32)
    for new_id, old_id in enumerate(keep, start=1):
        remap[old_id] = new_id
    # Close gaps AFTER culling, not before: culling hands its rejects back to the
    # unlabelled class, and those rejects are exactly the debris the fill exists
    # to absorb. Filling first left thousands of specks behind.
    labels = _close_gaps(remap[raw], gap_reach)

    boxes = ndimage.find_objects(labels)
    final_areas = np.bincount(labels.ravel(), minlength=len(keep) + 1)
    regions = []
    for new_id in range(1, len(keep) + 1):
        box = boxes[new_id - 1]
        if box is None:
            continue
        ys, xs = box
        regions.append(
            {
                "id": new_id,
                "area": int(final_areas[new_id]),
                "bbox": [int(xs.start), int(ys.start), int(xs.stop), int(ys.stop)],
            }
        )
    return labels, regions


def _encode(labels: np.ndarray) -> str:
    """Label ids into R + G*256 of a lossless, untagged PNG."""
    height, width = labels.shape
    rgb = np.zeros((height, width, 3), dtype=np.uint8)
    rgb[..., 0] = (labels & 0xFF).astype(np.uint8)
    rgb[..., 1] = ((labels >> 8) & 0xFF).astype(np.uint8)
    path = os.path.join(tempfile.mkdtemp(), "labels.png")
    Image.fromarray(rgb, mode="RGB").save(path, format="PNG", optimize=True)
    return path


def _preview(image: np.ndarray, labels: np.ndarray) -> np.ndarray:
    """A human-readable tint of the label map. Never parse this β€” use the PNG."""
    rng = np.random.default_rng(7)
    palette = rng.integers(40, 235, size=(int(labels.max()) + 1, 3), dtype=np.uint8)
    palette[0] = (0, 0, 0)
    tint = palette[labels]
    return ((image.astype(np.uint16) + tint.astype(np.uint16) * 2) // 3).astype(np.uint8)


# NOTE: `crops_n_layers` (SAM's multi-scale crop pass, the other way to find
# finer parts) is unusable on transformers 5.15 β€” its SAM2 processor tries to
# torch.stack crops of different sizes and raises. Re-test before reaching for it.
@spaces.GPU(duration=300)
def _run(pixels: np.ndarray, points_per_crop: int, pred_iou_thresh: float, stability: float):
    return generator(
        Image.fromarray(pixels),
        points_per_crop=int(points_per_crop),
        points_per_batch=64,
        pred_iou_thresh=float(pred_iou_thresh),
        stability_score_thresh=float(stability),
    )


def segment(
    image: Image.Image,
    points_per_crop: int = 96,
    min_area_frac: float = 0.0001,
    pred_iou_thresh: float = 0.3,
    stability_score_thresh: float = 0.3,
    gap_reach: int = 32,
):
    """
    Segment every object in an image and return the result as a label map.

    Args:
        image: The image to segment.
        points_per_crop: Grid density of the automatic prompt sampler β€” higher
            finds smaller parts and costs more time (8 coarse … 96 fine).
        min_area_frac: Drop segments smaller than this fraction of the frame.
        pred_iou_thresh: Minimum predicted mask quality to keep a segment.
        stability_score_thresh: Minimum mask stability to keep a segment.
        gap_reach: How far (px) an unlabelled pixel may reach to join its nearest
            segment. The default closes the debris between masks completely (a
            measured 2559 stray blobs down to 0) while leaving a genuinely large
            unsegmented expanse alone, since nothing is within reach of it. 0 off.

    Returns:
        labels.png β€” a lossless PNG whose pixels carry the segment id as
            `R + G*256`, with 0 meaning unsegmented;
        manifest β€” {width, height, count, model, regions:[{id, area, bbox}]};
        preview β€” a tinted image for eyeballing only, not for parsing.
    """
    if image is None:
        raise gr.Error("No image supplied.")
    image = _fit(image.convert("RGB"))
    pixels = np.asarray(image)
    height, width = pixels.shape[:2]

    outputs = _run(pixels, points_per_crop, pred_iou_thresh, stability_score_thresh)
    masks = outputs.get("masks", []) if isinstance(outputs, dict) else []
    min_area = max(1, int(min_area_frac * width * height))
    labels, regions = _paint_labels(masks, height, width, min_area, int(gap_reach))

    manifest = {
        "width": width,
        "height": height,
        "count": len(regions),
        "model": MODEL_ID,
        "regions": regions,
    }
    return _encode(labels), manifest, _preview(pixels, labels)


with gr.Blocks(title="Segment Everything API") as demo:
    gr.Markdown(
        "# Segment Everything API\n"
        "SAM 2.1 automatic mask generation returned as a **label map PNG** "
        "(`id = R + G*256`, 0 = unsegmented) plus a JSON manifest β€” so a client can "
        "decode the masks and do its own hit-testing offline. Built for API use; "
        "the preview is only for eyeballing."
    )
    with gr.Row():
        with gr.Column():
            image_in = gr.Image(label="Image", type="pil")
            points = gr.Slider(8, 128, value=96, step=8, label="Points per crop (detail)")
            min_area = gr.Slider(
                0.0, 0.01, value=0.0001, step=0.0001, label="Min segment area (fraction)"
            )
            iou = gr.Slider(0.2, 0.99, value=0.3, step=0.01, label="Predicted IoU threshold")
            stability = gr.Slider(0.2, 0.99, value=0.3, step=0.01, label="Stability threshold")
            gap = gr.Slider(0, 64, value=32, step=1, label="Gap close reach (px)")
            run = gr.Button("Segment", variant="primary")
        with gr.Column():
            preview_out = gr.Image(label="Preview (not machine-readable)")
            labels_out = gr.File(label="labels.png")
            manifest_out = gr.JSON(label="manifest")

    run.click(
        fn=segment,
        inputs=[image_in, points, min_area, iou, stability, gap],
        outputs=[labels_out, manifest_out, preview_out],
        api_name="segment",
    )

demo.launch(mcp_server=True)