CoolFace
Apppublic

user-agent/outpaint-bakeoff

sourceHugging Faceapache-2.0updated 22d agoView on Hugging Face
0likes
app.py381 linesDownload Raw Back to root
1"""Reframe product images to a target ratio — 2:3 to 1:1, with or without the background.2 3Two routes to the same target, and which one you want depends on whether the final image4keeps its photographic backdrop:5 6  Extend background   FLUX.2-klein-4B + fal's outpaint LoRA generates new backdrop into a7                      green border. Use when the studio background must be kept.8  Cut out and pad     Trendyol's IS-Net removes the background; the subject is then pasted9                      onto a solid canvas at the target ratio. NO generation at all, so it10                      is instant, deterministic and pixel-exact.11 12If the output has a flat background anyway, the second route is strictly better — nothing13is invented, so nothing can be invented wrongly. Generative outpainting earns its cost only14when the real backdrop has to continue.15"""16import os17 18os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")19 20import spaces  # noqa: E402  — must precede torch / diffusers21 22import time  # noqa: E40223from typing import Tuple  # noqa: E40224 25import gradio as gr  # noqa: E40226import torch  # noqa: E40227from diffusers import Flux2KleinPipeline, FluxFillPipeline  # noqa: E40228from PIL import Image, ImageFilter  # noqa: E40229 30import bgremove  # noqa: E40231 32BASE = "black-forest-labs/FLUX.2-klein-4B"            # Apache-2.0 (the 9B is NOT)33LORA_REPO = "xocialize/outpaint-FLUX.2-klein-4B-lora"  # byte-identical mirror of fal's34LORA_FILE = "flux-outpaint-lora.safetensors"35LORA_PROMPT = "Fill the green spaces according to the image"36LORA_SCALE = 1.137GREEN = (0, 255, 0)38MAX_SIDE = 153639 40RATIOS = {"1:1 (square)": 1 / 1, "4:5": 4 / 5, "3:4": 3 / 4,41          "2:3": 2 / 3, "16:9": 16 / 9, "9:16": 9 / 16}42 43FILL = "black-forest-labs/FLUX.1-Fill-dev"      # 12B, mask-based, NON-COMMERCIAL licence44 45# klein (~16 GB) is held resident. Fill (~34 GB) would not fit alongside it in a 48 GB46# ZeroGPU slice, so it runs with model CPU offload: only the active submodule sits in47# VRAM. That makes Fill slower per image, which is a fair cost for having both engines48# answer the same input in one run.49pipe = Flux2KleinPipeline.from_pretrained(BASE, torch_dtype=torch.bfloat16).to("cuda")50pipe.load_lora_weights(LORA_REPO, weight_name=LORA_FILE)51pipe.fuse_lora(lora_scale=LORA_SCALE)52 53# Fill is GATED, so it is loaded lazily rather than at import: without an HF_TOKEN the54# Space must still start and serve klein + cut-out instead of crashing on boot.55_fill_pipe = None56_fill_error = None57 58 59def get_fill():60    global _fill_pipe, _fill_error61    if _fill_pipe is not None:62        return _fill_pipe63    if _fill_error is not None:64        raise gr.Error(_fill_error)65    token = os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN")66    try:67        p = FluxFillPipeline.from_pretrained(FILL, torch_dtype=torch.bfloat16, token=token)68        p.enable_model_cpu_offload()69        _fill_pipe = p70        return p71    except Exception as exc:72        _fill_error = (73            "FLUX.1-Fill-dev is a gated repo and this Space has no credentials. "74            "Add an HF_TOKEN secret (read scope is enough) from an account that has "75            f"accepted the licence at huggingface.co/{FILL}.  [{type(exc).__name__}]")76        raise gr.Error(_fill_error)77 78 79def _fit(img: Image.Image) -> Image.Image:80    img = img.convert("RGB")81    if max(img.size) > MAX_SIDE:82        s = MAX_SIDE / max(img.size)83        img = img.resize((int(img.width * s), int(img.height * s)), Image.LANCZOS)84    return img85 86 87def pad_for_ratio(size: Tuple[int, int], target: float, anchor: str,88                  bias: float = 0.5) -> Tuple[int, int, int, int]:89    """Padding (l, r, t, b) that takes an image to `target` = width/height, adding only.90 91    `bias` is the share of the new width given to the LEFT (or height to the TOP).92    It is deliberately clamped away from 0 and 1: a single large contiguous fill region93    reads to the model as a separate panel and gets a whole new scene painted into it,94    which is a far worse failure than continuing a limb. Two smaller regions each sit95    against real content and get continued instead.96    """97    bias = max(0.25, min(0.75, bias))98    w, h = size99    cur = w / h100    if abs(cur - target) < 1e-6:101        return 0, 0, 0, 0102    if cur < target:                       # too tall -> widen (head is never at risk)103        need = int(round(h * target)) - w104        if anchor == "left":105            return 0, need, 0, 0106        if anchor == "right":107            return need, 0, 0, 0108        left = int(round(need * bias))109        return left, need - left, 0, 0110    need = int(round(w / target)) - h      # too wide -> heighten111    if anchor == "top":112        return 0, 0, 0, need113    if anchor == "bottom":114        return 0, 0, need, 0115    top = int(round(need * bias))116    return 0, 0, top, need - top117 118 119def _round16(n: int) -> int:120    return (n + 15) // 16 * 16121 122 123def paste_offset(img: Image.Image, pad) -> Tuple[int, int, int, int]:124    """Where the source sits on the padded canvas, and the canvas size."""125    l, r, t, b = pad126    nw, nh = _round16(img.width + l + r), _round16(img.height + t + b)127    ox = l + (nw - img.width - l - r) // 2128    oy = t + (nh - img.height - t - b) // 2129    return ox, oy, nw, nh130 131 132def build_canvas(img: Image.Image, pad, fill=GREEN) -> Image.Image:133    ox, oy, nw, nh = paste_offset(img, pad)134    canvas = Image.new("RGB", (nw, nh), fill)135    canvas.paste(img, (ox, oy))136    return canvas137 138 139def restore_original(out: Image.Image, src: Image.Image, pad, feather: int = 0) -> Image.Image:140    """Paste the source back over a generated frame.141 142    FLUX.2 [klein] is an image-EDITING model: it denoises the whole canvas, so the green143    border is a hint rather than a constraint and the original region comes back altered —144    faces, fabric and logos all drift. For catalog imagery the product pixels must survive145    untouched, so the source is composited back at its exact position. Mask-based Fill146    drifts far less but is not guaranteed either, so the same guard is applied to both.147 148    `feather` softens only the outermost pixels of the pasted block, to hide a hard seam149    where the generated backdrop does not quite meet the original. 0 = byte-exact paste.150    """151    ox, oy, _, _ = paste_offset(src, pad)152    out = out.convert("RGB").copy()153    if feather <= 0:154        out.paste(src, (ox, oy))155        return out156    mask = Image.new("L", src.size, 255)157    edge = Image.new("L", src.size, 0)158    edge.paste(255, (feather, feather, src.width - feather, src.height - feather))159    mask = edge.filter(ImageFilter.GaussianBlur(feather / 2))160    out.paste(src, (ox, oy), mask)161    return out162 163 164def _duration(image=None, ratio=None, engines=None, anchor=None, bg_colour=None,165              preserve=True, feather=0, steps=8, fill_steps=28, *a, **k):166    """Fill is offloaded and runs many more steps, so it dominates the estimate."""167    sel = engines or []168    total = 30169    if "FLUX.2-klein-4B + fal LoRA" in sel:170        total += 10 + 4 * float(steps or 8)171    if "FLUX.1-Fill-dev (mask)" in sel:172        total += 60 + 5 * float(fill_steps or 28)173    if "Cut out and pad" in sel:174        total += 15175    return int(min(280, total))176 177 178@spaces.GPU(duration=_duration)179def reframe(image, ratio: str = "1:1 (square)", engines=None,180            anchor: str = "auto", bg_colour: str = "#FFFFFF",181            preserve: bool = True, feather: int = 0,182            steps: int = 8, fill_steps: int = 28, guidance: float = 1.0, seed: int = 0,183            prompt: str = LORA_PROMPT, progress=gr.Progress()):184    """Reframe a product image to a target aspect ratio.185 186    Args:187        image: Source product image (typically 2:3 portrait).188        ratio: Target aspect ratio.189        engines: Which expansion engines to run. All selected engines see the same input.190        anchor: Where the original sits when padding is uneven.191        bg_colour: Fill colour for the cut-out route.192        preserve: Composite the original back over the generated frame. klein rewrites the193            whole canvas, so without this your product pixels are not preserved.194        feather: Pixels of blend at the pasted edge. 0 keeps the original byte-exact.195        steps: Denoising steps for klein; it is distilled so 4-8 is usually enough.196        fill_steps: Denoising steps for FLUX.1-Fill-dev, which is not distilled.197        guidance: Guidance scale.198        seed: Random seed.199        prompt: The LoRA was trained on this exact sentence.200 201    Returns:202        The green conditioning canvas and one image per selected engine.203    """204    if image is None:205        raise gr.Error("Upload an image first.")206    src = _fit(image if isinstance(image, Image.Image) else Image.open(image))207 208    # Where does the subject actually touch the frame? Generative fill only mangles things209    # when it must continue a body across the edge it is extending.210    contact = warning = None211    if anchor == "auto" or "FLUX.2-klein-4B + fal LoRA" in (engines or []) \212            or "FLUX.1-Fill-dev (mask)" in (engines or []):213        try:214            contact = bgremove.edge_contact(src)215        except Exception as exc:216            print(f"[contact] skipped: {type(exc).__name__}", flush=True)217 218    effective_anchor, bias = anchor, 0.5219    if anchor == "auto":220        effective_anchor = "center"221        if contact:222            # Give less padding to the edge the subject touches, but keep BOTH sides223            # non-trivial — a single large region is the worse failure mode.224            l, r = contact["left"], contact["right"]225            if l + r > 1e-6:226                bias = r / (l + r)          # busier left -> smaller left pad227            bias = max(0.3, min(0.7, bias))228 229    pad = pad_for_ratio(src.size, RATIOS[ratio], effective_anchor, bias)230    if pad == (0, 0, 0, 0):231        raise gr.Error(f"Image is already {ratio} — nothing to add.")232 233    if contact:234        risky = [name for name, amount, added in235                 (("left", contact["left"], pad[0]), ("right", contact["right"], pad[1]),236                  ("top", contact["top"], pad[2]), ("bottom", contact["bottom"], pad[3]))237                 if added > 0 and amount > 0.02]238        if risky:239            warning = (240                f"⚠️ The subject touches the **{', '.join(risky)}** edge"241                f"{'s' if len(risky) > 1 else ''} being extended "242                f"({', '.join(f'{n} {contact[n]*100:.0f}%' for n in risky)}). "243                "Generative fill has to invent anatomy there — cropped feet and hands are "244                "where it fails worst. **FLUX.1-Fill-dev handles this better than klein** "245                "(a real mask keeps it to one scene). For a guaranteed-safe result use "246                "**Cut out and pad**. Note that forcing all padding to one side is *not* a "247                "fix: one large region makes the model paint a separate photo into it.")248 249    engines = engines or ["FLUX.2-klein-4B + fal LoRA"]250    canvas = build_canvas(src, pad)251    out_klein = out_fill = out_cut = None252    timings = []253 254    if "FLUX.2-klein-4B + fal LoRA" in engines:255        progress(0.15, desc="klein + LoRA")256        t0 = time.perf_counter()257        out_klein = pipe(258            prompt=prompt, image=canvas,259            height=canvas.height, width=canvas.width,260            guidance_scale=float(guidance), num_inference_steps=int(steps),261            generator=torch.Generator(device="cuda").manual_seed(int(seed)),262        ).images[0]263        if preserve:264            out_klein = restore_original(out_klein, src, pad, int(feather))265        timings.append(f"klein {time.perf_counter()-t0:.0f}s")266 267    if "FLUX.1-Fill-dev (mask)" in engines:268        progress(0.45, desc="FLUX.1-Fill-dev")269        t0 = time.perf_counter()270        # Fill takes a real mask: white marks the region to generate.271        base = build_canvas(src, pad, fill=(127, 127, 127))272        mask = Image.new("L", base.size, 255)273        ox = pad[0] + (base.width - src.width - pad[0] - pad[1]) // 2274        oy = pad[2] + (base.height - src.height - pad[2] - pad[3]) // 2275        mask.paste(0, (ox, oy, ox + src.width, oy + src.height))276        out_fill = get_fill()(277            prompt="continue the studio background seamlessly",278            image=base, mask_image=mask,279            height=base.height, width=base.width,280            guidance_scale=30.0, num_inference_steps=int(fill_steps),281            max_sequence_length=512,282            generator=torch.Generator("cpu").manual_seed(int(seed)),283        ).images[0]284        if preserve:285            out_fill = restore_original(out_fill, src, pad, int(feather))286        timings.append(f"fill {time.perf_counter()-t0:.0f}s")287 288    if "Cut out and pad" in engines:289        progress(0.9, desc="Background removal")290        t0 = time.perf_counter()291        colour = bg_colour.lstrip("#")292        rgb = tuple(int(colour[i:i + 2], 16) for i in (0, 2, 4)) if len(colour) == 6 \293            else (255, 255, 255)294        cut = bgremove.cutout(src, background=rgb)295        out_cut = build_canvas(cut, pad, fill=rgb)296        timings.append(f"cutout {time.perf_counter()-t0:.0f}s")297 298    print("[reframe] " + " · ".join(timings), flush=True)299    note = ""300    if contact:301        note = ("**Edge contact** — " + " · ".join(302            f"{k} {v*100:.0f}%" for k, v in contact.items())303            + f"  ·  anchor `{effective_anchor}` split {bias*100:.0f}/{100-bias*100:.0f}\n\n")304    if warning:305        note += warning + "\n\n"306    return canvas, out_klein, out_fill, out_cut, note + "`" + " · ".join(timings) + "`"307 308 309with gr.Blocks(title="Reframe / Outpaint") as demo:310    gr.Markdown(311        "# Reframe product images — 2:3 → 1:1\n"312        "Two routes to the same target ratio:\n\n"313        "**Extend background** — [FLUX.2-klein-4B](https://huggingface.co/black-forest-labs/FLUX.2-klein-4B) "314        "(Apache-2.0, ~13 GB) with [fal's outpaint LoRA](https://huggingface.co/fal/flux-2-klein-4B-outpaint-lora) "315        "(Apache-2.0). The source is pasted on a **green** canvas and the model fills the "316        "green — the middle panel is the real conditioning signal, not a debug view.\n\n"317        "**Cut out and pad** — [Trendyol's IS-Net](https://huggingface.co/Trendyol/background-removal), "318        "fine-tuned on Trendyol fashion imagery, removes the background; the subject is pasted "319        "onto a solid canvas at the target ratio. **No generation**, so it is instant, "320        "deterministic and cannot invent anything.\n\n"321        "**FLUX.1-Fill-dev** is the previous-generation mask-based fill, included as the "322        "comparison baseline — it is 12B against klein's 4B, is **not** distilled so it needs "323        "far more steps, and its licence is **non-commercial**.\n\n"324        "If the final image has a flat background anyway, the cut-out route is strictly "325        "better. Generative outpainting earns its cost only when the studio backdrop must "326        "continue."327    )328    with gr.Row():329        with gr.Column(scale=1):330            image = gr.Image(type="pil", label="Source (2:3 portrait)", height=280)331            ratio = gr.Dropdown(list(RATIOS), value="1:1 (square)", label="Target ratio")332            engines = gr.CheckboxGroup(333                ["FLUX.2-klein-4B + fal LoRA", "FLUX.1-Fill-dev (mask)", "Cut out and pad"],334                value=["FLUX.2-klein-4B + fal LoRA", "Cut out and pad"],335                label="Expansion engines",336                info="All selected engines run on the same input, same seed.")337            anchor = gr.Radio(["auto", "center", "left", "right", "top", "bottom"],338                              value="auto", label="Anchor",339                              info="auto = bias the split away from the edge the subject "340                                   "touches, while keeping both sides non-trivial. One "341                                   "large region makes the model paint a separate scene.")342            bg_colour = gr.ColorPicker("#FFFFFF", label="Cut-out background colour")343            preserve = gr.Checkbox(344                True, label="Preserve original pixels",345                info="klein rewrites the whole canvas — without this, faces, fabric and "346                     "logos drift. Composites the source back over the result.")347            feather = gr.Slider(0, 24, 0, step=2, label="Seam feather (px)",348                                info="0 = byte-exact original. Raise only if the join shows.")349            run = gr.Button("Reframe", variant="primary")350            with gr.Accordion("Advanced", open=False):351                steps = gr.Slider(2, 28, 8, step=1, label="klein steps")352                fill_steps = gr.Slider(10, 50, 28, step=1, label="Fill steps",353                                       info="Fill is not distilled — it needs more.")354                guidance = gr.Slider(0.5, 5.0, 1.0, step=0.1, label="Guidance")355                seed = gr.Number(0, label="Seed", precision=0)356                prompt = gr.Textbox(LORA_PROMPT, label="Prompt")357        with gr.Column(scale=3):358            timing = gr.Markdown()359            with gr.Row():360                canvas_out = gr.Image(label="Green canvas (klein conditioning)", height=320)361                klein_out = gr.Image(label="FLUX.2-klein-4B + fal LoRA", height=320)362            with gr.Row():363                fill_out = gr.Image(label="FLUX.1-Fill-dev (mask-based)", height=320)364                cut_out = gr.Image(label="Cut out and padded (no generation)", height=320)365 366    run.click(reframe,367              [image, ratio, engines, anchor, bg_colour, preserve, feather,368               steps, fill_steps, guidance, seed, prompt],369              [canvas_out, klein_out, fill_out, cut_out, timing])370 371    gr.Markdown(372        "---\n"373        "**Licences:** klein-4B and the outpaint LoRA are Apache-2.0; klein-**9B** is "374        "non-commercial and deliberately not used. Trendyol's background-removal model is "375        "**CC BY-SA 4.0** — a share-alike licence, worth checking against your intended use.\n\n"376        "The background model is built for images with a clear human subject; flat-lay and "377        "object-only packshots are outside what its card claims."378    )379 380demo.queue(max_size=10).launch(theme=gr.themes.Citrus(), mcp_server=True)381