mansi-2 commited on
Commit
eab4d9b
·
verified ·
1 Parent(s): 4502b17

Upload 11 files

Browse files
Files changed (11) hide show
  1. Dockerfile +55 -0
  2. app.py +92 -0
  3. clip_matcher.py +145 -0
  4. config.py +75 -0
  5. detector.py +124 -0
  6. download_checkpoints.py +65 -0
  7. image_utils.py +270 -0
  8. inpainter_lama.py +299 -0
  9. pipeline.py +259 -0
  10. requirements.txt +17 -0
  11. segmenter.py +133 -0
Dockerfile ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Base image: PyTorch with CUDA (torch is pre-installed in conda here)
2
+ FROM pytorch/pytorch:2.1.2-cuda12.1-cudnn8-runtime
3
+
4
+ # Environment
5
+ ENV DEBIAN_FRONTEND=noninteractive
6
+ ENV PYTHONUNBUFFERED=1
7
+
8
+ # ---------------------------------------------------------------
9
+ # ALL installs run as ROOT so pip uses the conda environment
10
+ # where torch already lives. This prevents GroundingDINO's
11
+ # setup.py from failing to find torch.
12
+ # ---------------------------------------------------------------
13
+
14
+ # System dependencies
15
+ RUN apt-get update && apt-get install -y \
16
+ git \
17
+ ffmpeg \
18
+ libsm6 \
19
+ libxext6 \
20
+ wget \
21
+ && rm -rf /var/lib/apt/lists/*
22
+
23
+ # Upgrade pip inside conda
24
+ RUN pip install --no-cache-dir --upgrade pip
25
+
26
+ # Python dependencies (into the conda env as root)
27
+ COPY requirements.txt /tmp/requirements.txt
28
+ RUN pip install --no-cache-dir -r /tmp/requirements.txt
29
+
30
+ # Install GroundingDINO with --no-build-isolation so its setup.py can
31
+ # find torch from the conda environment (not a sandboxed temp env).
32
+ RUN pip install --no-cache-dir --no-build-isolation \
33
+ "git+https://github.com/IDEA-Research/GroundingDINO.git"
34
+
35
+ # Install Segment Anything
36
+ RUN pip install --no-cache-dir \
37
+ "git+https://github.com/facebookresearch/segment-anything.git"
38
+
39
+ # ---------------------------------------------------------------
40
+ # Create non-root user for runtime (HF Spaces requirement)
41
+ # Packages installed above are in /opt/conda and accessible
42
+ # to all users, so this is safe.
43
+ # ---------------------------------------------------------------
44
+ RUN useradd -m -u 1000 user
45
+ USER user
46
+ ENV PATH="/home/user/.local/bin:${PATH}"
47
+ WORKDIR /home/user/app
48
+
49
+ # Copy application files
50
+ COPY --chown=user . .
51
+
52
+ # Hugging Face Spaces mandatory port
53
+ EXPOSE 7860
54
+
55
+ CMD ["python", "app.py"]
app.py ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ app.py - Hugging Face Spaces entry point.
3
+ Automatically downloads model checkpoints on first run and
4
+ serves a Gradio interface for AI object removal.
5
+ """
6
+
7
+ import os
8
+ import sys
9
+
10
+ # Ensure project root is on the path
11
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
12
+
13
+ # -- Step 1: Download checkpoints if needed ----------------------------------
14
+ print("Checking model checkpoints...")
15
+ from download_checkpoints import main as download_checkpoints
16
+ download_checkpoints()
17
+
18
+ # -- Step 2: Load the pipeline -----------------------------------------------
19
+ import gradio as gr
20
+ from pipeline import ObjectRemovalPipeline
21
+ from config import CLIP_SIMILARITY_THRESHOLD
22
+
23
+ print("Initializing AI models (GroundingDINO, SAM, CLIP)...")
24
+ pipeline = ObjectRemovalPipeline()
25
+ print("All models loaded. Ready!")
26
+
27
+ # -- Step 3: Define Gradio callback ------------------------------------------
28
+ def predict(scene_img_path, object_img_paths, threshold):
29
+ if scene_img_path is None:
30
+ return None, "Error: Please upload a scene image."
31
+ if not object_img_paths:
32
+ return None, "Error: Please upload at least one object reference image."
33
+
34
+ # object_img_paths is a list of file paths from gr.File
35
+ if isinstance(object_img_paths, str):
36
+ object_img_paths = [object_img_paths]
37
+
38
+ try:
39
+ result_pil = pipeline.run(
40
+ scene_path=scene_img_path,
41
+ object_paths=object_img_paths,
42
+ threshold=threshold,
43
+ save_debug=False # avoid cluttering cloud storage
44
+ )
45
+ return result_pil, "Done! Object(s) removed successfully."
46
+ except Exception as e:
47
+ import traceback
48
+ traceback.print_exc()
49
+ return None, f"Error: {str(e)}"
50
+
51
+ # -- Step 4: Build Gradio UI -------------------------------------------------
52
+ with gr.Blocks(title="AI Object Eraser") as demo:
53
+ gr.Markdown("# AI Object Eraser")
54
+ gr.Markdown(
55
+ "Upload a **scene photo** and one or more **reference photos** of the objects "
56
+ "you want to remove. The AI will detect and erase them for you."
57
+ )
58
+
59
+ with gr.Row():
60
+ with gr.Column():
61
+ scene_input = gr.Image(
62
+ label="Scene Image",
63
+ type="filepath"
64
+ )
65
+ object_input = gr.File(
66
+ label="Object Reference(s) to Remove",
67
+ file_count="multiple",
68
+ type="filepath"
69
+ )
70
+ threshold_slider = gr.Slider(
71
+ minimum=0.1,
72
+ maximum=1.0,
73
+ value=CLIP_SIMILARITY_THRESHOLD,
74
+ step=0.05,
75
+ label="Detection Sensitivity (lower = more detections)"
76
+ )
77
+ run_btn = gr.Button("Remove Objects", variant="primary")
78
+
79
+ with gr.Column():
80
+ output_img = gr.Image(label="Result")
81
+ status_box = gr.Textbox(label="Status")
82
+
83
+ run_btn.click(
84
+ fn=predict,
85
+ inputs=[scene_input, object_input, threshold_slider],
86
+ outputs=[output_img, status_box]
87
+ )
88
+
89
+ # -- Step 5: Launch ----------------------------------------------------------
90
+ if __name__ == "__main__":
91
+ # Port 7860 is mandatory for Hugging Face Spaces
92
+ demo.launch(server_name="0.0.0.0", server_port=7860)
clip_matcher.py ADDED
@@ -0,0 +1,145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ models/clip_matcher.py
3
+ -----------------------
4
+ Uses CLIP to compute similarity between a reference-object crop
5
+ and candidate regions in the scene, and to generate text prompts
6
+ for GroundingDINO.
7
+ """
8
+
9
+ import os
10
+ from typing import List, Tuple, Optional
11
+
12
+ import numpy as np
13
+ import torch
14
+ import torch.nn.functional as F
15
+ from PIL import Image
16
+ from transformers import CLIPProcessor, CLIPModel
17
+
18
+ import sys
19
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
20
+ from config import CLIP_MODEL, DEVICE, CLIP_SIMILARITY_THRESHOLD
21
+
22
+
23
+ class CLIPMatcher:
24
+ """
25
+ Wraps CLIP for two tasks:
26
+ 1. Predict a text description of a reference-object image.
27
+ 2. Score similarity between a reference image and scene crops.
28
+ """
29
+
30
+ CANDIDATE_LABELS = [
31
+ "a person", "a car", "a chair", "a table", "a dog", "a cat",
32
+ "a bottle", "a cup", "a book", "a laptop", "a phone", "a bag",
33
+ "a bicycle", "a motorcycle", "a bus", "a truck", "a tree",
34
+ "a flower", "a ball", "a clock", "a lamp", "a vase",
35
+ "a backpack", "furniture", "an electronic device", "a toy",
36
+ "a plant", "a statue", "a sign", "a box", "an object",
37
+ # plumbing & household
38
+ "a tap", "a faucet", "a pipe", "a bucket", "a hose", "a valve",
39
+ # waste / clutter
40
+ "trash", "garbage", "waste", "litter", "rubbish",
41
+ "a plastic bag", "a polythene bag", "a leaf", "dry leaves",
42
+ "crumpled paper", "a wrapper", "debris",
43
+ ]
44
+
45
+ def __init__(self) -> None:
46
+ print(" Loading CLIP ...")
47
+ self.model = CLIPModel.from_pretrained(CLIP_MODEL).to(DEVICE)
48
+ self.processor = CLIPProcessor.from_pretrained(CLIP_MODEL)
49
+ self.model.eval()
50
+
51
+ @torch.no_grad()
52
+ def predict_label(self, ref_image: Image.Image) -> str:
53
+ """
54
+ Zero-shot classify the reference object and return the best matching
55
+ text label (usable as a GroundingDINO prompt).
56
+ """
57
+ inputs = self.processor(
58
+ text=self.CANDIDATE_LABELS,
59
+ images=ref_image,
60
+ return_tensors="pt",
61
+ padding=True,
62
+ ).to(DEVICE)
63
+
64
+ outputs = self.model(**inputs)
65
+ probs = outputs.logits_per_image.softmax(dim=-1)[0]
66
+ best_idx = probs.argmax().item()
67
+ best_label = self.CANDIDATE_LABELS[best_idx]
68
+ best_prob = probs[best_idx].item()
69
+
70
+ print(f" CLIP label: '{best_label}' (conf={best_prob:.2f})")
71
+ return best_label
72
+
73
+ @torch.no_grad()
74
+ def image_embedding(self, image: Image.Image) -> torch.Tensor:
75
+ """Return normalised CLIP image embedding (1 x D)."""
76
+ inputs = self.processor(images=image, return_tensors="pt").to(DEVICE)
77
+ emb = self.model.get_image_features(**inputs)
78
+ if hasattr(emb, "pooler_output"):
79
+ emb = emb.pooler_output
80
+ return F.normalize(emb, dim=-1)
81
+
82
+ @torch.no_grad()
83
+ def score_crops(
84
+ self,
85
+ ref_image: Image.Image,
86
+ scene_image: Image.Image,
87
+ boxes: List[Tuple[int, int, int, int]],
88
+ ) -> List[float]:
89
+ """
90
+ For each box (x1,y1,x2,y2) crop the scene and compute cosine
91
+ similarity to the reference image embedding.
92
+ Returns a list of float scores, one per box.
93
+ """
94
+ if not boxes:
95
+ return []
96
+
97
+ ref_emb = self.image_embedding(ref_image) # 1 x D
98
+
99
+ crops = []
100
+ for x1, y1, x2, y2 in boxes:
101
+ crop = scene_image.crop((x1, y1, x2, y2))
102
+ crops.append(crop)
103
+
104
+ batch_size = 8
105
+ all_sims = []
106
+ for i in range(0, len(crops), batch_size):
107
+ batch_crops = crops[i:i+batch_size]
108
+ inputs = self.processor(images=batch_crops, return_tensors="pt", padding=True).to(DEVICE)
109
+ with torch.no_grad():
110
+ crop_embs = self.model.get_image_features(**inputs)
111
+ if hasattr(crop_embs, "pooler_output"):
112
+ crop_embs = crop_embs.pooler_output
113
+ crop_embs = F.normalize(crop_embs, dim=-1) # B x D
114
+
115
+ sims = (ref_emb @ crop_embs.T).squeeze(0) # B
116
+
117
+ # handle 1D vs 0D (if batch_size is 1)
118
+ if sims.dim() == 0:
119
+ all_sims.append(sims.item())
120
+ else:
121
+ all_sims.extend(sims.cpu().tolist())
122
+
123
+ return all_sims
124
+
125
+ def filter_boxes_by_similarity(
126
+ self,
127
+ ref_image: Image.Image,
128
+ scene_image: Image.Image,
129
+ boxes: List[Tuple[int, int, int, int]],
130
+ threshold: float = CLIP_SIMILARITY_THRESHOLD,
131
+ ) -> List[Tuple[int, int, int, int]]:
132
+ """
133
+ Return only the boxes whose crop has cosine similarity >= threshold
134
+ with the reference image.
135
+ """
136
+ scores = self.score_crops(ref_image, scene_image, boxes)
137
+ filtered = []
138
+ for box, score in zip(boxes, scores):
139
+ print(f" box {box} sim={score:.3f}", end="")
140
+ if score >= threshold:
141
+ print(" [v]")
142
+ filtered.append(box)
143
+ else:
144
+ print(" [x]")
145
+ return filtered
config.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Pipeline configuration - edit these settings to control behaviour.
3
+ """
4
+
5
+ import os
6
+ import torch
7
+
8
+ # -- Device --------------------------------------------------------------------
9
+ DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
10
+ DTYPE = torch.float16 if DEVICE == "cuda" else torch.float32
11
+
12
+ # -- Paths ---------------------------------------------------------------------
13
+ BASE_DIR = os.path.dirname(os.path.abspath(__file__))
14
+ INPUT_SCENE_DIR = os.path.join(BASE_DIR, "input", "scene")
15
+ INPUT_OBJ_DIR = os.path.join(BASE_DIR, "input", "objects")
16
+ OUTPUT_DIR = os.path.join(BASE_DIR, "output")
17
+ CHECKPOINT_DIR = os.path.join(BASE_DIR, "checkpoints")
18
+
19
+ # -- GroundingDINO -------------------------------------------------------------
20
+ GDINO_CONFIG = os.path.join(
21
+ CHECKPOINT_DIR,
22
+ "GroundingDINO_SwinT_OGC.py"
23
+ )
24
+ GDINO_WEIGHTS = os.path.join(
25
+ CHECKPOINT_DIR,
26
+ "groundingdino_swint_ogc.pth"
27
+ )
28
+ GDINO_CONFIG_URL = (
29
+ "https://raw.githubusercontent.com/IDEA-Research/GroundingDINO/main/"
30
+ "groundingdino/config/GroundingDINO_SwinT_OGC.py"
31
+ )
32
+ GDINO_WEIGHTS_URL = (
33
+ "https://github.com/IDEA-Research/GroundingDINO/releases/download/"
34
+ "v0.1.0-alpha/groundingdino_swint_ogc.pth"
35
+ )
36
+
37
+ # -- SAM -----------------------------------------------------------------------
38
+ SAM_CHECKPOINT = os.path.join(CHECKPOINT_DIR, "sam_vit_h_4b8939.pth")
39
+ SAM_MODEL_TYPE = "vit_h"
40
+ SAM_CHECKPOINT_URL = (
41
+ "https://dl.fbaipublicfiles.com/segment_anything/sam_vit_h_4b8939.pth"
42
+ )
43
+
44
+ # -- CLIP (matching) -----------------------------------------------------------
45
+ CLIP_MODEL = "openai/clip-vit-large-patch14"
46
+
47
+ # -- Inpainting ----------------------------------------------------------------
48
+ # Options: "lama" | "sd_inpaint"
49
+ INPAINT_METHOD = "lama"
50
+
51
+ # Stable Diffusion inpainting model (used when INPAINT_METHOD == "sd_inpaint")
52
+ SD_INPAINT_MODEL = "stabilityai/stable-diffusion-2-inpainting"
53
+ SD_INPAINT_STEPS = 30
54
+ SD_INPAINT_GUIDANCE = 7.5
55
+ SD_INPAINT_PROMPT = "clean background, sharp, photorealistic, 8k, no blur, seamless"
56
+
57
+ # LaMa checkpoint (auto-downloaded if missing)
58
+ LAMA_CHECKPOINT = os.path.join(CHECKPOINT_DIR, "big-lama")
59
+ LAMA_URL = (
60
+ "https://huggingface.co/smartywu/big-lama/resolve/main/big-lama.zip"
61
+ )
62
+
63
+ # -- Detection / Matching thresholds -------------------------------------------
64
+ # GroundingDINO - box + text confidence
65
+ GDINO_BOX_THRESHOLD = 0.30
66
+ GDINO_TEXT_THRESHOLD = 0.25
67
+
68
+ # CLIP cosine similarity threshold for accepting a match
69
+ CLIP_SIMILARITY_THRESHOLD = 0.30
70
+
71
+ # Mask dilation (pixels) applied before inpainting to cover object edges
72
+ MASK_DILATION_PX = 25
73
+
74
+ # -- Visualisation -------------------------------------------------------------
75
+ SAVE_DEBUG_IMAGES = True # saves intermediate masks and detections
detector.py ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ models/detector.py
3
+ -------------------
4
+ GroundingDINO-based open-vocabulary object detector.
5
+
6
+ Given a text prompt (generated by CLIP or provided by the user) and a scene
7
+ image, returns bounding boxes with scores.
8
+ """
9
+
10
+ import os
11
+ import sys
12
+ from typing import List, Tuple, Dict, Any
13
+
14
+ import numpy as np
15
+ import torch
16
+ from PIL import Image
17
+
18
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
19
+ from config import (
20
+ DEVICE,
21
+ GDINO_CONFIG, GDINO_WEIGHTS,
22
+ GDINO_BOX_THRESHOLD, GDINO_TEXT_THRESHOLD,
23
+ )
24
+
25
+
26
+ class GroundingDINODetector:
27
+ """
28
+ Wraps the GroundingDINO model for open-vocabulary detection.
29
+ The model is loaded lazily on first use.
30
+ """
31
+
32
+ def __init__(self) -> None:
33
+ self._model = None
34
+
35
+ def _load(self) -> None:
36
+ if self._model is not None:
37
+ self._model = self._model.to(DEVICE)
38
+ return
39
+
40
+ print(" Loading GroundingDINO ...")
41
+ try:
42
+ from groundingdino.util.inference import load_model
43
+ self._model = load_model(GDINO_CONFIG, GDINO_WEIGHTS)
44
+ self._model = self._model.to(DEVICE)
45
+ self._model.eval()
46
+ except ImportError as e:
47
+ raise RuntimeError(
48
+ "GroundingDINO is not installed. "
49
+ "Run: pip install groundingdino-py (or install from source)\n"
50
+ f"Original error: {e}"
51
+ )
52
+
53
+ @torch.no_grad()
54
+ def detect(
55
+ self,
56
+ image_pil: Image.Image,
57
+ text_prompt: str,
58
+ box_threshold: float = GDINO_BOX_THRESHOLD,
59
+ text_threshold: float = GDINO_TEXT_THRESHOLD,
60
+ ) -> List[Dict[str, Any]]:
61
+ """
62
+ Run GroundingDINO on `image_pil` with `text_prompt`.
63
+
64
+ Returns a list of dicts:
65
+ { "box": (x1, y1, x2, y2), "score": float, "label": str }
66
+ All coordinates are in absolute pixels.
67
+ """
68
+ self._load()
69
+
70
+ from groundingdino.util.inference import predict
71
+ import groundingdino.datasets.transforms as T
72
+
73
+ transform = T.Compose([
74
+ T.RandomResize([800], max_size=1333),
75
+ T.ToTensor(),
76
+ T.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]),
77
+ ])
78
+
79
+ image_tensor, _ = transform(image_pil, None)
80
+ image_tensor = image_tensor.to(DEVICE)
81
+
82
+ w, h = image_pil.size
83
+
84
+ boxes_norm, scores, labels = predict(
85
+ model=self._model,
86
+ image=image_tensor,
87
+ caption=text_prompt,
88
+ box_threshold=box_threshold,
89
+ text_threshold=text_threshold,
90
+ device=DEVICE,
91
+ )
92
+
93
+ detections = []
94
+ for box_n, score, label in zip(boxes_norm, scores, labels):
95
+ # box_n is cx,cy,bw,bh (normalised) -> convert to absolute x1y1x2y2
96
+ cx, cy, bw, bh = box_n.tolist()
97
+ x1 = int((cx - bw / 2) * w)
98
+ y1 = int((cy - bh / 2) * h)
99
+ x2 = int((cx + bw / 2) * w)
100
+ y2 = int((cy + bh / 2) * h)
101
+
102
+ # clamp to image bounds
103
+ x1, y1 = max(0, x1), max(0, y1)
104
+ x2, y2 = min(w, x2), min(h, y2)
105
+
106
+ detections.append({
107
+ "box": (x1, y1, x2, y2),
108
+ "score": float(score),
109
+ "label": label,
110
+ })
111
+
112
+ print(f" GroundingDINO found {len(detections)} candidate(s) "
113
+ f"for prompt '{text_prompt}'")
114
+ return detections
115
+
116
+ def boxes_only(
117
+ self,
118
+ image_pil: Image.Image,
119
+ text_prompt: str,
120
+ **kwargs,
121
+ ) -> List[Tuple[int, int, int, int]]:
122
+ """Convenience wrapper - returns only the list of (x1,y1,x2,y2) boxes."""
123
+ dets = self.detect(image_pil, text_prompt, **kwargs)
124
+ return [d["box"] for d in dets]
download_checkpoints.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ utils/download_checkpoints.py
3
+ ------------------------------
4
+ Downloads all required model checkpoints if not already present.
5
+ Run once before using the pipeline: python -m utils.download_checkpoints
6
+ """
7
+
8
+ import os
9
+ import sys
10
+
11
+ # allow running standalone
12
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
13
+
14
+ from config import (
15
+ CHECKPOINT_DIR,
16
+ GDINO_CONFIG, GDINO_WEIGHTS, GDINO_CONFIG_URL, GDINO_WEIGHTS_URL,
17
+ SAM_CHECKPOINT, SAM_CHECKPOINT_URL,
18
+ LAMA_CHECKPOINT, LAMA_URL,
19
+ INPAINT_METHOD,
20
+ )
21
+ from image_utils import download_file, download_text_file, unzip
22
+
23
+
24
+ def download_groundingdino() -> None:
25
+ print("\n[1/3] GroundingDINO weights")
26
+ download_text_file(GDINO_CONFIG_URL, GDINO_CONFIG)
27
+ download_file(GDINO_WEIGHTS_URL, GDINO_WEIGHTS, "GroundingDINO weights")
28
+
29
+
30
+ def download_sam() -> None:
31
+ print("\n[2/3] SAM (Segment Anything) weights")
32
+ download_file(SAM_CHECKPOINT_URL, SAM_CHECKPOINT, "SAM ViT-H")
33
+
34
+
35
+ def download_lama() -> None:
36
+ print("\n[3/3] LaMa inpainting weights")
37
+ if os.path.isdir(LAMA_CHECKPOINT):
38
+ print(" [DONE] LaMa checkpoint directory already exists")
39
+ return
40
+ zip_path = os.path.join(CHECKPOINT_DIR, "big-lama.zip")
41
+ download_file(LAMA_URL, zip_path, "LaMa big-lama")
42
+ unzip(zip_path, CHECKPOINT_DIR)
43
+ os.remove(zip_path)
44
+ print(" [DONE] LaMa extracted")
45
+
46
+
47
+ def main() -> None:
48
+ os.makedirs(CHECKPOINT_DIR, exist_ok=True)
49
+ print("=" * 60)
50
+ print(" Downloading model checkpoints")
51
+ print("=" * 60)
52
+
53
+ download_groundingdino()
54
+ download_sam()
55
+
56
+ if INPAINT_METHOD == "lama":
57
+ download_lama()
58
+ else:
59
+ print("\n[3/3] Stable Diffusion -- will auto-download on first run via diffusers")
60
+
61
+ print("\n[SUCCESS] All checkpoints ready.\n")
62
+
63
+
64
+ if __name__ == "__main__":
65
+ main()
image_utils.py ADDED
@@ -0,0 +1,270 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ utils/image_utils.py
3
+ --------------------
4
+ Image I/O, mask manipulation, and debug-image helpers.
5
+ """
6
+
7
+ import os
8
+ import math
9
+ import zipfile
10
+ import requests
11
+ import urllib.request
12
+ from pathlib import Path
13
+ from typing import List, Tuple, Optional, Union
14
+
15
+ import cv2
16
+ import numpy as np
17
+ from PIL import Image
18
+ from tqdm import tqdm
19
+ import matplotlib
20
+ matplotlib.use("Agg")
21
+ import matplotlib.pyplot as plt
22
+ import matplotlib.patches as mpatches
23
+
24
+
25
+ # -- Image I/O -----------------------------------------------------------------
26
+
27
+ def load_image_pil(path: str) -> Image.Image:
28
+ """Load image as PIL RGB."""
29
+ return Image.open(path).convert("RGB")
30
+
31
+
32
+ def load_image_cv2(path: str) -> np.ndarray:
33
+ """Load image as OpenCV BGR numpy array."""
34
+ img = cv2.imread(path)
35
+ if img is None:
36
+ raise FileNotFoundError(f"Cannot read image: {path}")
37
+ return img
38
+
39
+
40
+ def pil_to_cv2(img: Image.Image) -> np.ndarray:
41
+ return cv2.cvtColor(np.array(img), cv2.COLOR_RGB2BGR)
42
+
43
+
44
+ def cv2_to_pil(img: np.ndarray) -> Image.Image:
45
+ return Image.fromarray(cv2.cvtColor(img, cv2.COLOR_BGR2RGB))
46
+
47
+
48
+ # Alias for compatibility
49
+ load_image = load_image_pil
50
+
51
+
52
+ def show_mask(mask, ax, random_color=False):
53
+ """Stub for SAM visualization."""
54
+ pass
55
+
56
+
57
+ def show_box(box, ax):
58
+ """Stub for SAM visualization."""
59
+ pass
60
+
61
+
62
+ def dilate_mask_with_sam_prediction(mask, dilation_px):
63
+ """Stub for SAM-based dilation."""
64
+ return mask
65
+
66
+
67
+ def save_image(img: Union[Image.Image, np.ndarray], path: str) -> None:
68
+ os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
69
+ if isinstance(img, np.ndarray):
70
+ cv2.imwrite(path, img)
71
+ else:
72
+ img.save(path)
73
+
74
+
75
+ def list_images(directory: str) -> List[str]:
76
+ """Return sorted list of image file paths in a directory."""
77
+ exts = {".jpg", ".jpeg", ".png", ".bmp", ".webp", ".tiff"}
78
+ paths = sorted(
79
+ str(p) for p in Path(directory).iterdir()
80
+ if p.suffix.lower() in exts
81
+ )
82
+ return paths
83
+
84
+
85
+ # -- Mask operations -----------------------------------------------------------
86
+
87
+ def boxes_to_mask(
88
+ boxes: List[Tuple[int, int, int, int]],
89
+ h: int,
90
+ w: int,
91
+ dilation_px: int = 0,
92
+ ) -> np.ndarray:
93
+ """
94
+ Convert list of (x1,y1,x2,y2) boxes to a binary uint8 mask (HW).
95
+ Optionally dilate the mask by `dilation_px` pixels.
96
+ """
97
+ mask = np.zeros((h, w), dtype=np.uint8)
98
+ for x1, y1, x2, y2 in boxes:
99
+ x1, y1 = max(0, x1), max(0, y1)
100
+ x2, y2 = min(w, x2), min(h, y2)
101
+ mask[y1:y2, x1:x2] = 255
102
+
103
+ if dilation_px > 0:
104
+ kernel = cv2.getStructuringElement(
105
+ cv2.MORPH_ELLIPSE, (dilation_px * 2 + 1, dilation_px * 2 + 1)
106
+ )
107
+ mask = cv2.dilate(mask, kernel)
108
+
109
+ return mask
110
+
111
+
112
+ def combine_masks(masks: List[np.ndarray]) -> np.ndarray:
113
+ """OR-combine a list of binary uint8 masks."""
114
+ if not masks:
115
+ raise ValueError("Empty mask list")
116
+ out = np.zeros_like(masks[0])
117
+ for m in masks:
118
+ out = cv2.bitwise_or(out, m)
119
+ return out
120
+
121
+
122
+ def refine_mask_with_sam_prediction(
123
+ raw_mask: np.ndarray,
124
+ sam_masks: List[np.ndarray],
125
+ ) -> np.ndarray:
126
+ """
127
+ Given SAM predicted masks (each boolean HW), pick the one with the
128
+ highest IoU against the raw_mask and return it as uint8.
129
+ """
130
+ best_mask = raw_mask
131
+ best_iou = 0.0
132
+ raw_bool = raw_mask.astype(bool)
133
+
134
+ for m in sam_masks:
135
+ m_bool = m.astype(bool)
136
+ intersection = (raw_bool & m_bool).sum()
137
+ union = (raw_bool | m_bool).sum()
138
+ iou = intersection / (union + 1e-8)
139
+ if iou > best_iou:
140
+ best_iou = iou
141
+ best_mask = (m_bool.astype(np.uint8)) * 255
142
+
143
+ return best_mask
144
+
145
+
146
+ def dilate_mask(mask: np.ndarray, px: int) -> np.ndarray:
147
+ if px <= 0:
148
+ return mask
149
+ kernel = cv2.getStructuringElement(
150
+ cv2.MORPH_ELLIPSE, (px * 2 + 1, px * 2 + 1)
151
+ )
152
+ return cv2.dilate(mask, kernel)
153
+
154
+
155
+ # -- Debug visualisation -------------------------------------------------------
156
+
157
+ def save_detection_debug(
158
+ scene_path: str,
159
+ detections: List[dict],
160
+ output_path: str,
161
+ ) -> None:
162
+ """
163
+ Draw bounding boxes + labels on the scene image and save.
164
+ `detections` is a list of dicts with keys: box (x1,y1,x2,y2), label, score.
165
+ """
166
+ img = load_image_pil(scene_path)
167
+ fig, ax = plt.subplots(1, figsize=(12, 8))
168
+ ax.imshow(img)
169
+
170
+ colors = plt.cm.get_cmap("tab10").colors
171
+ for i, det in enumerate(detections):
172
+ x1, y1, x2, y2 = det["box"]
173
+ color = colors[i % len(colors)]
174
+ rect = mpatches.FancyBboxPatch(
175
+ (x1, y1), x2 - x1, y2 - y1,
176
+ boxstyle="round,pad=2",
177
+ linewidth=2, edgecolor=color, facecolor="none",
178
+ )
179
+ ax.add_patch(rect)
180
+ ax.text(
181
+ x1, y1 - 6,
182
+ f"{det['label']} ({det['score']:.2f})",
183
+ color="white", fontsize=9,
184
+ bbox=dict(facecolor=color, alpha=0.7, pad=2, edgecolor="none"),
185
+ )
186
+
187
+ ax.axis("off")
188
+ plt.tight_layout()
189
+ plt.savefig(output_path, dpi=150, bbox_inches="tight")
190
+ plt.close()
191
+
192
+
193
+ def save_mask_debug(
194
+ scene_path: str,
195
+ mask: np.ndarray,
196
+ output_path: str,
197
+ ) -> None:
198
+ """Overlay the combined mask on the scene image (red, semi-transparent)."""
199
+ img = np.array(load_image_pil(scene_path))
200
+ overlay = img.copy()
201
+ overlay[mask > 0] = [255, 80, 80]
202
+ blended = cv2.addWeighted(img, 0.55, overlay, 0.45, 0)
203
+ save_image(Image.fromarray(blended), output_path)
204
+
205
+
206
+ def save_comparison(
207
+ before: Union[Image.Image, np.ndarray],
208
+ after: Union[Image.Image, np.ndarray],
209
+ output_path: str,
210
+ labels: Tuple[str, str] = ("Before", "After"),
211
+ ) -> None:
212
+ """Save a side-by-side before/after comparison image."""
213
+ if isinstance(before, np.ndarray):
214
+ before = cv2_to_pil(before)
215
+ if isinstance(after, np.ndarray):
216
+ after = cv2_to_pil(after)
217
+
218
+ w = before.width + after.width + 20
219
+ h = max(before.height, after.height) + 40
220
+ canvas = Image.new("RGB", (w, h), (30, 30, 30))
221
+ canvas.paste(before, (0, 40))
222
+ canvas.paste(after, (before.width + 20, 40))
223
+
224
+ # draw labels using matplotlib to avoid font dependency
225
+ fig, axes = plt.subplots(1, 2, figsize=(14, 7))
226
+ axes[0].imshow(before); axes[0].set_title(labels[0], fontsize=14); axes[0].axis("off")
227
+ axes[1].imshow(after); axes[1].set_title(labels[1], fontsize=14); axes[1].axis("off")
228
+ plt.tight_layout()
229
+ plt.savefig(output_path, dpi=150, bbox_inches="tight")
230
+ plt.close()
231
+
232
+
233
+ # -- Checkpoint downloader -----------------------------------------------------
234
+
235
+ def download_file(url: str, dest: str, desc: str = "") -> None:
236
+ """Download a file with a progress bar."""
237
+ os.makedirs(os.path.dirname(dest) or ".", exist_ok=True)
238
+ if os.path.exists(dest):
239
+ print(f" [DONE] Already downloaded: {os.path.basename(dest)}")
240
+ return
241
+
242
+ print(f" v Downloading {desc or os.path.basename(dest)} ...")
243
+ response = requests.get(url, stream=True, timeout=120)
244
+ response.raise_for_status()
245
+
246
+ total = int(response.headers.get("content-length", 0))
247
+ with open(dest, "wb") as f, tqdm(
248
+ total=total, unit="B", unit_scale=True, desc=desc or os.path.basename(dest)
249
+ ) as bar:
250
+ for chunk in response.iter_content(chunk_size=8192):
251
+ f.write(chunk)
252
+ bar.update(len(chunk))
253
+
254
+
255
+ def download_text_file(url: str, dest: str) -> None:
256
+ """Download a small text/config file."""
257
+ os.makedirs(os.path.dirname(dest) or ".", exist_ok=True)
258
+ if os.path.exists(dest):
259
+ return
260
+ print(f" Fetching config: {os.path.basename(dest)} ...")
261
+ resp = requests.get(url, timeout=30)
262
+ resp.raise_for_status()
263
+ with open(dest, "w") as f:
264
+ f.write(resp.text)
265
+
266
+
267
+ def unzip(zip_path: str, dest_dir: str) -> None:
268
+ print(f" -> Extracting {os.path.basename(zip_path)} ...")
269
+ with zipfile.ZipFile(zip_path, "r") as z:
270
+ z.extractall(dest_dir)
inpainter_lama.py ADDED
@@ -0,0 +1,299 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ inpainter_lama.py — big-lama PyTorch inpainter
3
+ Architecture built to match checkpoint indices exactly.
4
+ """
5
+
6
+ import os, sys, warnings
7
+ import cv2
8
+ import numpy as np
9
+ import torch
10
+ import torch.nn as nn
11
+ import torch.nn.functional as F
12
+ from PIL import Image
13
+
14
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
15
+ from config import CHECKPOINT_DIR, MASK_DILATION_PX, DEVICE
16
+
17
+ LAMA_CKPT = os.path.join(CHECKPOINT_DIR, "big-lama", "models", "best.ckpt")
18
+
19
+
20
+ # ── FFC primitives ────────────────────────────────────────────────────────────
21
+
22
+ class FourierUnit(nn.Module):
23
+ def __init__(self, in_ch, out_ch):
24
+ super().__init__()
25
+ self.conv_layer = nn.Conv2d(in_ch * 2, out_ch * 2, 1, bias=False)
26
+ self.bn = nn.BatchNorm2d(out_ch * 2)
27
+ self.relu = nn.ReLU(inplace=True)
28
+
29
+ def forward(self, x):
30
+ b, c, h, w = x.shape
31
+ f = torch.fft.rfftn(x, dim=(-2,-1), norm="ortho")
32
+ f = torch.stack([f.real, f.imag], -1).permute(0,1,4,2,3).contiguous()
33
+ f = f.view(b, -1, f.shape[-2], f.shape[-1])
34
+ f = self.relu(self.bn(self.conv_layer(f)))
35
+ f = f.view(b, -1, 2, f.shape[-2], f.shape[-1]).permute(0,1,3,4,2).contiguous()
36
+ f = torch.view_as_complex(f)
37
+ return torch.fft.irfftn(f, s=(h,w), dim=(-2,-1), norm="ortho")
38
+
39
+
40
+ class SpectralTransform(nn.Module):
41
+ def __init__(self, in_ch, out_ch, stride=1):
42
+ super().__init__()
43
+ self.downsample = nn.AvgPool2d(2) if stride == 2 else nn.Identity()
44
+ self.conv1 = nn.Sequential(
45
+ nn.Conv2d(in_ch, out_ch//2, 1, bias=False),
46
+ nn.BatchNorm2d(out_ch//2), nn.ReLU(inplace=True))
47
+ self.fu = FourierUnit(out_ch//2, out_ch//2)
48
+ self.conv2 = nn.Conv2d(out_ch//2, out_ch, 1, bias=False)
49
+
50
+ def forward(self, x):
51
+ return self.conv2(self.fu(self.conv1(self.downsample(x))))
52
+
53
+
54
+ class FFC(nn.Module):
55
+ def __init__(self, in_ch, out_ch, ksize, ratio_gin, ratio_gout, stride=1, pad=0):
56
+ super().__init__()
57
+ in_cg = int(in_ch * ratio_gin); in_cl = in_ch - in_cg
58
+ out_cg = int(out_ch * ratio_gout); out_cl = out_ch - out_cg
59
+ self.in_cg = in_cg
60
+ self.convl2l = nn.Conv2d(in_cl, out_cl, ksize, stride, pad, bias=False) if in_cl>0 and out_cl>0 else None
61
+ self.convl2g = nn.Conv2d(in_cl, out_cg, ksize, stride, pad, bias=False) if in_cl>0 and out_cg>0 else None
62
+ self.convg2l = nn.Conv2d(in_cg, out_cl, ksize, stride, pad, bias=False) if in_cg>0 and out_cl>0 else None
63
+ self.convg2g = SpectralTransform(in_cg, out_cg, stride) if in_cg>0 and out_cg>0 else None
64
+
65
+ def forward(self, x):
66
+ xl, xg = x if isinstance(x, tuple) else (x, None)
67
+ yl_parts = [f(t) for f, t in [(self.convl2l, xl), (self.convg2l, xg)] if f is not None and t is not None]
68
+ yg_parts = [f(t) for f, t in [(self.convl2g, xl), (self.convg2g, xg)] if f is not None and t is not None]
69
+ yl = yl_parts[0] + yl_parts[1] if len(yl_parts) == 2 else (yl_parts[0] if yl_parts else torch.zeros_like(xl))
70
+ yg = yg_parts[0] + yg_parts[1] if len(yg_parts) == 2 else (yg_parts[0] if yg_parts else None)
71
+ return yl, yg
72
+
73
+
74
+ class FFCBNAct(nn.Module):
75
+ def __init__(self, in_ch, out_ch, ksize, ratio_gin, ratio_gout, stride=1, pad=0):
76
+ super().__init__()
77
+ out_cl = int(out_ch*(1-ratio_gout)); out_cg = out_ch - out_cl
78
+ self.ffc = FFC(in_ch, out_ch, ksize, ratio_gin, ratio_gout, stride, pad)
79
+ self.bn_l = nn.BatchNorm2d(out_cl) if out_cl>0 else None
80
+ self.bn_g = nn.BatchNorm2d(out_cg) if out_cg>0 else None
81
+
82
+ def forward(self, x):
83
+ xl, xg = self.ffc(x)
84
+ if self.bn_l: xl = F.relu(self.bn_l(xl), inplace=True)
85
+ if self.bn_g and xg is not None: xg = F.relu(self.bn_g(xg), inplace=True)
86
+ return xl, xg
87
+
88
+
89
+ class FFCResBlock(nn.Module):
90
+ def __init__(self, dim, ratio_gin, ratio_gout):
91
+ super().__init__()
92
+ self.conv1 = FFCBNAct(dim, dim, 3, ratio_gin, ratio_gout, pad=1)
93
+ self.conv2 = FFCBNAct(dim, dim, 3, ratio_gout, ratio_gout, pad=1)
94
+
95
+ def forward(self, x):
96
+ xl, xg = x if isinstance(x, tuple) else (x, None)
97
+ rl, rg = xl, xg
98
+ xl, xg = self.conv1((xl, xg))
99
+ xl, xg = self.conv2((xl, xg))
100
+ xl = xl + rl
101
+ if xg is not None and rg is not None: xg = xg + rg
102
+ return xl, xg
103
+
104
+
105
+ # ── Generator — indices match checkpoint exactly ──────────────────────────────
106
+ # Index map from checkpoint:
107
+ # 0 = ReflectionPad2d(3) [no params]
108
+ # 1 = FFCBNAct(4→64, k7, 0→0)
109
+ # 2 = FFCBNAct(64→128, k3, 0→0, stride=2)
110
+ # 3 = FFCBNAct(128→256,k3, 0→0, stride=2)
111
+ # 4 = FFCBNAct(256→512,k3, 0→0.75, stride=2) 128 local + 384 global
112
+ # 5-22 = FFCResBlock(512, 0.75→0.75) × 18
113
+ # 23 = ReLU [no params]
114
+ # 24 = ConvTranspose2d(512→256)
115
+ # 25 = BatchNorm2d(256)
116
+ # 26 = ReLU [no params]
117
+ # 27 = ConvTranspose2d(256→128)
118
+ # 28 = BatchNorm2d(128)
119
+ # 29 = ReLU [no params]
120
+ # 30 = ConvTranspose2d(128→64)
121
+ # 31 = BatchNorm2d(64)
122
+ # 32 = ReLU [no params]
123
+ # 33 = ReflectionPad2d(3) [no params]
124
+ # 34 = Conv2d(64→3, k7)
125
+ # 35 = Sigmoid [no params]
126
+
127
+ class LaMaGenerator(nn.Module):
128
+ def __init__(self):
129
+ super().__init__()
130
+ ngf = 64
131
+ self.model = nn.ModuleDict({
132
+ "1": FFCBNAct(4, ngf, 7, 0, 0, pad=0),
133
+ "2": FFCBNAct(ngf, ngf*2, 3, 0, 0, stride=2, pad=1),
134
+ "3": FFCBNAct(ngf*2, ngf*4, 3, 0, 0, stride=2, pad=1),
135
+ "4": FFCBNAct(ngf*4, ngf*8, 3, 0, 0.75, stride=2, pad=1),
136
+ **{str(i): FFCResBlock(ngf*8, 0.75, 0.75) for i in range(5, 23)},
137
+ "24": nn.ConvTranspose2d(ngf*8, ngf*4, 3, stride=2, padding=1, output_padding=1),
138
+ "25": nn.BatchNorm2d(ngf*4),
139
+ "27": nn.ConvTranspose2d(ngf*4, ngf*2, 3, stride=2, padding=1, output_padding=1),
140
+ "28": nn.BatchNorm2d(ngf*2),
141
+ "30": nn.ConvTranspose2d(ngf*2, ngf, 3, stride=2, padding=1, output_padding=1),
142
+ "31": nn.BatchNorm2d(ngf),
143
+ "34": nn.Conv2d(ngf, 3, 7, padding=0),
144
+ })
145
+
146
+ def forward(self, x):
147
+ x = F.pad(x, (3,3,3,3), mode="reflect") # idx 0
148
+ x = self.model["1"](x) # FFCBNAct → tuple
149
+ x = self.model["2"](x)
150
+ x = self.model["3"](x)
151
+ x = self.model["4"](x)
152
+ for i in range(5, 23):
153
+ x = self.model[str(i)](x)
154
+ # merge local+global
155
+ xl, xg = x
156
+ x = torch.cat([xl, xg], dim=1) # 512 ch
157
+ x = F.relu(x, inplace=True) # idx 23
158
+ x = F.relu(self.model["25"](self.model["24"](x)), inplace=True)
159
+ x = F.relu(self.model["28"](self.model["27"](x)), inplace=True)
160
+ x = F.relu(self.model["31"](self.model["30"](x)), inplace=True)
161
+ x = F.pad(x, (3,3,3,3), mode="reflect") # idx 33
162
+ x = torch.sigmoid(self.model["34"](x)) # idx 34+35
163
+ return x
164
+
165
+
166
+ # ── Inpainter ─────────────────────────────────────────────────────────────────
167
+
168
+ class LamaInpainter:
169
+
170
+ def __init__(self):
171
+ self._model = None
172
+ self._session = None
173
+ self._load_pytorch()
174
+ if self._model is None:
175
+ self._load_onnx()
176
+
177
+ def _load_pytorch(self):
178
+ if not os.path.exists(LAMA_CKPT):
179
+ print(f" big-lama checkpoint not found at {LAMA_CKPT}"); return
180
+ try:
181
+ print(" Loading big-lama (PyTorch) ...")
182
+ ckpt = torch.load(LAMA_CKPT, map_location="cpu", weights_only=False)
183
+ state = ckpt.get("state_dict", ckpt)
184
+ # strip "generator." prefix
185
+ gen_state = {k[len("generator."):]: v
186
+ for k, v in state.items() if k.startswith("generator.")}
187
+ model = LaMaGenerator()
188
+ missing, unexpected = model.load_state_dict(gen_state, strict=False)
189
+ if missing: print(f" Missing keys: {missing[:3]}")
190
+ model.eval().to(DEVICE)
191
+ self._model = model
192
+ print(" big-lama loaded successfully.")
193
+ except Exception as e:
194
+ warnings.warn(f" big-lama PyTorch load failed: {e}")
195
+
196
+ def _load_onnx(self):
197
+ onnx_path = os.path.join(CHECKPOINT_DIR, "lama_fp32.onnx")
198
+ try:
199
+ import onnxruntime as ort
200
+ from huggingface_hub import hf_hub_download
201
+ if not os.path.exists(onnx_path):
202
+ print(" Downloading LaMa ONNX (~125 MB)...")
203
+ os.makedirs(CHECKPOINT_DIR, exist_ok=True)
204
+ hf_hub_download(repo_id="Carve/LaMa-ONNX", filename="lama_fp32.onnx",
205
+ local_dir=CHECKPOINT_DIR)
206
+ self._session = ort.InferenceSession(
207
+ onnx_path, providers=["CUDAExecutionProvider","CPUExecutionProvider"])
208
+ print(" LaMa ONNX loaded as fallback.")
209
+ except Exception as e:
210
+ warnings.warn(f" LaMa ONNX load failed: {e}. Will use OpenCV.")
211
+
212
+ # ── public ────────────────────────────────────────────────────────────────
213
+
214
+ def inpaint(self, image_pil: Image.Image, mask: np.ndarray) -> Image.Image:
215
+ img_np = np.array(image_pil.convert("RGB"))
216
+ if self._model is not None:
217
+ import gc
218
+ try:
219
+ return self._pytorch_inpaint(img_np, mask)
220
+ except Exception as e:
221
+ warnings.warn(f" PyTorch inpaint failed: {e}")
222
+ import torch
223
+ torch.cuda.empty_cache()
224
+ gc.collect()
225
+
226
+ # Lazy load ONNX if needed
227
+ if self._session is None:
228
+ self._load_onnx()
229
+
230
+ if self._session is not None:
231
+ try: return self._onnx_inpaint(img_np, mask)
232
+ except Exception as e: warnings.warn(f" ONNX inpaint failed: {e}")
233
+
234
+ return self._opencv_inpaint(img_np, mask)
235
+
236
+ def _pytorch_inpaint(self, img_np, mask):
237
+ h, w = img_np.shape[:2]
238
+ ys, xs = np.where(mask > 0)
239
+ if len(xs) == 0: return Image.fromarray(img_np)
240
+
241
+ x1,x2 = int(xs.min()), int(xs.max())
242
+ y1,y2 = int(ys.min()), int(ys.max())
243
+ pad = max(64, int(max(x2-x1, y2-y1)*0.3))
244
+ x1=max(0,x1-pad); y1=max(0,y1-pad)
245
+ x2=min(w,x2+pad); y2=min(h,y2+pad)
246
+
247
+ pi = img_np[y1:y2, x1:x2].copy()
248
+ pm = mask[y1:y2, x1:x2].copy()
249
+ ph, pw = pi.shape[:2]
250
+
251
+ # pad to multiple of 8
252
+ ph_p = ((ph+7)//8)*8; pw_p = ((pw+7)//8)*8
253
+ ip = np.pad(pi, ((0,ph_p-ph),(0,pw_p-pw),(0,0)))
254
+ mp = np.pad(pm, ((0,ph_p-ph),(0,pw_p-pw)))
255
+
256
+ it = torch.from_numpy(ip.astype(np.float32)/255.).permute(2,0,1).unsqueeze(0).to(DEVICE)
257
+ mt = torch.from_numpy((mp>0).astype(np.float32)).unsqueeze(0).unsqueeze(0).to(DEVICE)
258
+ it = it*(1-mt)
259
+ inp = torch.cat([it, mt], dim=1)
260
+
261
+ with torch.no_grad():
262
+ out = self._model(inp)
263
+
264
+ res = (out[0].permute(1,2,0).cpu().numpy()*255).clip(0,255).astype(np.uint8)[:ph,:pw]
265
+
266
+ out_np = img_np.copy()
267
+ mask_bool = pm > 0
268
+ out_np[y1:y2, x1:x2][mask_bool] = res[mask_bool]
269
+ return Image.fromarray(out_np)
270
+
271
+ def _onnx_inpaint(self, img_np, mask):
272
+ SZ = 512
273
+ h, w = img_np.shape[:2]
274
+ ys, xs = np.where(mask>0)
275
+ if len(xs)==0: return Image.fromarray(img_np)
276
+ x1,x2=int(xs.min()),int(xs.max()); y1,y2=int(ys.min()),int(ys.max())
277
+ px=max(40,int((x2-x1)*.25)); py=max(40,int((y2-y1)*.25))
278
+ x1=max(0,x1-px); y1=max(0,y1-py); x2=min(w,x2+px); y2=min(h,y2+py)
279
+ pi=img_np[y1:y2,x1:x2]; pm=mask[y1:y2,x1:x2]; ph,pw=pi.shape[:2]
280
+ ir=cv2.resize(pi,(SZ,SZ),interpolation=cv2.INTER_LINEAR)
281
+ mr=cv2.resize(pm,(SZ,SZ),interpolation=cv2.INTER_NEAREST)
282
+ it=(ir.astype(np.float32)/255.).transpose(2,0,1)[None]
283
+ mt=(mr>0).astype(np.float32)[None,None]
284
+ n0=self._session.get_inputs()[0].name; n1=self._session.get_inputs()[1].name
285
+ o0=self._session.get_outputs()[0].name
286
+ res=self._session.run([o0],{n0:it,n1:mt})[0][0]
287
+ res=(res.transpose(1,2,0)*(255. if res.max()<=1.5 else 1.)).clip(0,255).astype(np.uint8)
288
+ res=cv2.resize(res,(pw,ph),interpolation=cv2.INTER_LINEAR)
289
+ out=img_np.copy()
290
+ mask_bool = pm > 0
291
+ out[y1:y2,x1:x2][mask_bool] = res[mask_bool]
292
+ return Image.fromarray(out)
293
+
294
+ @staticmethod
295
+ def _opencv_inpaint(img_np, mask):
296
+ print(" (OpenCV NS fallback)")
297
+ bgr=cv2.cvtColor(img_np,cv2.COLOR_RGB2BGR)
298
+ res=cv2.inpaint(bgr,(mask>0).astype(np.uint8)*255,5,cv2.INPAINT_NS)
299
+ return Image.fromarray(cv2.cvtColor(res,cv2.COLOR_BGR2RGB))
pipeline.py ADDED
@@ -0,0 +1,259 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ pipeline.py
3
+ ------------
4
+ Core orchestrator -- ties together detection, segmentation, and inpainting.
5
+
6
+ Usage (programmatic):
7
+ from pipeline import ObjectRemovalPipeline
8
+ pipe = ObjectRemovalPipeline()
9
+ result = pipe.run(scene_path, object_paths)
10
+ result.save("output.png")
11
+
12
+ Usage (CLI):
13
+ python pipeline.py --scene input/scene/room.jpg \
14
+ --objects input/objects/chair.jpg input/objects/lamp.jpg
15
+ """
16
+
17
+ import os
18
+ import sys
19
+ import argparse
20
+ from pathlib import Path
21
+ from typing import List, Optional, Tuple
22
+
23
+ import numpy as np
24
+ from PIL import Image
25
+
26
+ from config import (
27
+ OUTPUT_DIR, INPAINT_METHOD, SAVE_DEBUG_IMAGES,
28
+ CLIP_SIMILARITY_THRESHOLD, MASK_DILATION_PX,
29
+ )
30
+ from clip_matcher import CLIPMatcher
31
+ from detector import GroundingDINODetector
32
+ from segmenter import SAMSegmenter
33
+ from image_utils import (
34
+ load_image_pil, save_image,
35
+ boxes_to_mask, combine_masks,
36
+ save_detection_debug, save_mask_debug, save_comparison,
37
+ list_images,
38
+ )
39
+
40
+
41
+ # -- Inpainter factory ---------------------------------------------------------
42
+
43
+ def _make_inpainter():
44
+ if INPAINT_METHOD == "lama":
45
+ from inpainter_lama import LamaInpainter
46
+ return LamaInpainter()
47
+ elif INPAINT_METHOD == "sd_inpaint":
48
+ from inpainter_sd import SDInpainter
49
+ return SDInpainter()
50
+ else:
51
+ raise ValueError(f"Unknown INPAINT_METHOD: {INPAINT_METHOD!r}")
52
+
53
+
54
+ # -- Pipeline ------------------------------------------------------------------
55
+
56
+ class ObjectRemovalPipeline:
57
+ """
58
+ End-to-end object removal:
59
+ reference images CLIP label GroundingDINO detection
60
+ CLIP similarity filter SAM segmentation inpainting
61
+ """
62
+
63
+ def __init__(self) -> None:
64
+ self.clip = CLIPMatcher()
65
+ self.detector = GroundingDINODetector()
66
+ self.segmenter = SAMSegmenter()
67
+ self.inpainter = _make_inpainter()
68
+
69
+ # -- Public entry point ----------------------------------------------------
70
+
71
+ def run(
72
+ self,
73
+ scene_path: str,
74
+ object_paths: List[str],
75
+ output_dir: str = OUTPUT_DIR,
76
+ save_debug: bool = SAVE_DEBUG_IMAGES,
77
+ threshold: Optional[float] = None,
78
+ ) -> Image.Image:
79
+ """
80
+ Remove all objects specified by `object_paths` from `scene_path`.
81
+
82
+ Returns the inpainted PIL image and writes it to `output_dir`.
83
+ """
84
+ os.makedirs(output_dir, exist_ok=True)
85
+ stem = Path(scene_path).stem
86
+
87
+ print(f"\n{'='*60}")
88
+ print(f" Scene : {scene_path}")
89
+ print(f" Objects ({len(object_paths)}): {[os.path.basename(p) for p in object_paths]}")
90
+ print(f"{'='*60}")
91
+
92
+ scene_pil = load_image_pil(scene_path)
93
+ w, h = scene_pil.size
94
+
95
+ # -- Stage 1: detect & match each reference object -----------------
96
+ all_boxes = []
97
+ all_dets = [] # for debug visualisation
98
+
99
+ for obj_path in object_paths:
100
+ print(f"\n-- Object: {os.path.basename(obj_path)}")
101
+ obj_pil = load_image_pil(obj_path)
102
+
103
+ # 1a. Generate text prompt via CLIP zero-shot classification
104
+ label = self.clip.predict_label(obj_pil)
105
+
106
+ # 1b. Detect in scene with GroundingDINO
107
+ dets = self.detector.detect(scene_pil, label)
108
+ candidate_boxes = [d["box"] for d in dets]
109
+ all_dets.extend(dets)
110
+
111
+ if not candidate_boxes:
112
+ print(f" [!] No candidates found for '{label}' -- skipping")
113
+ continue
114
+
115
+ # 1c. Filter by CLIP image-image similarity
116
+ target_threshold = threshold if threshold is not None else CLIP_SIMILARITY_THRESHOLD
117
+ accepted = self.clip.filter_boxes_by_similarity(
118
+ obj_pil, scene_pil, candidate_boxes, threshold=target_threshold
119
+ )
120
+
121
+ if not accepted:
122
+ print(f" [!] No boxes passed similarity threshold -- skipping")
123
+ else:
124
+ print(f" [v] {len(accepted)} box(es) accepted")
125
+ all_boxes.extend(accepted)
126
+
127
+ if not all_boxes:
128
+ print("\n[!] No objects detected. Returning original image.")
129
+ return scene_pil
130
+
131
+ # -- Debug: save detection visualisation ---------------------------
132
+ if save_debug:
133
+ det_path = os.path.join(output_dir, f"{stem}_debug_detections.jpg")
134
+ save_detection_debug(scene_path, all_dets, det_path)
135
+ print(f"\n [debug] detection image {det_path}")
136
+
137
+ # -- Stage 2: SAM segmentation -------------------------------------
138
+ print(f"\n-- Segmentation ({len(all_boxes)} box(es))")
139
+ combined_mask = self.segmenter.segment_boxes(scene_pil, all_boxes)
140
+
141
+ if save_debug:
142
+ mask_path = os.path.join(output_dir, f"{stem}_debug_mask.jpg")
143
+ save_mask_debug(scene_path, combined_mask, mask_path)
144
+ print(f" [debug] mask image {mask_path}")
145
+
146
+ # -- Stage 3: Inpainting -------------------------------------------
147
+ print(f"\n-- Inpainting ({INPAINT_METHOD})")
148
+ result_pil = self.inpainter.inpaint(scene_pil, combined_mask)
149
+
150
+ # -- Save outputs --------------------------------------------------
151
+ out_path = os.path.join(output_dir, f"{stem}_result.png")
152
+ result_pil.save(out_path)
153
+ print(f"\n [DONE] Result saved {out_path}")
154
+
155
+ if save_debug:
156
+ cmp_path = os.path.join(output_dir, f"{stem}_comparison.jpg")
157
+ save_comparison(scene_pil, result_pil, cmp_path,
158
+ labels=("Original", "Objects Removed"))
159
+ print(f" [debug] comparison {cmp_path}")
160
+
161
+ return result_pil
162
+
163
+ # -- Batch helper ----------------------------------------------------------
164
+
165
+ def run_batch(
166
+ self,
167
+ scene_dir: str,
168
+ objects_dir: str,
169
+ output_dir: str = OUTPUT_DIR,
170
+ ) -> None:
171
+ """
172
+ Process all images in `scene_dir`, removing all objects found in
173
+ `objects_dir`.
174
+ """
175
+ scenes = list_images(scene_dir)
176
+ objects = list_images(objects_dir)
177
+
178
+ if not scenes:
179
+ print(f"No images found in scene directory: {scene_dir}")
180
+ return
181
+ if not objects:
182
+ print(f"No object images found in: {objects_dir}")
183
+ return
184
+
185
+ print(f"Batch: {len(scenes)} scene(s) {len(objects)} object reference(s)")
186
+ for scene_path in scenes:
187
+ self.run(scene_path, objects, output_dir=output_dir)
188
+
189
+
190
+ # -- CLI -----------------------------------------------------------------------
191
+
192
+ def _parse_args():
193
+ parser = argparse.ArgumentParser(
194
+ description="Object Removal Pipeline -- remove specific objects from a scene image."
195
+ )
196
+ group = parser.add_mutually_exclusive_group(required=True)
197
+ group.add_argument(
198
+ "--scene", type=str,
199
+ help="Path to the scene image."
200
+ )
201
+ group.add_argument(
202
+ "--scene-dir", type=str,
203
+ help="Directory of scene images (batch mode)."
204
+ )
205
+ parser.add_argument(
206
+ "--objects", type=str, nargs="+",
207
+ help="One or more paths to reference object images."
208
+ )
209
+ parser.add_argument(
210
+ "--objects-dir", type=str,
211
+ help="Directory of object images (alternative to --objects)."
212
+ )
213
+ parser.add_argument(
214
+ "--output-dir", type=str, default=OUTPUT_DIR,
215
+ help=f"Output directory (default: {OUTPUT_DIR})."
216
+ )
217
+ parser.add_argument(
218
+ "--inpaint", choices=["lama", "sd_inpaint"],
219
+ default=None,
220
+ help="Override inpainting method from config."
221
+ )
222
+ parser.add_argument(
223
+ "--no-debug", action="store_true",
224
+ help="Skip saving debug images."
225
+ )
226
+ return parser.parse_args()
227
+
228
+
229
+ def main():
230
+ args = _parse_args()
231
+
232
+ # Override config if flags given
233
+ if args.inpaint:
234
+ import config
235
+ config.INPAINT_METHOD = args.inpaint
236
+
237
+ pipe = ObjectRemovalPipeline()
238
+
239
+ # Resolve object paths
240
+ if args.objects:
241
+ object_paths = args.objects
242
+ elif args.objects_dir:
243
+ object_paths = list_images(args.objects_dir)
244
+ else:
245
+ print("Error: provide --objects or --objects-dir")
246
+ sys.exit(1)
247
+
248
+ save_debug = not args.no_debug
249
+
250
+ if args.scene:
251
+ pipe.run(args.scene, object_paths,
252
+ output_dir=args.output_dir, save_debug=save_debug)
253
+ elif args.scene_dir:
254
+ pipe.run_batch(args.scene_dir, args.objects_dir or "",
255
+ output_dir=args.output_dir)
256
+
257
+
258
+ if __name__ == "__main__":
259
+ main()
requirements.txt ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # torch + torchvision are already in the Docker base image (pytorch/pytorch:2.1.2-cuda12.1)
2
+ # Do NOT add torch here - it will conflict with the pre-installed CUDA version
3
+ transformers==4.38.2
4
+ diffusers>=0.24.0
5
+ accelerate>=0.24.0
6
+ opencv-python>=4.8.0
7
+ Pillow>=10.0.0
8
+ numpy>=1.24.0
9
+ scipy>=1.11.0
10
+ scikit-image>=0.21.0
11
+ matplotlib>=3.7.0
12
+ tqdm>=4.66.0
13
+ requests>=2.31.0
14
+ huggingface-hub>=0.19.0
15
+ gradio>=4.0.0
16
+ onnxruntime>=1.16.0
17
+ # segment-anything and groundingdino are installed via git in Dockerfile
segmenter.py ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ models/segmenter.py
3
+ --------------------
4
+ SAM (Segment Anything Model) wrapper.
5
+
6
+ Given a scene image and one or more bounding boxes (from the detector),
7
+ produces precise pixel-level masks for each detected object.
8
+ """
9
+
10
+ import os
11
+ import sys
12
+ from typing import List, Tuple, Optional
13
+
14
+ import numpy as np
15
+ import torch
16
+ from PIL import Image
17
+
18
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
19
+ from config import DEVICE, SAM_CHECKPOINT, SAM_MODEL_TYPE, MASK_DILATION_PX
20
+ from image_utils import load_image, save_image, show_mask, show_box, dilate_mask_with_sam_prediction, dilate_mask, combine_masks
21
+
22
+
23
+ class SAMSegmenter:
24
+ """
25
+ Wraps SAM to convert bounding boxes into fine-grained masks.
26
+ Loaded lazily on first use.
27
+ """
28
+
29
+ def __init__(self) -> None:
30
+ self._predictor = None
31
+
32
+ def _load(self) -> None:
33
+ if self._predictor is not None:
34
+ self._predictor.model.to(DEVICE)
35
+ return
36
+
37
+ print(" Loading SAM (this may take a moment) ...")
38
+ try:
39
+ from segment_anything import sam_model_registry, SamPredictor
40
+ sam = sam_model_registry[SAM_MODEL_TYPE](checkpoint=SAM_CHECKPOINT)
41
+ sam = sam.to(DEVICE)
42
+ self._predictor = SamPredictor(sam)
43
+ except ImportError as e:
44
+ raise RuntimeError(
45
+ "segment-anything is not installed.\n"
46
+ "Run: pip install git+https://github.com/facebookresearch/segment-anything.git\n"
47
+ f"Original error: {e}"
48
+ )
49
+
50
+ def segment_boxes(
51
+ self,
52
+ image_pil: Image.Image,
53
+ boxes: List[Tuple[int, int, int, int]],
54
+ dilation_px: int = MASK_DILATION_PX,
55
+ ) -> np.ndarray:
56
+ """
57
+ For each box, run SAM and return the combined binary mask (HW, uint8).
58
+
59
+ Args:
60
+ image_pil: The scene image.
61
+ boxes: List of (x1, y1, x2, y2) in absolute pixels.
62
+ dilation_px: How many pixels to dilate the final mask (covers edges).
63
+
64
+ Returns:
65
+ Combined mask (255 = object, 0 = background).
66
+ """
67
+ self._load()
68
+
69
+ img_np = np.array(image_pil)
70
+ h, w = img_np.shape[:2]
71
+
72
+ self._predictor.set_image(img_np)
73
+
74
+ individual_masks = []
75
+ for box in boxes:
76
+ x1, y1, x2, y2 = box
77
+ box_np = np.array([[x1, y1, x2, y2]], dtype=np.float32)
78
+
79
+ masks, scores, _ = self._predictor.predict(
80
+ box=box_np,
81
+ multimask_output=True,
82
+ )
83
+ # scores shape: (3,); masks shape: (3, H, W)
84
+ best_idx = scores.argmax()
85
+ best_mask = (masks[best_idx].astype(np.uint8)) * 255
86
+ individual_masks.append(best_mask)
87
+
88
+ if not individual_masks:
89
+ return np.zeros((h, w), dtype=np.uint8)
90
+
91
+ combined = combine_masks(individual_masks)
92
+ if dilation_px > 0:
93
+ combined = dilate_mask(combined, dilation_px)
94
+
95
+ pct = 100 * (combined > 0).sum() / (h * w)
96
+ print(f" SAM mask covers {pct:.1f}% of the image")
97
+ return combined
98
+
99
+ def segment_points(
100
+ self,
101
+ image_pil: Image.Image,
102
+ points: List[Tuple[int, int]],
103
+ point_labels: Optional[List[int]] = None,
104
+ dilation_px: int = MASK_DILATION_PX,
105
+ ) -> np.ndarray:
106
+ """
107
+ Segment using foreground point prompts (1 = foreground, 0 = background).
108
+ Falls back to all-foreground if point_labels is None.
109
+ """
110
+ self._load()
111
+
112
+ img_np = np.array(image_pil)
113
+ h, w = img_np.shape[:2]
114
+
115
+ self._predictor.set_image(img_np)
116
+
117
+ pts_np = np.array(points, dtype=np.float32)
118
+ labels_np = np.array(
119
+ point_labels if point_labels else [1] * len(points), dtype=np.int32
120
+ )
121
+
122
+ masks, scores, _ = self._predictor.predict(
123
+ point_coords=pts_np,
124
+ point_labels=labels_np,
125
+ multimask_output=True,
126
+ )
127
+ best_idx = scores.argmax()
128
+ best_mask = (masks[best_idx].astype(np.uint8)) * 255
129
+
130
+ if dilation_px > 0:
131
+ best_mask = dilate_mask(best_mask, dilation_px)
132
+
133
+ return best_mask