File size: 21,007 Bytes
37ac762
 
c58fac8
37ac762
 
 
c58fac8
 
37ac762
 
 
c58fac8
 
37ac762
c58fac8
37ac762
c58fac8
37ac762
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c58fac8
37ac762
 
 
c58fac8
37ac762
 
 
c58fac8
37ac762
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c58fac8
37ac762
 
 
 
 
 
 
 
 
 
 
 
 
 
c58fac8
37ac762
c58fac8
 
 
 
37ac762
c58fac8
 
 
37ac762
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c3b43b4
 
 
 
 
 
37ac762
 
 
 
c58fac8
 
 
37ac762
 
 
 
 
c58fac8
37ac762
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c58fac8
37ac762
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c58fac8
37ac762
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c58fac8
37ac762
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
import base64
from io import BytesIO
import spaces
import gradio as gr
import torch
from PIL import Image, ImageChops

from diffusers import Flux2KleinPipeline

from utils import process_source, process_reference, paste_back, binarize_mask

pipe = Flux2KleinPipeline.from_pretrained(
    "black-forest-labs/FLUX.2-klein-base-4B", torch_dtype=torch.bfloat16
)
pipe.to("cuda")

pipe.load_lora_weights("LiXiY/Easy-Insert")

PROMPT = "Replace the white mask of image1 with the content in image2. Preserving the background, lighting, and surrounding elements, maintain a seamless and natural result."


# ===================== Example Data =====================

CANVAS_W, CANVAS_H = 1024, 1024

EXAMPLES = [
    ("examples/background_image/1.png", "examples/insert_mask/1.png", "examples/ref_image/1.png", "examples/ref_mask/1.png"),
    ("examples/background_image/2.png", "examples/insert_mask/2.png", "examples/ref_image/2.png", "examples/ref_mask/2.png"),
    ("examples/background_image/3.png", "examples/insert_mask/3.png", "examples/ref_image/3.png", "examples/ref_mask/3.png"),
    ("examples/background_image/4.png", "examples/insert_mask/4.png", "examples/ref_image/4.png", "examples/ref_mask/4.png"),
]


# ===================== Thumbnail HTML Generation =====================

def img_to_b64(path, size=(240, 240)):
    img = Image.open(path).convert("RGB")
    # Preserve the original aspect ratio; fit inside `size`. 2x the display
    # width (110 CSS px) so thumbnails stay sharp on HiDPI screens.
    img.thumbnail(size, Image.LANCZOS)
    buf = BytesIO()
    img.save(buf, format="PNG")
    return base64.b64encode(buf.getvalue()).decode()


def build_examples_html():
    row_pairs = [EXAMPLES[0:2], EXAMPLES[2:4]]

    html = '<div class="ex-grid">'
    for row_idx, row_examples in enumerate(row_pairs):
        html += '<div class="ex-grid-row">'
        for col_idx, (bg, mask, ref, ref_mask) in enumerate(row_examples):
            i = row_idx * 2 + col_idx
            bg_b64 = img_to_b64(bg)
            mask_b64 = img_to_b64(mask)
            ref_b64 = img_to_b64(ref)
            ref_mask_b64 = img_to_b64(ref_mask)
            html += f'''
            <div class="ex-row" onclick="(function(){{var el=document.getElementById('ex_btn_{i}');if(!el)return;var btn=el.querySelector('button')||el;btn.dispatchEvent(new MouseEvent('click',{{bubbles:true,cancelable:true}}));}})()">
                <div class="ex-label">Example {i + 1}</div>
                <div class="ex-thumbs">
                    <div class="ex-thumb-wrap">
                        <img src="data:image/png;base64,{bg_b64}" class="ex-thumb" draggable="false"/>
                        <span class="ex-thumb-sublabel">Background</span>
                    </div>
                    <div class="ex-thumb-wrap">
                        <img src="data:image/png;base64,{mask_b64}" class="ex-thumb" draggable="false"/>
                        <span class="ex-thumb-sublabel">Mask</span>
                    </div>
                    <div class="ex-thumb-wrap">
                        <img src="data:image/png;base64,{ref_b64}" class="ex-thumb" draggable="false"/>
                        <span class="ex-thumb-sublabel">Reference</span>
                    </div>
                    <div class="ex-thumb-wrap">
                        <img src="data:image/png;base64,{ref_mask_b64}" class="ex-thumb" draggable="false"/>
                        <span class="ex-thumb-sublabel">Ref Mask</span>
                    </div>
                </div>
            </div>
            '''
        html += '</div>'
    html += '</div>'
    return html


