Spaces:
Running on Zero
Running on Zero
Text-guided removal: auto-generate the mask with SAM 3
#1
by linoyts HF Staff - opened
- README.md +11 -2
- app.py +93 -15
- studio.html +39 -5
- text_mask.py +152 -0
README.md
CHANGED
|
@@ -30,7 +30,7 @@ object you point at and leaves the rest of the photo as it was. Paint a rough ma
|
|
| 30 |
say what it is, and it is replaced with what would be behind it, its shadow or reflection included.
|
| 31 |
|
| 32 |
- **`/` — Studio** (`gr.Server` API + custom UI, Pruna design, dark and light themes): paint the mask
|
| 33 |
-
with a brush and eraser, write the instruction, pick a variant; the card's 18 examples load photo,
|
| 34 |
mask and instruction in one click; before/after split view and run history.
|
| 35 |
- **`/workflow` — Workflow canvas** (`gr.Workflow`): photo + mask + instruction into a removal node,
|
| 36 |
chained into a second removal. To change the starter graph, edit `workflow.json`.
|
|
@@ -45,8 +45,17 @@ The mask is drawn as the red outline the model expects (`draw_outline.py`, from
|
|
| 45 |
the matching sentence is added to the instruction. With no mask, the instruction alone says what to
|
| 46 |
remove. No CFG.
|
| 47 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
The `remove` API endpoint takes `source` and `mask` (uploaded file paths; mask white = remove, may
|
| 49 |
-
be empty), `instruction`, `variant`, `seed` and `
|
|
|
|
|
|
|
| 50 |
|
| 51 |
Example photos are from [Unsplash](https://unsplash.com) (credits on the model card); the masks are
|
| 52 |
the card's own, except `c9`'s, rebuilt from its outlined input.
|
|
|
|
| 30 |
say what it is, and it is replaced with what would be behind it, its shadow or reflection included.
|
| 31 |
|
| 32 |
- **`/` — Studio** (`gr.Server` API + custom UI, Pruna design, dark and light themes): paint the mask
|
| 33 |
+
with a brush and eraser, or let SAM 3 find it from the instruction, write the instruction, pick a variant; the card's 18 examples load photo,
|
| 34 |
mask and instruction in one click; before/after split view and run history.
|
| 35 |
- **`/workflow` — Workflow canvas** (`gr.Workflow`): photo + mask + instruction into a removal node,
|
| 36 |
chained into a second removal. To change the starter graph, edit `workflow.json`.
|
|
|
|
| 45 |
the matching sentence is added to the instruction. With no mask, the instruction alone says what to
|
| 46 |
remove. No CFG.
|
| 47 |
|
| 48 |
+
**Text-guided masks.** Instead of painting, write the instruction and press *Auto-generate mask from instruction*:
|
| 49 |
+
[SAM 3](https://huggingface.co/facebook/sam3) masks every object the instruction names
|
| 50 |
+
(`text_mask.py`; "the phone on the right" picks one), and the mask lands on the canvas to touch up.
|
| 51 |
+
A run with nothing painted does this by itself, in the same GPU call as the removal, and falls back
|
| 52 |
+
to the instruction alone if SAM 3 finds nothing. `facebook/sam3` is gated: the Space needs an `HF_TOKEN` secret with
|
| 53 |
+
access, and without one it runs with painted masks only.
|
| 54 |
+
|
| 55 |
The `remove` API endpoint takes `source` and `mask` (uploaded file paths; mask white = remove, may
|
| 56 |
+
be empty), `instruction`, `variant`, `seed`, `randomize` and `auto_mask` (with no mask, find it from
|
| 57 |
+
the instruction with SAM 3; the mask found comes back as `mask_data`). The `find_mask` endpoint takes
|
| 58 |
+
`source` and `text` and returns the SAM 3 mask as `mask_data`.
|
| 59 |
|
| 60 |
Example photos are from [Unsplash](https://unsplash.com) (credits on the model card); the masks are
|
| 61 |
the card's own, except `c9`'s, rebuilt from its outlined input.
|
app.py
CHANGED
|
@@ -3,6 +3,9 @@
|
|
| 3 |
- "/" Studio: custom UI (studio.html) over the `remove` API endpoint
|
| 4 |
- "/workflow" Workflow canvas: node graph from workflow.json (pq_remove)
|
| 5 |
|
|
|
|
|
|
|
|
|
|
| 6 |
The removal LoRA runs as its model card says: the few-step variants on top of PrunaAI's distillation
|
| 7 |
LoRA (at 1.0) at strength 0.7, the base variant alone at 40 steps; no CFG in all cases.
|
| 8 |
"""
|
|
@@ -29,6 +32,7 @@ from diffusers import FlowMatchEulerDiscreteScheduler, QwenImage21Pipeline
|
|
| 29 |
|
| 30 |
from draw_outline import draw_outline, outline_prompt
|
| 31 |
from examples import EXAMPLES
|
|
|
|
| 32 |
|
| 33 |
APP_DIR = os.path.dirname(os.path.abspath(__file__))
|
| 34 |
EXAMPLES_DIR = os.path.join(APP_DIR, "examples")
|
|
@@ -118,6 +122,24 @@ def _activate(name: str) -> dict:
|
|
| 118 |
|
| 119 |
_activate(DEFAULT_VARIANT)
|
| 120 |
pipe = pipe.to("cuda")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
|
| 122 |
|
| 123 |
def _resolve_variant(variant) -> str:
|
|
@@ -132,13 +154,36 @@ def _resolve_variant(variant) -> str:
|
|
| 132 |
# ------------------------------------------------------------------ rendering
|
| 133 |
|
| 134 |
|
| 135 |
-
def
|
| 136 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
|
| 138 |
|
| 139 |
@spaces.GPU(duration=_gpu_seconds)
|
| 140 |
-
def _render(prompt: str, variant: str, seed: int, image: Image.Image, resolution: int):
|
|
|
|
|
|
|
| 141 |
spec = _activate(variant)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
torch.cuda.synchronize()
|
| 143 |
start = time.perf_counter()
|
| 144 |
result = pipe(
|
|
@@ -151,7 +196,12 @@ def _render(prompt: str, variant: str, seed: int, image: Image.Image, resolution
|
|
| 151 |
generator=torch.Generator("cuda").manual_seed(seed),
|
| 152 |
).images[0]
|
| 153 |
torch.cuda.synchronize()
|
| 154 |
-
return result, time.perf_counter() - start
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
|
| 156 |
|
| 157 |
def _canvas(ratio: float, side: int) -> tuple[int, int]:
|
|
@@ -160,33 +210,39 @@ def _canvas(ratio: float, side: int) -> tuple[int, int]:
|
|
| 160 |
return round(width / 32) * 32, round(width / ratio / 32) * 32
|
| 161 |
|
| 162 |
|
| 163 |
-
def _remove(photo: Image.Image, mask, instruction: str, variant, seed: int):
|
| 164 |
"""Object removal as the model card does it: work at the photo's area (sides rounded to 32,
|
| 165 |
capped per variant), draw the red outline at that size with the matching sentence, and resize
|
| 166 |
the result back to the photo's aspect ratio. Without a mask (or an empty one) it runs on the
|
| 167 |
-
instruction alone
|
|
|
|
| 168 |
name = _resolve_variant(variant)
|
| 169 |
instruction = (instruction or "").strip()
|
| 170 |
area = photo.width * photo.height
|
| 171 |
side = min(round(area**0.5), VARIANTS[name]["max_side"])
|
| 172 |
work_size = _canvas(photo.width / photo.height, side)
|
| 173 |
work = photo.resize(work_size, Image.LANCZOS)
|
| 174 |
-
|
| 175 |
if mask is not None:
|
| 176 |
mask = mask.resize(work_size, Image.NEAREST)
|
| 177 |
if (np.array(mask) > 127).any():
|
| 178 |
work = draw_outline(work, mask)
|
| 179 |
instruction = outline_prompt(instruction or DEFAULT_INSTRUCTION)
|
| 180 |
-
|
| 181 |
if not instruction:
|
| 182 |
raise gr.Error("Paint over the object to remove, or describe it.")
|
|
|
|
| 183 |
try:
|
| 184 |
-
result, seconds = _render(instruction, name, seed, work, side)
|
| 185 |
except (RuntimeError, ValueError) as error:
|
| 186 |
raise gr.Error(str(error).split("\n")[0])
|
|
|
|
|
|
|
|
|
|
|
|
|
| 187 |
scale = side / area**0.5
|
| 188 |
out_size = (round(photo.width * scale), round(photo.height * scale)) if scale < 1 else photo.size
|
| 189 |
-
return result.resize(out_size, Image.LANCZOS), seconds, name,
|
| 190 |
|
| 191 |
|
| 192 |
# ------------------------------------------------------------------ helpers
|
|
@@ -307,7 +363,7 @@ def pq_remove(image, prompt="", variant: str = DEFAULT_VARIANT, seed: str = "42"
|
|
| 307 |
"""Remove an object with a mask outline, a text instruction, or both (no CFG)."""
|
| 308 |
if _empty_media(image):
|
| 309 |
raise gr.Error("Connect a source image first.")
|
| 310 |
-
result, _, _, _ = _remove(
|
| 311 |
_open_rgb(image), _open_mask(mask), _as_instruction(prompt), variant, _seed(seed)
|
| 312 |
)
|
| 313 |
return _as_media(_publish(result))
|
|
@@ -328,6 +384,7 @@ def _studio_page():
|
|
| 328 |
"examples": [{"id": eid, "label": label, "prompt": prompt} for eid, label, prompt in EXAMPLES],
|
| 329 |
"variants": VARIANT_CHOICES,
|
| 330 |
"defaultVariant": DEFAULT_VARIANT,
|
|
|
|
| 331 |
}
|
| 332 |
return page.replace("__STUDIO_CONFIG__", json.dumps(config).replace("</", "<\\/"))
|
| 333 |
|
|
@@ -352,15 +409,36 @@ def api_remove(
|
|
| 352 |
variant: str = DEFAULT_VARIANT,
|
| 353 |
seed: float = 42,
|
| 354 |
randomize: bool = True,
|
|
|
|
| 355 |
) -> dict:
|
| 356 |
-
"""Remove the masked object (white = remove) with P-Q-Image-2.1-Remove-Object.
|
|
|
|
| 357 |
if not source:
|
| 358 |
raise gr.Error("Upload a photo first.")
|
| 359 |
seed_value = _resolve_seed(seed, randomize)
|
| 360 |
-
image, seconds, name,
|
| 361 |
-
|
|
|
|
| 362 |
info = f"{image.width}×{image.height}, seed {seed_value} · {name} · {mode} · {seconds:.1f} s on the GPU"
|
| 363 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 364 |
|
| 365 |
|
| 366 |
# The @server.api endpoints are deferred until Server.launch(), which also fires
|
|
|
|
| 3 |
- "/" Studio: custom UI (studio.html) over the `remove` API endpoint
|
| 4 |
- "/workflow" Workflow canvas: node graph from workflow.json (pq_remove)
|
| 5 |
|
| 6 |
+
Text-guided masks: with no painted mask, SAM 3 can find the object the instruction names
|
| 7 |
+
(text_mask.py) and the removal runs on its outline, in the same GPU call.
|
| 8 |
+
|
| 9 |
The removal LoRA runs as its model card says: the few-step variants on top of PrunaAI's distillation
|
| 10 |
LoRA (at 1.0) at strength 0.7, the base variant alone at 40 steps; no CFG in all cases.
|
| 11 |
"""
|
|
|
|
| 32 |
|
| 33 |
from draw_outline import draw_outline, outline_prompt
|
| 34 |
from examples import EXAMPLES
|
| 35 |
+
from text_mask import TextMasker
|
| 36 |
|
| 37 |
APP_DIR = os.path.dirname(os.path.abspath(__file__))
|
| 38 |
EXAMPLES_DIR = os.path.join(APP_DIR, "examples")
|
|
|
|
| 122 |
|
| 123 |
_activate(DEFAULT_VARIANT)
|
| 124 |
pipe = pipe.to("cuda")
|
| 125 |
+
# Decoding a ~2 MP result in one piece runs out of memory on a ZeroGPU slot; tiles of 1024 px leave
|
| 126 |
+
# results up to 1024 px a side decoded exactly as before.
|
| 127 |
+
pipe.vae.enable_tiling(
|
| 128 |
+
tile_sample_min_height=1024, tile_sample_min_width=1024,
|
| 129 |
+
tile_sample_stride_height=768, tile_sample_stride_width=768,
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
+
# SAM 3 for text-guided masks. facebook/sam3 is gated: without access (HF_TOKEN) the Space still
|
| 133 |
+
# runs, with painted masks only. It stays in CPU memory and visits the GPU only to find a mask, so
|
| 134 |
+
# the removal keeps the GPU memory it had without it.
|
| 135 |
+
try:
|
| 136 |
+
masker = TextMasker(device="cpu")
|
| 137 |
+
except Exception as error: # noqa: BLE001
|
| 138 |
+
print(f"Text-guided masks disabled, SAM 3 did not load: {error}")
|
| 139 |
+
masker = None
|
| 140 |
+
|
| 141 |
+
# SAM 3 works at 1008 px; segmenting a larger copy only costs time.
|
| 142 |
+
FIND_MAX_SIDE = 1536
|
| 143 |
|
| 144 |
|
| 145 |
def _resolve_variant(variant) -> str:
|
|
|
|
| 154 |
# ------------------------------------------------------------------ rendering
|
| 155 |
|
| 156 |
|
| 157 |
+
def _find(image: Image.Image, text: str):
|
| 158 |
+
"""SAM 3 mask (L, white = the object) of what `text` names in `image`, at its size, or None."""
|
| 159 |
+
if masker is None or not text:
|
| 160 |
+
return None
|
| 161 |
+
small = image
|
| 162 |
+
if max(image.size) > FIND_MAX_SIDE:
|
| 163 |
+
scale = FIND_MAX_SIDE / max(image.size)
|
| 164 |
+
small = image.resize((round(image.width * scale), round(image.height * scale)), Image.LANCZOS)
|
| 165 |
+
try:
|
| 166 |
+
masker.model.to("cuda")
|
| 167 |
+
mask, _ = masker(small, text)
|
| 168 |
+
finally:
|
| 169 |
+
masker.model.to("cpu")
|
| 170 |
+
torch.cuda.empty_cache()
|
| 171 |
+
return None if mask is None else mask.resize(image.size, Image.NEAREST)
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def _gpu_seconds(prompt, variant, seed, image, resolution, find=""):
|
| 175 |
+
return VARIANTS[variant]["gpu_seconds"] + (10 if find else 0)
|
| 176 |
|
| 177 |
|
| 178 |
@spaces.GPU(duration=_gpu_seconds)
|
| 179 |
+
def _render(prompt: str, variant: str, seed: int, image: Image.Image, resolution: int, find: str = ""):
|
| 180 |
+
"""The removal. With `find`, SAM 3 first looks for the object it names; when found, the photo
|
| 181 |
+
gets its red outline and the prompt the outline sentence. Returns (image, seconds, mask or None)."""
|
| 182 |
spec = _activate(variant)
|
| 183 |
+
mask = _find(image, find) if find else None
|
| 184 |
+
if mask is not None:
|
| 185 |
+
image = draw_outline(image, mask)
|
| 186 |
+
prompt = outline_prompt(prompt)
|
| 187 |
torch.cuda.synchronize()
|
| 188 |
start = time.perf_counter()
|
| 189 |
result = pipe(
|
|
|
|
| 196 |
generator=torch.Generator("cuda").manual_seed(seed),
|
| 197 |
).images[0]
|
| 198 |
torch.cuda.synchronize()
|
| 199 |
+
return result, time.perf_counter() - start, mask
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
@spaces.GPU(duration=20)
|
| 203 |
+
def _find_gpu(image: Image.Image, text: str):
|
| 204 |
+
return _find(image, text)
|
| 205 |
|
| 206 |
|
| 207 |
def _canvas(ratio: float, side: int) -> tuple[int, int]:
|
|
|
|
| 210 |
return round(width / 32) * 32, round(width / ratio / 32) * 32
|
| 211 |
|
| 212 |
|
| 213 |
+
def _remove(photo: Image.Image, mask, instruction: str, variant, seed: int, auto_mask: bool = False):
|
| 214 |
"""Object removal as the model card does it: work at the photo's area (sides rounded to 32,
|
| 215 |
capped per variant), draw the red outline at that size with the matching sentence, and resize
|
| 216 |
the result back to the photo's aspect ratio. Without a mask (or an empty one) it runs on the
|
| 217 |
+
instruction alone, or, with `auto_mask`, on the outline of what SAM 3 finds from the
|
| 218 |
+
instruction. Returns (image, gpu seconds, variant, mode, found mask at the photo's size or None)."""
|
| 219 |
name = _resolve_variant(variant)
|
| 220 |
instruction = (instruction or "").strip()
|
| 221 |
area = photo.width * photo.height
|
| 222 |
side = min(round(area**0.5), VARIANTS[name]["max_side"])
|
| 223 |
work_size = _canvas(photo.width / photo.height, side)
|
| 224 |
work = photo.resize(work_size, Image.LANCZOS)
|
| 225 |
+
mode = "prompt only"
|
| 226 |
if mask is not None:
|
| 227 |
mask = mask.resize(work_size, Image.NEAREST)
|
| 228 |
if (np.array(mask) > 127).any():
|
| 229 |
work = draw_outline(work, mask)
|
| 230 |
instruction = outline_prompt(instruction or DEFAULT_INSTRUCTION)
|
| 231 |
+
mode = "outline"
|
| 232 |
if not instruction:
|
| 233 |
raise gr.Error("Paint over the object to remove, or describe it.")
|
| 234 |
+
find = instruction if auto_mask and mode == "prompt only" and masker is not None else ""
|
| 235 |
try:
|
| 236 |
+
result, seconds, found = _render(instruction, name, seed, work, side, find)
|
| 237 |
except (RuntimeError, ValueError) as error:
|
| 238 |
raise gr.Error(str(error).split("\n")[0])
|
| 239 |
+
if find:
|
| 240 |
+
mode = "text mask (SAM 3)" if found is not None else "prompt only, SAM 3 found nothing"
|
| 241 |
+
if found is not None:
|
| 242 |
+
found = found.resize(photo.size, Image.NEAREST)
|
| 243 |
scale = side / area**0.5
|
| 244 |
out_size = (round(photo.width * scale), round(photo.height * scale)) if scale < 1 else photo.size
|
| 245 |
+
return result.resize(out_size, Image.LANCZOS), seconds, name, mode, found
|
| 246 |
|
| 247 |
|
| 248 |
# ------------------------------------------------------------------ helpers
|
|
|
|
| 363 |
"""Remove an object with a mask outline, a text instruction, or both (no CFG)."""
|
| 364 |
if _empty_media(image):
|
| 365 |
raise gr.Error("Connect a source image first.")
|
| 366 |
+
result, _, _, _, _ = _remove(
|
| 367 |
_open_rgb(image), _open_mask(mask), _as_instruction(prompt), variant, _seed(seed)
|
| 368 |
)
|
| 369 |
return _as_media(_publish(result))
|
|
|
|
| 384 |
"examples": [{"id": eid, "label": label, "prompt": prompt} for eid, label, prompt in EXAMPLES],
|
| 385 |
"variants": VARIANT_CHOICES,
|
| 386 |
"defaultVariant": DEFAULT_VARIANT,
|
| 387 |
+
"textMask": masker is not None,
|
| 388 |
}
|
| 389 |
return page.replace("__STUDIO_CONFIG__", json.dumps(config).replace("</", "<\\/"))
|
| 390 |
|
|
|
|
| 409 |
variant: str = DEFAULT_VARIANT,
|
| 410 |
seed: float = 42,
|
| 411 |
randomize: bool = True,
|
| 412 |
+
auto_mask: bool = False,
|
| 413 |
) -> dict:
|
| 414 |
+
"""Remove the masked object (white = remove) with P-Q-Image-2.1-Remove-Object. With no mask and
|
| 415 |
+
`auto_mask`, SAM 3 finds the object the instruction names and its mask is returned as `mask_data`."""
|
| 416 |
if not source:
|
| 417 |
raise gr.Error("Upload a photo first.")
|
| 418 |
seed_value = _resolve_seed(seed, randomize)
|
| 419 |
+
image, seconds, name, mode, found = _remove(
|
| 420 |
+
_open_rgb(source), _open_mask(mask), instruction, variant, seed_value, auto_mask
|
| 421 |
+
)
|
| 422 |
info = f"{image.width}×{image.height}, seed {seed_value} · {name} · {mode} · {seconds:.1f} s on the GPU"
|
| 423 |
+
payload = {"image_data": _as_data_uri(image), "seed": seed_value, "info": info}
|
| 424 |
+
if found is not None:
|
| 425 |
+
payload["mask_data"] = _as_data_uri(found)
|
| 426 |
+
return payload
|
| 427 |
+
|
| 428 |
+
|
| 429 |
+
@server.api(name="find_mask", concurrency_limit=2)
|
| 430 |
+
def api_find_mask(source: str, text: str = "") -> dict:
|
| 431 |
+
"""Mask (white = the object) of what `text` names in the photo, found with SAM 3."""
|
| 432 |
+
if masker is None:
|
| 433 |
+
raise gr.Error("Text-guided masks are not available on this Space.")
|
| 434 |
+
if not source:
|
| 435 |
+
raise gr.Error("Upload a photo first.")
|
| 436 |
+
if not (text or "").strip():
|
| 437 |
+
raise gr.Error("Write what to remove first.")
|
| 438 |
+
mask = _find_gpu(_open_rgb(source), text.strip())
|
| 439 |
+
if mask is None:
|
| 440 |
+
raise gr.Error("SAM 3 found nothing matching the instruction. Try naming the object more simply.")
|
| 441 |
+
return {"mask_data": _as_data_uri(mask)}
|
| 442 |
|
| 443 |
|
| 444 |
# The @server.api endpoints are deferred until Server.launch(), which also fires
|
studio.html
CHANGED
|
@@ -239,6 +239,11 @@
|
|
| 239 |
.tool-toggle button.active { background: var(--accent-soft); color: var(--lavender); }
|
| 240 |
.mask-clear { border: 1px solid var(--border); background: var(--raised); margin-top: 8px; width: 100%; }
|
| 241 |
.mask-clear:hover { color: var(--text); border-color: var(--accent); }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 242 |
input[type="range"] { width: 100%; accent-color: var(--accent); }
|
| 243 |
@media (max-width: 860px) {
|
| 244 |
html, body { height: auto !important; overflow: visible !important; }
|
|
@@ -355,12 +360,11 @@
|
|
| 355 |
<label for="brush-size">Brush size</label>
|
| 356 |
<input type="range" id="brush-size" min="4" max="120" value="28" />
|
| 357 |
<button class="mask-clear" id="mask-clear">Clear mask</button>
|
| 358 |
-
<div class="hint">Paint roughly over the object; include its shadow or reflection if they should go too, and keep the mask off anything that should stay. It is drawn as a red outline for the model. With no mask, the instruction alone says what to remove.</div>
|
| 359 |
-
|
| 360 |
-
<section class="panel">
|
| 361 |
-
<div class="panel-title">Instruction</div>
|
| 362 |
<textarea id="prompt" rows="4" placeholder="e.g. Remove the man on the right."></textarea>
|
| 363 |
<div class="hint">Name the object as it appears, and say which one when there are several.</div>
|
|
|
|
| 364 |
</section>
|
| 365 |
<section class="panel">
|
| 366 |
<div class="panel-title">Variant</div>
|
|
@@ -627,6 +631,32 @@
|
|
| 627 |
return state.maskPath;
|
| 628 |
}
|
| 629 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 630 |
// ---------- history ----------
|
| 631 |
function showResultImage(uri) {
|
| 632 |
$("result-img").src = uri;
|
|
@@ -757,7 +787,8 @@
|
|
| 757 |
const run = {
|
| 758 |
prompt, variant: $("variant").value, sourceUrl: state.sourceUrl, sourcePath: state.sourcePath,
|
| 759 |
};
|
| 760 |
-
|
|
|
|
| 761 |
try {
|
| 762 |
const app = await getApp();
|
| 763 |
const result = await app.predict("/remove", {
|
|
@@ -767,6 +798,7 @@
|
|
| 767 |
variant: run.variant,
|
| 768 |
seed: parseInt($("seed").value, 10) || 0,
|
| 769 |
randomize: $("randomize").checked,
|
|
|
|
| 770 |
});
|
| 771 |
const payload = unwrap(result);
|
| 772 |
if (typeof payload.image_data !== "string") { setStatus("Run finished but no image came back (see console)."); return; }
|
|
@@ -774,6 +806,8 @@
|
|
| 774 |
if (state.sourceUrl === run.sourceUrl) { // the photo may have changed while it ran
|
| 775 |
$("result-meta").textContent = payload.info || "Done";
|
| 776 |
showResultImage(payload.image_data);
|
|
|
|
|
|
|
| 777 |
}
|
| 778 |
history.unshift({ ...run, uri: payload.image_data, seed: payload.seed, info: payload.info || "" });
|
| 779 |
if (history.length > 8) history.pop();
|
|
|
|
| 239 |
.tool-toggle button.active { background: var(--accent-soft); color: var(--lavender); }
|
| 240 |
.mask-clear { border: 1px solid var(--border); background: var(--raised); margin-top: 8px; width: 100%; }
|
| 241 |
.mask-clear:hover { color: var(--text); border-color: var(--accent); }
|
| 242 |
+
.panel label.instruction-label { margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--hairline); }
|
| 243 |
+
.mask-find { display: inline-flex; align-items: center; justify-content: center; gap: 5px; }
|
| 244 |
+
.mask-find { min-width: 0; }
|
| 245 |
+
.mask-find svg { width: 13px; height: 13px; flex-shrink: 0; }
|
| 246 |
+
.mask-find:disabled { opacity: 0.5; cursor: wait; }
|
| 247 |
input[type="range"] { width: 100%; accent-color: var(--accent); }
|
| 248 |
@media (max-width: 860px) {
|
| 249 |
html, body { height: auto !important; overflow: visible !important; }
|
|
|
|
| 360 |
<label for="brush-size">Brush size</label>
|
| 361 |
<input type="range" id="brush-size" min="4" max="120" value="28" />
|
| 362 |
<button class="mask-clear" id="mask-clear">Clear mask</button>
|
| 363 |
+
<div class="hint" id="mask-hint">Paint roughly over the object; include its shadow or reflection if they should go too, and keep the mask off anything that should stay. It is drawn as a red outline for the model. With no mask, the instruction alone says what to remove.</div>
|
| 364 |
+
<label for="prompt" class="instruction-label">Instruction</label>
|
|
|
|
|
|
|
| 365 |
<textarea id="prompt" rows="4" placeholder="e.g. Remove the man on the right."></textarea>
|
| 366 |
<div class="hint">Name the object as it appears, and say which one when there are several.</div>
|
| 367 |
+
<button class="mask-clear mask-find" id="mask-find" title="Mask what the instruction names, with SAM 3" hidden><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9z"/><path d="M19 15l.8 2.2L22 18l-2.2.8L19 21l-.8-2.2L16 18l2.2-.8z"/></svg>Auto-generate mask from instruction</button>
|
| 368 |
</section>
|
| 369 |
<section class="panel">
|
| 370 |
<div class="panel-title">Variant</div>
|
|
|
|
| 631 |
return state.maskPath;
|
| 632 |
}
|
| 633 |
|
| 634 |
+
// ---------- text-guided mask (SAM 3) ----------
|
| 635 |
+
if (CONFIG.textMask) {
|
| 636 |
+
$("mask-find").hidden = false;
|
| 637 |
+
$("mask-hint").textContent = "Paint roughly over the object, its shadow or reflection too if they should go, or let SAM 3 find it from the instruction below and touch that up; with nothing painted, Remove Object finds it by itself. The mask is drawn as a red outline for the model.";
|
| 638 |
+
}
|
| 639 |
+
$("mask-find").onclick = async () => {
|
| 640 |
+
const prompt = $("prompt").value.trim();
|
| 641 |
+
if (!state.sourcePath) { setStatus("Upload a photo or pick an example first."); return; }
|
| 642 |
+
if (!prompt) { setStatus("Write what to remove in the instruction first."); $("prompt").focus(); return; }
|
| 643 |
+
const sourceUrl = state.sourceUrl;
|
| 644 |
+
setStatus("Finding the object…", true);
|
| 645 |
+
$("mask-find").disabled = true;
|
| 646 |
+
try {
|
| 647 |
+
const app = await getApp();
|
| 648 |
+
const payload = unwrap(await app.predict("/find_mask", { source: state.sourcePath, text: prompt }));
|
| 649 |
+
if (typeof payload.mask_data !== "string") { setStatus("Nothing found (see console)."); return; }
|
| 650 |
+
if (state.sourceUrl !== sourceUrl) return; // the photo changed while it ran
|
| 651 |
+
await loadMask(payload.mask_data);
|
| 652 |
+
setStatus("Mask found. Touch it up if needed, then hit Remove Object");
|
| 653 |
+
} catch (err) {
|
| 654 |
+
setStatus(err?.message || String(err));
|
| 655 |
+
} finally {
|
| 656 |
+
$("mask-find").disabled = false;
|
| 657 |
+
}
|
| 658 |
+
};
|
| 659 |
+
|
| 660 |
// ---------- history ----------
|
| 661 |
function showResultImage(uri) {
|
| 662 |
$("result-img").src = uri;
|
|
|
|
| 787 |
const run = {
|
| 788 |
prompt, variant: $("variant").value, sourceUrl: state.sourceUrl, sourcePath: state.sourcePath,
|
| 789 |
};
|
| 790 |
+
const autoMask = CONFIG.textMask && !state.maskPainted;
|
| 791 |
+
setStatus(autoMask ? "Finding the object and removing it…" : "Removing…", true);
|
| 792 |
try {
|
| 793 |
const app = await getApp();
|
| 794 |
const result = await app.predict("/remove", {
|
|
|
|
| 798 |
variant: run.variant,
|
| 799 |
seed: parseInt($("seed").value, 10) || 0,
|
| 800 |
randomize: $("randomize").checked,
|
| 801 |
+
auto_mask: autoMask,
|
| 802 |
});
|
| 803 |
const payload = unwrap(result);
|
| 804 |
if (typeof payload.image_data !== "string") { setStatus("Run finished but no image came back (see console)."); return; }
|
|
|
|
| 806 |
if (state.sourceUrl === run.sourceUrl) { // the photo may have changed while it ran
|
| 807 |
$("result-meta").textContent = payload.info || "Done";
|
| 808 |
showResultImage(payload.image_data);
|
| 809 |
+
// Show the mask SAM 3 found, so the next run can reuse or refine it.
|
| 810 |
+
if (typeof payload.mask_data === "string" && !state.maskPainted) await loadMask(payload.mask_data);
|
| 811 |
}
|
| 812 |
history.unshift({ ...run, uri: payload.image_data, seed: payload.seed, info: payload.info || "" });
|
| 813 |
if (history.length > 8) history.pop();
|
text_mask.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Find the object to remove from a text description with SAM 3 (text-prompted segmentation).
|
| 2 |
+
|
| 3 |
+
The instruction ("Remove the wooden ladder, the white stool and the potted plant.") is turned into
|
| 4 |
+
short noun phrases ("wooden ladder", "white stool", "potted plant"), SAM 3 segments every instance
|
| 5 |
+
of each, and the union is dilated a little so it reads like a rough painted mask: white = remove.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import re
|
| 11 |
+
|
| 12 |
+
import cv2
|
| 13 |
+
import numpy as np
|
| 14 |
+
import torch
|
| 15 |
+
from PIL import Image
|
| 16 |
+
|
| 17 |
+
SAM3_REPO = "facebook/sam3"
|
| 18 |
+
|
| 19 |
+
_VERBS = r"(?:please\s+)?(?:remove|erase|delete|get rid of|take out|take away|clear|eliminate|cut out)"
|
| 20 |
+
_LEADING = re.compile(rf"^\s*{_VERBS}\s+", re.IGNORECASE)
|
| 21 |
+
_ARTICLE = re.compile(r"^(?:the|a|an|all(?: of)?(?: the)?|both|with|plus)\s+", re.IGNORECASE)
|
| 22 |
+
_SPLIT = re.compile(r"\s*(?:,|;|\band\b|\balong with\b|\bas well as\b)\s*", re.IGNORECASE)
|
| 23 |
+
# Where the object is: stripped from the phrase SAM 3 sees, then used to pick among the instances.
|
| 24 |
+
_WHERE = re.compile(
|
| 25 |
+
r"\s*\b(?:(?:on|to|at|in)\s+the\s+)?(?:far\s+)?(left|right|middle|center|centre|top|bottom|foreground|background)"
|
| 26 |
+
r"(?:\s+(?:side|edge|corner))?(?:\s+of\s+the\s+(?:image|photo|picture|frame))?\b",
|
| 27 |
+
re.IGNORECASE,
|
| 28 |
+
)
|
| 29 |
+
# Where a long description stops naming the object ("person | with the umbrella walking ...").
|
| 30 |
+
_QUALIFIER = re.compile(
|
| 31 |
+
r"\s+(?:with|in|on|at|of|near|next to|behind|beside|holding|wearing|walking|standing|sitting|"
|
| 32 |
+
r"lying|running|riding|that|which|who)\b.*$",
|
| 33 |
+
re.IGNORECASE,
|
| 34 |
+
)
|
| 35 |
+
_COUNT = re.compile(r"^(?:two|three|four|five|six|\d+|several|many|some|all)\b", re.IGNORECASE)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def phrases_from_instruction(text: str) -> list[str]:
|
| 39 |
+
"""'Remove the boathouse and the red canoes on the shore.' -> ['boathouse', 'red canoes on the shore']"""
|
| 40 |
+
text = (text or "").strip()
|
| 41 |
+
# Only the first sentence names the object; later ones are extra guidance.
|
| 42 |
+
text = re.split(r"(?<=[.!?])\s+", text, maxsplit=1)[0].rstrip(" .!?")
|
| 43 |
+
text = _LEADING.sub("", text)
|
| 44 |
+
phrases = []
|
| 45 |
+
for part in _SPLIT.split(text):
|
| 46 |
+
part = part.strip()
|
| 47 |
+
while (stripped := _ARTICLE.sub("", part)) != part:
|
| 48 |
+
part = stripped
|
| 49 |
+
if part and part.lower() not in phrases:
|
| 50 |
+
phrases.append(part.lower())
|
| 51 |
+
return phrases
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _split_where(phrase: str) -> tuple[str, list[str]]:
|
| 55 |
+
where = [w.lower() for w in _WHERE.findall(phrase)]
|
| 56 |
+
stripped = re.sub(r"\s+", " ", _WHERE.sub(" ", phrase)).strip()
|
| 57 |
+
return (stripped or phrase), where
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def _pick(masks: np.ndarray, scores: np.ndarray, where: list[str]) -> np.ndarray:
|
| 61 |
+
"""The one instance the position words point at (instances: N x H x W bool): the instances on
|
| 62 |
+
that side of the photo, then the most prominent of them (people point at the obvious one)."""
|
| 63 |
+
h, w = masks.shape[1:]
|
| 64 |
+
sides = {
|
| 65 |
+
"left": lambda cx, cy: cx < 0.5, "right": lambda cx, cy: cx > 0.5,
|
| 66 |
+
"top": lambda cx, cy: cy < 0.5, "bottom": lambda cx, cy: cy > 0.5,
|
| 67 |
+
"middle": lambda cx, cy: abs(cx - 0.5) < 0.25, "center": lambda cx, cy: abs(cx - 0.5) < 0.25,
|
| 68 |
+
"centre": lambda cx, cy: abs(cx - 0.5) < 0.25,
|
| 69 |
+
}
|
| 70 |
+
stats = []
|
| 71 |
+
for m in masks:
|
| 72 |
+
ys, xs = np.nonzero(m)
|
| 73 |
+
stats.append((xs.mean() / w, ys.mean() / h, m.mean()))
|
| 74 |
+
keep = [i for i, (cx, cy, _) in enumerate(stats) if all(sides[x](cx, cy) for x in where if x in sides)]
|
| 75 |
+
keep = keep or list(range(len(masks)))
|
| 76 |
+
sign = -1 if "background" in where else 1 # background: the smallest, the furthest away
|
| 77 |
+
best = max(keep, key=lambda i: (sign * stats[i][2] * (0.5 + float(scores[i]))))
|
| 78 |
+
return masks[best : best + 1]
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def _plural(query: str) -> bool:
|
| 82 |
+
head = _QUALIFIER.sub("", query).split()
|
| 83 |
+
return bool(_COUNT.match(query)) or (bool(head) and head[-1].endswith("s") and not head[-1].endswith("ss"))
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def _fill(mask: np.ndarray) -> np.ndarray:
|
| 87 |
+
"""Fill holes: the outline should go around the object, not through it (a ladder's rungs)."""
|
| 88 |
+
contours, _ = cv2.findContours(mask.astype(np.uint8), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
| 89 |
+
filled = np.zeros(mask.shape, np.uint8)
|
| 90 |
+
cv2.drawContours(filled, contours, -1, 1, thickness=cv2.FILLED)
|
| 91 |
+
return filled > 0
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
class TextMasker:
|
| 95 |
+
def __init__(self, device: str = "cuda", dtype=torch.bfloat16):
|
| 96 |
+
from transformers import Sam3Model, Sam3Processor
|
| 97 |
+
|
| 98 |
+
self.processor = Sam3Processor.from_pretrained(SAM3_REPO)
|
| 99 |
+
self.model = Sam3Model.from_pretrained(SAM3_REPO, torch_dtype=dtype).to(device).eval()
|
| 100 |
+
self.dtype = dtype
|
| 101 |
+
|
| 102 |
+
@torch.no_grad()
|
| 103 |
+
def _segment(self, image: Image.Image, phrases: list[str], threshold: float):
|
| 104 |
+
inputs = self.processor(images=[image] * len(phrases), text=phrases, return_tensors="pt").to(self.model.device)
|
| 105 |
+
inputs["pixel_values"] = inputs["pixel_values"].to(self.dtype)
|
| 106 |
+
outputs = self.model(**inputs)
|
| 107 |
+
results = self.processor.post_process_instance_segmentation(
|
| 108 |
+
outputs, threshold=threshold, mask_threshold=0.5, target_sizes=inputs["original_sizes"].tolist()
|
| 109 |
+
)
|
| 110 |
+
return [(r["masks"].bool().cpu().numpy(), r["scores"].float().cpu().numpy()) for r in results]
|
| 111 |
+
|
| 112 |
+
def __call__(
|
| 113 |
+
self, image: Image.Image, text: str, threshold: float = 0.4, dilate: float = 0.012
|
| 114 |
+
) -> tuple[Image.Image | None, list[tuple[str, int]]]:
|
| 115 |
+
"""Mask (L, white = remove) for every phrase in `text`, or None when nothing is found.
|
| 116 |
+
Also returns (phrase, instances used) per phrase."""
|
| 117 |
+
image = image.convert("RGB")
|
| 118 |
+
phrases = phrases_from_instruction(text)
|
| 119 |
+
if not phrases:
|
| 120 |
+
return None, []
|
| 121 |
+
queries = [_split_where(p) for p in phrases]
|
| 122 |
+
results = self._segment(image, [q for q, _ in queries], threshold)
|
| 123 |
+
# A long relational description ("studio light in the foreground on the left") can find
|
| 124 |
+
# nothing: retry those with just the head noun phrase, a little more permissively.
|
| 125 |
+
retry = [i for i, (masks, _) in enumerate(results) if not len(masks)]
|
| 126 |
+
# Then with just the noun ("wooden chapel" -> "chapel"). A retry is a looser guess, so it
|
| 127 |
+
# keeps only its best instance unless the phrase asks for several.
|
| 128 |
+
for shorten in (lambda q: _QUALIFIER.sub("", q), lambda q: _QUALIFIER.sub("", q).split()[-1]):
|
| 129 |
+
retry = [i for i, (masks, _) in enumerate(results) if not len(masks)]
|
| 130 |
+
if not retry:
|
| 131 |
+
break
|
| 132 |
+
again = self._segment(image, [shorten(queries[i][0]) for i in retry], threshold * 0.75)
|
| 133 |
+
for i, (masks, scores) in zip(retry, again):
|
| 134 |
+
if len(masks) > 1 and not _plural(queries[i][0]):
|
| 135 |
+
keep = int(scores.argmax())
|
| 136 |
+
masks, scores = masks[keep : keep + 1], scores[keep : keep + 1]
|
| 137 |
+
results[i] = (masks, scores)
|
| 138 |
+
union = np.zeros((image.height, image.width), dtype=bool)
|
| 139 |
+
found = []
|
| 140 |
+
for (query, where), phrase, (masks, scores) in zip(queries, phrases, results):
|
| 141 |
+
# One instance when the phrase is singular and says where it is ("the phone on the right").
|
| 142 |
+
if len(masks) > 1 and where and not _COUNT.match(query):
|
| 143 |
+
masks = _pick(masks, scores, where)
|
| 144 |
+
found.append((phrase, int(len(masks))))
|
| 145 |
+
if len(masks):
|
| 146 |
+
union |= masks.any(0)
|
| 147 |
+
if not union.any():
|
| 148 |
+
return None, found
|
| 149 |
+
radius = max(2, round(dilate * min(image.size)))
|
| 150 |
+
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * radius + 1, 2 * radius + 1))
|
| 151 |
+
union = _fill(cv2.dilate(union.astype(np.uint8), kernel) > 0)
|
| 152 |
+
return Image.fromarray((union * 255).astype(np.uint8), "L"), found
|