File size: 16,352 Bytes
4cbb825
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1fba98a
 
 
4cbb825
 
 
1fba98a
 
 
 
 
 
 
4cbb825
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1fba98a
4cbb825
 
 
 
 
1fba98a
 
 
4cbb825
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
import os

os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")

import spaces  # noqa: E402  (must come before torch / CUDA-touching imports)
import math
import time
import random

import numpy as np
import torch
import gradio as gr
from PIL import Image

from direct import DirectPipeline

# ----------------------------------------------------------------------------
# Config
# ----------------------------------------------------------------------------
MODEL_INPUT_RESOLUTION = 1024
DIRECT_MODEL_PATH = "superGong/DIRECT"
FLUX_MODEL_PATH = "black-forest-labs/FLUX.1-Fill-dev"
SIGLIP_MODEL_PATH = "google/siglip2-so400m-patch14-384"

HF_TOKEN = os.environ.get("HF_TOKEN")

# ----------------------------------------------------------------------------
# Load models at module scope (ZeroGPU packs weights to disk after this)
# ----------------------------------------------------------------------------
print("Loading DIRECT pipeline (FLUX.1-Fill-dev + SigLIP2 + DIRECT adapters)...")
direct_pipeline = DirectPipeline.from_pretrained(
    direct_model_path=DIRECT_MODEL_PATH,
    flux_model_path=FLUX_MODEL_PATH,
    siglip_model_path=SIGLIP_MODEL_PATH,
    device=torch.device("cuda"),
    torch_dtype=torch.bfloat16,
    token=HF_TOKEN,
)
print("DIRECT pipeline loaded.")

# Background remover for the object image (ungated). Loaded lazily/cheaply.
_rembg_session = None


def _get_rembg_session():
    global _rembg_session
    if _rembg_session is None:
        from rembg import new_session

        _rembg_session = new_session("u2net")
    return _rembg_session


# ----------------------------------------------------------------------------
# Image-preparation helpers (2D proxy construction).
#
# The full DIRECT paper uses an interactive 3D viewer (TRELLIS + Viser) to let
# users pose a reconstructed 3D proxy of the object. That live 3D websocket
# viewer cannot run inside a single-port HF Space, so here we build the model's
# geometric-guidance inputs from a simple 2D placement (position + scale). The
# underlying DIRECT model (real weights) then performs the 3D-aware harmonized
# insertion. See the notes in the UI for this limitation.
# ----------------------------------------------------------------------------

def segment_object(object_rgb: Image.Image) -> Image.Image:
    """Return an RGBA image of the object with background removed."""
    from rembg import remove

    rgba = remove(object_rgb.convert("RGB"), session=_get_rembg_session())
    return rgba.convert("RGBA")


def _tight_crop_rgba(rgba: Image.Image) -> Image.Image:
    alpha = np.array(rgba.split()[-1])
    ys, xs = np.where(alpha > 10)
    if ys.size == 0:
        return rgba
    y1, y2, x1, x2 = ys.min(), ys.max() + 1, xs.min(), xs.max() + 1
    return rgba.crop((x1, y1, x2, y2))


