Text-guided removal: auto-generate the mask with SAM 3

#1
by linoyts HF Staff - opened
Files changed (4) hide show
  1. README.md +11 -2
  2. app.py +93 -15
  3. studio.html +39 -5
  4. 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 `randomize`.
 
 
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 _gpu_seconds(prompt, variant, seed, image, resolution):
136
- return VARIANTS[variant]["gpu_seconds"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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. Returns (image, gpu seconds, variant, outlined)."""
 
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
- outlined = False
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
- outlined = True
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, outlined
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, outlined = _remove(_open_rgb(source), _open_mask(mask), instruction, variant, seed_value)
361
- mode = "outline" if outlined else "prompt only"
 
362
  info = f"{image.width}×{image.height}, seed {seed_value} · {name} · {mode} · {seconds:.1f} s on the GPU"
363
- return {"image_data": _as_data_uri(image), "seed": seed_value, "info": info}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- </section>
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
- setStatus("Removing…", true);
 
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