# ===================== Utility Functions =====================

def extract_mask_from_layers(layers, target_size):
    mask = Image.new("L", target_size, 0)
    for layer in layers:
        if layer is not None:
            layer_rgba = layer.convert("RGBA").resize(target_size)
            alpha = layer_rgba.split()[3]
            alpha_binary = alpha.point(lambda x: 255 if x > 0 else 0)
            mask = Image.composite(Image.new("L", target_size, 255), mask, alpha_binary)
    return mask


EDITOR_SIZE = 800
# Matches Gradio light-mode image editor background so padded examples blend in.
EDITOR_BG_COLOR = (243, 244, 246)


def fit_transform(img, editor_size=EDITOR_SIZE):
    """Display transform the fixed-canvas editor applies to `img`: scale to fit
    inside the square canvas, then center-pad. Returns (disp_size, pad)."""
    scale = min(editor_size / img.width, editor_size / img.height, 1.0)
    disp_size = (
        max(1, round(img.width * scale)),
        max(1, round(img.height * scale)),
    )
    pad = (
        (editor_size - disp_size[0]) // 2,
        (editor_size - disp_size[1]) // 2,
    )
    return disp_size, pad


def build_display_bg(bg, editor_size=EDITOR_SIZE):
    """Rebuild the padded square background the editor shows for `bg`."""
    disp_size, pad = fit_transform(bg, editor_size)
    canvas = Image.new("RGB", (editor_size, editor_size), EDITOR_BG_COLOR)
    canvas.paste(bg.convert("RGB").resize(disp_size, Image.LANCZOS), pad)
    return canvas


def images_close(a, b, tol_frac=0.001):
    """True if two RGB images are (nearly) pixel-identical."""
    if a.size != b.size:
        return False
    diff = ImageChops.difference(a.convert("RGB"), b.convert("RGB"))
    changed = sum(diff.histogram()[1:])
    return changed <= tol_frac * a.size[0] * a.size[1] * 3


def make_editor_value(bg_pil, mask_pil=None, editor_size=EDITOR_SIZE):
    """Build an ImageEditor value on a fixed square canvas.

    The background is downscaled to fit inside `editor_size` and padded to a
    square; the mask is padded the same way and sent as a real layer. This
    keeps the mask and background perfectly aligned in the frontend (both are
    800x800 and the padding is identical), while the full-resolution image and
    exact mask are kept in gr.State for inference.

    Returns (editor_value, sent_mask, transform).
    """
    bg = bg_pil.convert("RGB")
    disp_size, pad = fit_transform(bg, editor_size)
    bg_sq = build_display_bg(bg, editor_size)

    transform = {
        "editor_size": editor_size,
        "disp_size": disp_size,
        "pad": pad,
    }

    if mask_pil is None:
        # composite=None avoids saving a second, redundant image file. The editor
        # can render the background directly; if it needs a composite it will build
        # it from background + (empty) layers.
        return {"background": bg_sq, "layers": [], "composite": None}, None, transform

    mask = mask_pil.convert("L").resize(disp_size, Image.NEAREST)
    mask_sq = Image.new("L", (editor_size, editor_size), 0)
    mask_sq.paste(mask, pad)

    transparent = Image.new("RGBA", (editor_size, editor_size), (0, 0, 0, 0))
    # Semi-transparent white (alpha 153 ≈ 60%) so the background shows through
    # the loaded mask. Mask extraction binarizes alpha > 0, so this still
    # resolves to a solid mask at inference time.
    white_solid = Image.new("RGBA", (editor_size, editor_size), (255, 255, 255, 153))
    mask_layer = Image.composite(white_solid, transparent, mask_sq)

    return {"background": bg_sq, "layers": [mask_layer], "composite": None}, mask_sq, transform


def masks_close(a, b, tol_frac=0.0):
    """True if two binarized masks are pixel-identical (within tol_frac).
    The fixed-canvas PNG round-trip is exact, so we treat any non-zero
    difference as a real user edit."""
    if a.size != b.size:
        b = b.resize(a.size, Image.NEAREST)
    diff = ImageChops.difference(binarize_mask(a), binarize_mask(b))
    changed = sum(diff.histogram()[1:])
    return changed <= tol_frac * a.size[0] * a.size[1]