def center_reference(rgba: Image.Image, out_size: int = MODEL_INPUT_RESOLUTION) -> Image.Image:
    """Object centered on black, square, with ~1.2 margin (model reference input)."""
    obj = _tight_crop_rgba(rgba)
    w, h = obj.size
    side = max(int(math.ceil(max(w, h) * 1.2)), 1)
    canvas = Image.new("RGB", (side, side), (0, 0, 0))
    canvas.paste(obj, ((side - w) // 2, (side - h) // 2), obj)
    return canvas.resize((out_size, out_size), Image.LANCZOS)


def place_object(bg: Image.Image, obj_rgba: Image.Image, cx: float, cy: float, scale: float):
    """Paste the (tight-cropped) object onto a copy of the background.

    cx, cy in [0, 1] (center), scale in [0, 1] (object longest side as a
    fraction of the background's longest side). Returns (placed_rgb, mask_L).
    """
    bg = bg.convert("RGB")
    W, H = bg.size
    obj = _tight_crop_rgba(obj_rgba)
    ow, oh = obj.size
    target_long = max(1, int(scale * max(W, H)))
    ratio = target_long / max(ow, oh)
    new_w = max(1, int(ow * ratio))
    new_h = max(1, int(oh * ratio))
    obj_r = obj.resize((new_w, new_h), Image.LANCZOS)

    center_x = int(cx * W)
    center_y = int(cy * H)
    x0 = center_x - new_w // 2
    y0 = center_y - new_h // 2

    placed_rgb = bg.copy()
    placed_rgb.paste(obj_r, (x0, y0), obj_r)

    mask = Image.new("L", (W, H), 0)
    obj_alpha = obj_r.split()[-1]
    mask.paste(obj_alpha, (x0, y0), obj_alpha)

    # Geometry proxy: the object RGB on a black canvas at its placed location.
    geometry_full = Image.new("RGB", (W, H), (0, 0, 0))
    geometry_full.paste(obj_r, (x0, y0), obj_r)

    return placed_rgb, mask, geometry_full


def get_mask_bbox(mask_pil, threshold=20):
    arr = np.array(mask_pil)
    ys, xs = np.where(arr > threshold)
    if ys.size == 0:
        return None
    return (xs.min(), ys.min(), xs.max() + 1, ys.max() + 1)


def get_smart_crop_bbox(mask_pil, min_ratio=0.02, max_ratio=0.3):
    bbox = get_mask_bbox(mask_pil)
    if bbox is None:
        s = MODEL_INPUT_RESOLUTION
        return (0, 0, s, s), s
    min_x, min_y, max_x, max_y = bbox
    mask_w, mask_h = max_x - min_x, max_y - min_y
    area = mask_w * mask_h
    side = int(math.sqrt(area / ((min_ratio + max_ratio) / 2.0)))
    side = max(side, max(mask_w, mask_h) + 40)
    cx = (min_x + max_x) // 2
    cy = (min_y + max_y) // 2
    half = side // 2
    return (cx - half, cy - half, cx - half + side, cy - half + side), side


def crop_and_pad(image, bbox, target_side):
    x1, y1, x2, y2 = bbox
    W, H = image.size
    valid = image.crop((max(0, x1), max(0, y1), min(W, x2), min(H, y2)))
    canvas = Image.new(image.mode, (target_side, target_side), 0)
    canvas.paste(valid, (max(0, -x1), max(0, -y1)))
    return canvas


def dilate_mask(mask_np, radius=10):
    import cv2

    m = (mask_np > 0).astype(np.uint8) * 255
    k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (radius * 2 + 1, radius * 2 + 1))
    return (cv2.dilate(m, k, iterations=1) > 0).astype(np.uint8)


def refine_mask_holes(mask_bool, kernel_size=7):
    import cv2

    m = mask_bool.astype(np.uint8) * 255
    k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (kernel_size, kernel_size))
    closed = cv2.morphologyEx(m, cv2.MORPH_CLOSE, k, iterations=2)
    contours, _ = cv2.findContours(closed, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
    filled = np.zeros_like(closed)
    cv2.drawContours(filled, contours, -1, 255, thickness=cv2.FILLED)
    return filled > 127


def adain_color_fix(target_pil, source_pil, mask_pil):
    from torchvision.transforms import ToPILImage, ToTensor

    to_tensor = ToTensor()
    t = to_tensor(target_pil).unsqueeze(0)
    s = to_tensor(source_pil).unsqueeze(0)
    m = to_tensor(mask_pil).unsqueeze(0)
    eps = 1e-5
    res = t.clone()
    for ch in range(3):
        bg_idx = m[0, 0] < 0.1
        if bg_idx.sum() < 10:
            continue
        s_pix = s[0, ch][bg_idx]
        t_pix = t[0, ch][bg_idx]
        s_mean, s_std = s_pix.mean(), s_pix.std() + eps
        t_mean, t_std = t_pix.mean(), t_pix.std() + eps
        res[0, ch] = (t[0, ch] - t_mean) * (s_std / t_std) + s_mean
    return ToPILImage()(res.squeeze(0).clamp(0, 1))


def build_inputs(bg_pil, composite_full, mask_full, reference_ref, geometry_full):
    """Produce the model's 1024x1024 conditioning tensors from full-frame inputs."""
    target_res = MODEL_INPUT_RESOLUTION

    mask_np = np.array(mask_full)
    dilated01 = dilate_mask(mask_np, radius=10)
    dilated_pil = Image.fromarray(dilated01 * 255, mode="L")

    # Context image: full background with the (dilated) insertion region blacked.
    full_bg = np.array(bg_pil.convert("RGB"))
    context_image = Image.fromarray((full_bg * (1 - dilated01[:, :, None])).astype(np.uint8))

    ideal_bbox, target_side = get_smart_crop_bbox(dilated_pil)

    patch_composite = crop_and_pad(composite_full, ideal_bbox, target_side)
    patch_mask = crop_and_pad(dilated_pil, ideal_bbox, target_side)
    patch_geometry = crop_and_pad(geometry_full, ideal_bbox, target_side)
    patch_bg_ref = crop_and_pad(bg_pil.convert("RGB"), ideal_bbox, target_side)
    patch_mask_orig = crop_and_pad(Image.fromarray(mask_np), ideal_bbox, target_side)

    comp_arr = np.array(patch_composite)
    mask_dilated_arr = np.array(patch_mask) > 127
    mask_orig_arr = refine_mask_holes(np.array(patch_mask_orig) > 127, kernel_size=7)
    diff_region = mask_dilated_arr & (~mask_orig_arr)
    comp_arr[diff_region] = [0, 0, 0]
    patch_composite = Image.fromarray(comp_arr)

    composite_image = patch_composite.resize((target_res, target_res), Image.LANCZOS)
    model_input_mask = Image.fromarray(np.array(patch_mask).astype(np.uint8)).resize(
        (target_res, target_res), Image.NEAREST
    )
    geometry_image = patch_geometry.resize((target_res, target_res), Image.LANCZOS)
    background_reference_image = patch_bg_ref.resize((target_res, target_res), Image.LANCZOS)

    inpaint_mask = Image.fromarray(((np.array(model_input_mask) > 0) * 255).astype(np.uint8))

    return {
        "composite_image": composite_image,
        "inpaint_mask": inpaint_mask,
        "reference_image": reference_ref,
        "geometry_image": geometry_image,
        "context_image": context_image,
        "model_input_mask": model_input_mask,
        "background_reference_image": background_reference_image,
        "ideal_bbox": ideal_bbox,
        "target_side": target_side,
    }


def paste_back(bg_pil, generated_patch, inp):
    fixed = adain_color_fix(
        generated_patch, inp["background_reference_image"], inp["model_input_mask"]
    )
    fixed = fixed.resize((inp["target_side"], inp["target_side"]), Image.LANCZOS)
    x1, y1, x2, y2 = inp["ideal_bbox"]
    W, H = bg_pil.size
    pad_left = max(0, -x1)
    pad_top = max(0, -y1)
    valid_w = min(W, x2) - max(0, x1)
    valid_h = min(H, y2) - max(0, y1)
    patch_valid = fixed.crop((pad_left, pad_top, pad_left + valid_w, pad_top + valid_h))
    out = bg_pil.convert("RGB").copy()
    out.paste(patch_valid, (max(0, x1), max(0, y1)))
    return out


# ----------------------------------------------------------------------------
# Inference
# ----------------------------------------------------------------------------

def _estimate_duration(bg, obj, cx, cy, scale, seed, ref_scale, steps, *a, **k):
    # Measured ~12 s/step at 1024 when reference guidance is on (CFG doubles the
    # forward pass); ~half that when it is off. Plus fixed overhead for VAE /
    # rembg / cold worker init.
    try:
        steps = int(steps)
    except Exception:
        steps = 16
    try:
        ref_on = float(ref_scale) > 1.0
    except Exception:
        ref_on = True
    per_step = 12.5 if ref_on else 6.5
    return int(min(600, 45 + steps * per_step))


@spaces.GPU(duration=_estimate_duration)
def insert_object(
    bg: Image.Image,
    obj: Image.Image,
    cx: float,
    cy: float,
    scale: float,
    seed: int,
    ref_scale: float,
    steps: int,
    progress=gr.Progress(track_tqdm=True),
):
    """Insert a reference object into a background image with 3D-aware harmonization.

    Args:
        bg: Background scene image.
        obj: Reference object image (background is removed automatically).
        cx: Horizontal placement of the object center (0=left, 1=right).
        cy: Vertical placement of the object center (0=top, 1=bottom).
        scale: Object size as a fraction of the background's longest side.
        seed: Random seed for reproducibility.
        ref_scale: Reference guidance scale (identity preservation strength).
        steps: Number of inference steps.

    Returns:
        The composited image with the object inserted, and a preview of the raw
        2D placement used as geometric guidance.
    """
    if bg is None:
        raise gr.Error("Please provide a background image.")
    if obj is None:
        raise gr.Error("Please provide an object image.")

    t0 = time.perf_counter()
    bg = bg.convert("RGB")
    obj_rgba = segment_object(obj)

    reference_ref = center_reference(obj_rgba, out_size=MODEL_INPUT_RESOLUTION)
    placed_rgb, mask_full, geometry_full = place_object(bg, obj_rgba, cx, cy, scale)

    inp = build_inputs(bg, placed_rgb, mask_full, reference_ref, geometry_full)

    seed = int(seed)
    final_images = direct_pipeline(
        composite_image=inp["composite_image"],
        inpaint_mask=inp["inpaint_mask"],
        reference_image=inp["reference_image"],
        geometry_image=inp["geometry_image"],
        context_image=inp["context_image"],
        seed=seed,
        guidance_scale=30,
        num_inference_steps=int(steps),
        height=MODEL_INPUT_RESOLUTION,
        width=MODEL_INPUT_RESOLUTION,
        use_autocast=True,
        reference_guidance_scale=float(ref_scale),
    )
    generated_patch = final_images[0]
    result = paste_back(bg, generated_patch, inp)
    print(f"[insert_object] done in {time.perf_counter() - t0:.1f}s (steps={steps})")
    return result, placed_rgb


def randomize_seed():
    return random.randint(0, 2**31 - 1)


# ----------------------------------------------------------------------------
# UI
# ----------------------------------------------------------------------------
CSS = """
#col-container { max-width: 1200px; margin: 0 auto; }
.dark .gradio-container { color: var(--body-text-color); }
"""

INTRO = """
# DIRECT: 3D-Aware Object Insertion

Insert a reference **object** into a **background** scene with realistic,
harmonized results, powered by the [DIRECT](https://huggingface.co/superGong/DIRECT)
model (ICML 2026) — a FLUX.1-Fill-dev network guided by a decomposed visual proxy.

**How to use:** upload a background and an object image (its background is
removed automatically), choose *where* and *how big* to place it, then click **Insert**.

> **Note.** The full paper uses an interactive 3D viewer (TRELLIS + Viser) to pose a
> reconstructed 3D proxy of the object. That live 3D viewer cannot run inside a
> single-port Space, so this demo drives the same DIRECT model with a simpler
> **2D placement** (position + scale) as its geometric guidance.

[Paper](https://arxiv.org/abs/2606.06601) · [Project page](https://gong1130.github.io/DIRECT/) · [Code](https://github.com/Gong1130/DIRECT)
"""

with gr.Blocks(theme=gr.themes.Citrus(), css=CSS) as demo:
    with gr.Column(elem_id="col-container"):
        gr.Markdown(INTRO)
        with gr.Row():
            with gr.Column(scale=1):
                bg_input = gr.Image(label="Background image", type="pil", height=300)
                obj_input = gr.Image(label="Object image", type="pil", height=300)
                run_btn = gr.Button("Insert", variant="primary")
            with gr.Column(scale=1):
                out_result = gr.Image(label="Inserted result", type="pil", height=360)
                out_preview = gr.Image(label="2D placement (geometric guidance)", type="pil", height=240)

        with gr.Accordion("Placement & advanced settings", open=True):
            with gr.Row():
                cx = gr.Slider(0.0, 1.0, value=0.5, step=0.01, label="Horizontal position")
                cy = gr.Slider(0.0, 1.0, value=0.6, step=0.01, label="Vertical position")
                scale = gr.Slider(0.05, 0.9, value=0.35, step=0.01, label="Object size")
            with gr.Row():
                ref_scale = gr.Slider(1.0, 5.0, value=2.0, step=0.1, label="Reference guidance scale")
                steps = gr.Slider(12, 28, value=16, step=1, label="Inference steps")
                seed = gr.Number(label="Seed", value=42, precision=0)
            rand_btn = gr.Button("🎲 Randomize seed")

        gr.Examples(
            examples=[
                ["examples/bg_landscape.jpg", "examples/obj_ducks.jpg", 0.55, 0.70, 0.28, 42, 2.0, 16],
                ["examples/bg_tent.jpg", "examples/obj_dog.jpg", 0.45, 0.68, 0.30, 7, 2.0, 16],
                ["examples/bg_beach.jpg", "examples/obj_cake.jpg", 0.50, 0.72, 0.22, 123, 2.5, 16],
            ],
            inputs=[bg_input, obj_input, cx, cy, scale, seed, ref_scale, steps],
            outputs=[out_result, out_preview],
            fn=insert_object,
            cache_examples=True,
            cache_mode="lazy",
        )

    rand_btn.click(fn=randomize_seed, outputs=seed)
    run_btn.click(
        fn=insert_object,
        inputs=[bg_input, obj_input, cx, cy, scale, seed, ref_scale, steps],
        outputs=[out_result, out_preview],
        api_name="insert",
    )

if __name__ == "__main__":
    demo.launch(mcp_server=True)