Spaces:
Runtime error
Runtime error
Upload 11 files
Browse files- Dockerfile +55 -0
- app.py +92 -0
- clip_matcher.py +145 -0
- config.py +75 -0
- detector.py +124 -0
- download_checkpoints.py +65 -0
- image_utils.py +270 -0
- inpainter_lama.py +299 -0
- pipeline.py +259 -0
- requirements.txt +17 -0
- 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
|