def load_example(idx):
    bg_path, mask_path, ref_path, ref_mask_path = EXAMPLES[idx]
    bg_img = Image.open(bg_path).convert("RGB")
    ref_img = Image.open(ref_path).convert("RGB")
    base_val, base_sent, base_transform = make_editor_value(
        bg_img, Image.open(mask_path)
    )
    ref_val, ref_sent, ref_transform = make_editor_value(
        ref_img, Image.open(ref_mask_path)
    )
    # Keep the pristine full-res image + exact mask in State. The editor value
    # is only for display; its small size avoids slow frontend re-uploads.
    base_state = (bg_img, Image.open(mask_path).convert("L"), base_sent, base_transform)
    ref_state = (ref_img, Image.open(ref_mask_path).convert("L"), ref_sent, ref_transform)
    return base_val, ref_val, base_state, ref_state


def load_ex1():
    return load_example(0)


def load_ex2():
    return load_example(1)


def load_ex3():
    return load_example(2)


def load_ex4():
    return load_example(3)


# ===================== Generation Function =====================

def resolve_source(editor_value, state):
    """Return (full_res_image, full_res_mask) for one editor.

    `state` is (image, mask, sent_mask, transform) captured when an example was
    loaded. The editor value's background is display-only, so we don't trust it
    blindly: rebuild the padded display image from the state's pristine image
    and compare it with what the editor currently shows. If they match, the
    example is still loaded and the state's full-res image (+ exact mask) is
    used. If they differ, the user uploaded a different image - the editor
    background is then the full-res original, and the brush strokes are mapped
    back through the fixed-canvas transform.
    """
    bg_ed = editor_value.get("background")
    if bg_ed is None:
        return None, None
    img_ed = bg_ed.convert("RGB")

    ed_mask = extract_mask_from_layers(
        editor_value.get("layers", []), (EDITOR_SIZE, EDITOR_SIZE)
    )

    if state is not None:
        img, full_mask, sent_mask, transform = state
        img = img.convert("RGB")
        if images_close(img_ed, build_display_bg(img)):
            if full_mask is not None:
                # Example flow: exact full-res mask is in state. If the editor's
                # layer mask still matches what we sent (user hasn't brushed or
                # erased), use the state mask directly - bit-exact with inference.py.
                if sent_mask is not None and masks_close(ed_mask, sent_mask):
                    return img, binarize_mask(full_mask)
                # User edited the example's mask (erased and/or brushed): crop the
                # padding and map the edited mask back to the original resolution.
                if transform:
                    ed_mask_bin = binarize_mask(ed_mask)
                    px, py = transform["pad"]
                    dw, dh = transform["disp_size"]
                    cropped = ed_mask_bin.crop((px, py, px + dw, py + dh))
                    return img, binarize_mask(cropped.resize(img.size, Image.NEAREST))

    # Upload flow: the editor background is the user's full-res original.
    disp_size, pad = fit_transform(img_ed)
    ed_mask_bin = binarize_mask(ed_mask)
    cropped = ed_mask_bin.crop((pad[0], pad[1], pad[0] + disp_size[0], pad[1] + disp_size[1]))
    return img_ed, binarize_mask(cropped.resize(img_ed.size, Image.NEAREST))

