user-agent/outpaint-bakeoff
0
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 