@spaces.GPU
def run_local(base, ref, base_state, ref_state, seed, num_inference_steps, cfg_scale):
    if base is None or ref is None or not isinstance(base, dict) or not isinstance(ref, dict):
        return None, gr.update(visible=False)

    pil_bg, pil_mask = resolve_source(base, base_state)
    pil_ref, pil_ref_mask = resolve_source(ref, ref_state)
    if pil_bg is None or pil_ref is None:
        return None, gr.update(visible=False)

    if pil_mask.getextrema() == (0, 0) or pil_ref_mask.getextrema() == (0, 0):
        error_html = """
            <div class="error-overlay" style="
                position: fixed; top: 0; left: 0; width: 100%; height: 100%;
                background: rgba(0,0,0,0.5); display: flex; justify-content: center;
                align-items: center; z-index: 9999;
            ">
                <div style="
                    background: white; padding: 30px; border-radius: 10px;
                    text-align: center; font-size: 18px; box-shadow: 0 0 15px rgba(0,0,0,0.3);
                ">
                    <p style="color: red; margin-bottom: 20px;">
                        ⚠️ Please draw the mask on BOTH the background image and the reference image first, or click an example!
                    </p>
                    <button onclick="this.closest('.error-overlay').remove()"
                            style="padding: 8px 20px; cursor: pointer; border: none;
                                background: #eee; border-radius: 5px;">
                        OK
                    </button>
                </div>
            </div>
            """
        return None, gr.update(value=error_html, visible=True)

    background_image, _, crop_box, source_mask_cropped = process_source(pil_bg, pil_mask, CANVAS_W)
    ref_image = process_reference(pil_ref, pil_ref_mask, CANVAS_W)

    generator = torch.Generator(device="cuda").manual_seed(int(seed))
    generated_image = pipe(
        image=[background_image, ref_image],
        prompt=PROMPT,
        height=CANVAS_H,
        width=CANVAS_W,
        num_inference_steps=int(num_inference_steps),
        guidance_scale=float(cfg_scale),
        generator=generator,
    ).images[0]
    result_img = paste_back(generated_image, pil_bg, crop_box, source_mask_cropped, feather=0)

    return result_img, gr.update(visible=False)


# ===================== Gradio UI =====================

with gr.Blocks(css="""
    .input-row {
        overflow: visible !important;
    }

    .input-row .gr-image-editor {
        overflow: hidden !important;
    }
    .input-row .gr-image-editor .image-container,
    .input-row .gr-image-editor .canvas-container,
    .input-row .gr-image-editor canvas {
        max-width: 100% !important;
        max-height: 100% !important;
        object-fit: contain !important;
    }

    .ex-section-header {
        display: flex;
        align-items: center;
        gap: 10px;
        margin: 28px 0 14px 0;
        justify-content: center;
    }
    .ex-section-header::before {
        content: '';
        flex: 1;
        height: 1px;
        max-width: 180px;
        background: #e5e7eb;
    }
    .ex-section-header::after {
        content: '';
        flex: 1;
        height: 1px;
        max-width: 180px;
        background: #e5e7eb;
    }

    .ex-container {
        display: flex;
        flex-direction: column;
        align-items: center;
        padding-bottom: 20px;
    }

    .ex-grid {
        display: flex;
        flex-direction: column;
        align-items: center;
        gap: 12px;
        padding-bottom: 20px;
    }
    .ex-grid-row {
        display: flex;
        gap: 20px;
        justify-content: center;
        flex-wrap: wrap;
    }

    .ex-row {
        display: flex;
        align-items: center;
        gap: 20px;
        padding: 14px 28px;
        border: 2px solid #e5e7eb;
        border-radius: 12px;
        cursor: pointer;
        transition: all 0.25s ease;
        background: #ffffff;
        user-select: none;
        width: fit-content;
    }
    .ex-row:hover {
        border-color: #3b82f6;
        background: #f0f7ff;
        box-shadow: 0 4px 18px rgba(59, 130, 246, 0.15);
        transform: translateY(-2px);
    }
    .ex-row:active {
        transform: translateY(0);
        box-shadow: 0 2px 8px rgba(59, 130, 246, 0.2);
    }

    .ex-label {
        font-weight: 700;
        font-size: 15px;
        min-width: 62px;
        color: #1e40af;
        letter-spacing: 0.02em;
    }

    .ex-thumbs {
        display: flex;
        gap: 14px;
    }

    .ex-thumb-wrap {
        display: flex;
        flex-direction: column;
        align-items: center;
        gap: 6px;
    }

    .ex-thumb {
        width: 110px;
        height: auto;
        object-fit: contain;
        border-radius: 8px;
        border: 2px solid #e5e7eb;
        transition: all 0.25s ease;
        pointer-events: none;
    }
    .ex-row:hover .ex-thumb {
        border-color: #93c5fd;
    }

    .ex-thumb-sublabel {
        font-size: 12px;
        color: #6b7280;
        font-weight: 500;
    }

    /* Gradio >=5 does not mount components with visible=False into the DOM,
       so the example-thumbnail JS cannot find them. Hide via CSS instead:
       the buttons stay mounted (click handlers still fire) but invisible. */
    .ex-hidden-btn {
        display: none !important;
    }
""") as demo:

    gr.Markdown(
        "<h1 style='text-align: center;'>Reference-Based Object Insertion and Clothing Replacement</h1>"
        "<h3 style='text-align: center;'>Insert objects from reference images into masked regions of background images, and replace clothing in masked regions using reference images.</h3>"
        "<p style='text-align: center;'>"
        "<a href='https://github.com/huan-yin/Easy-Insert' target='_blank' "
        "style='color: #3b82f6; text-decoration: none; font-weight: 500;'>"
        "GitHub Repo: huan-yin/Easy-Insert</a></p>"
    )
    gr.Markdown(
        """
        **Instructions:**
        1. Upload a background image, then use the brush/eraser tools below the image to mark or refine the insertion region (mask); upload a reference image and brush/erase over the object to be inserted (ref mask).
        2. Or click any row of thumbnails in the "Examples" section below to automatically load background + mask + reference + ref mask.
        3. Click the "Generate" button, and the result will be displayed below.
        """
    )

    with gr.Row(elem_classes="input-row"):
        base = gr.ImageEditor(
            label="Background Image (brush the insertion region)",
            type="pil",
            format="png",
            width=420,
            height=450,
            sources=["upload"],
            canvas_size=(EDITOR_SIZE, EDITOR_SIZE),
            fixed_canvas=True,
            brush=gr.Brush(
                default_size=30,
                default_color="rgba(255, 255, 255, 0.6)",
                color_mode="fixed",
                colors=["rgba(255, 255, 255, 0.6)"],
            ),
            eraser=gr.Eraser(default_size=30),
        )
        ref = gr.ImageEditor(
            label="Reference Image (brush the object to insert)",
            type="pil",
            format="png",
            width=420,
            height=450,
            sources=["upload"],
            canvas_size=(EDITOR_SIZE, EDITOR_SIZE),
            fixed_canvas=True,
            brush=gr.Brush(
                default_size=30,
                default_color="rgba(255, 255, 255, 0.6)",
                color_mode="fixed",
                colors=["rgba(255, 255, 255, 0.6)"],
            ),
            eraser=gr.Eraser(default_size=30),
        )

    with gr.Row():
        seed = gr.Number(label="Seed", value=1, precision=0)
        num_inference_steps = gr.Slider(label="Inference Steps", minimum=1, maximum=50, step=1, value=15)
        cfg_scale = gr.Slider(label="CFG Scale", minimum=1, maximum=10, step=0.1, value=4)

    with gr.Row():
        gen_btn = gr.Button("Generate", variant="primary")

    # ==================== Generation Result ====================
    with gr.Row():
        output_image = gr.Image(
            label="Generated Result",
            interactive=False,
            width=420,
            height=450,
        )

    with gr.Row():
        error_dialog = gr.HTML(visible=False)

    gr.HTML('<div class="ex-section-header"><span style="font-weight:700;font-size:16px;color:#374151;">Examples (click to load background + mask + reference + ref mask)</span></div>')

    base_state = gr.State(None)
    ref_state = gr.State(None)

    ex_btn0 = gr.Button("Example 1", elem_id="ex_btn_0", elem_classes=["ex-hidden-btn"])
    ex_btn1 = gr.Button("Example 2", elem_id="ex_btn_1", elem_classes=["ex-hidden-btn"])
    ex_btn2 = gr.Button("Example 3", elem_id="ex_btn_2", elem_classes=["ex-hidden-btn"])
    ex_btn3 = gr.Button("Example 4", elem_id="ex_btn_3", elem_classes=["ex-hidden-btn"])

    gr.HTML('<div class="ex-container">' + build_examples_html() + '</div>')

    # ==================== Event Bindings ====================

    # Single-step load: a clear-then-load chain sends the frontend two rapid
    # updates, and the upload round-trip in between references temp PNGs that
    # get overwritten mid-read (truncated/broken PNG errors on preprocess).
    ex_btn0.click(fn=load_ex1, outputs=[base, ref, base_state, ref_state])
    ex_btn1.click(fn=load_ex2, outputs=[base, ref, base_state, ref_state])
    ex_btn2.click(fn=load_ex3, outputs=[base, ref, base_state, ref_state])
    ex_btn3.click(fn=load_ex4, outputs=[base, ref, base_state, ref_state])

    gen_btn.click(
        fn=run_local,
        inputs=[base, ref, base_state, ref_state, seed, num_inference_steps, cfg_scale],
        outputs=[output_image, error_dialog],
    )

demo.launch(server_name="0.0.0.0", server_port=7860)