pixellock-product-composer / model_runtime.py
Dbmaxwell's picture
Infer interactions only from visible scene evidence
e815955 verified
Raw
History Blame Contribute Delete
7.45 kB
"""Lazy FLUX.2 multi-reference editor for GPU-backed Hugging Face Spaces."""
from __future__ import annotations
import os
import threading
import numpy as np
from PIL import Image, ImageFilter
try:
import spaces
gpu_task = spaces.GPU(duration=300)
except ImportError:
# The `spaces` package is injected by the ZeroGPU runtime. Local and
# classical-GPU environments should keep working without it.
def gpu_task(function):
return function
MODEL_ID = os.getenv("EDIT_MODEL_ID", "black-forest-labs/FLUX.2-klein-4B")
_PIPE = None
_LOCK = threading.Lock()
def _model_roi(image: Image.Image, mask: Image.Image, max_side: int = 1280):
"""Crop to mask context and resize to model-friendly multiples of 32."""
bbox = mask.getbbox()
if bbox is None:
return None
left, top, right, bottom = bbox
margin = max(64, round(max(right - left, bottom - top) * 0.22))
crop_box = (
max(0, left - margin),
max(0, top - margin),
min(image.width, right + margin),
min(image.height, bottom + margin),
)
crop_image = image.crop(crop_box).convert("RGB")
crop_mask = mask.crop(crop_box).convert("L")
scale = min(1.0, max_side / max(crop_image.size))
width = max(64, int(np.ceil(crop_image.width * scale / 32)) * 32)
height = max(64, int(np.ceil(crop_image.height * scale / 32)) * 32)
model_image = crop_image.resize((width, height), Image.Resampling.LANCZOS)
model_mask = crop_mask.resize((width, height), Image.Resampling.NEAREST)
return crop_box, crop_image, crop_mask, model_image, model_mask
def _load_pipeline():
global _PIPE
if _PIPE is not None:
return _PIPE
with _LOCK:
if _PIPE is not None:
return _PIPE
try:
import torch
from diffusers import Flux2KleinPipeline
except ImportError as exc:
raise RuntimeError("FLUX.2 editing dependencies are not installed.") from exc
if not torch.cuda.is_available():
raise RuntimeError("AI replacement requires a CUDA GPU. Select ZeroGPU or GPU hardware for the Space.")
pipe = Flux2KleinPipeline.from_pretrained(
MODEL_ID,
torch_dtype=torch.bfloat16,
low_cpu_mem_usage=True,
)
if os.getenv("LOW_VRAM", "0") == "1":
pipe.enable_model_cpu_offload()
else:
pipe.to("cuda")
pipe.set_progress_bar_config(disable=True)
_PIPE = pipe
return _PIPE
@gpu_task
def replace_object(
image: Image.Image,
mask: Image.Image,
product_reference: Image.Image,
prompt: str,
seed: int,
steps: int = 4,
strength: float = 1.0,
) -> Image.Image:
if mask.getbbox() is None:
return image
import torch
roi = _model_roi(image, mask)
if roi is None:
return image
crop_box, crop_image, crop_mask, model_image, _ = roi
pipe = _load_pipeline()
product_reference = product_reference.convert("RGBA")
ref_bbox = product_reference.getchannel("A").getbbox()
if ref_bbox:
product_reference = product_reference.crop(ref_bbox)
max_ref = 768
ref_scale = min(1.0, max_ref / max(product_reference.size))
ref_size = (
max(1, round(product_reference.width * ref_scale)),
max(1, round(product_reference.height * ref_scale)),
)
product_reference = product_reference.resize(ref_size, Image.Resampling.LANCZOS)
pad = max(32, round(max(ref_size) * 0.12))
product_card = Image.new(
"RGBA",
(product_reference.width + pad * 2, product_reference.height + pad * 2),
(128, 128, 128, 255),
)
product_card.alpha_composite(product_reference, (pad, pad))
full_prompt = (
"Image 1 is the destination scene with an approximate replacement already positioned. "
"Image 2 is the exact identity and appearance reference for the replacement object; "
"its neutral isolation background is disposable and must never appear in the result. "
"Create a photorealistic edit of image 1: replace the previous object with the object "
"from image 2 at the approximate size, position, orientation and support contact shown "
"in image 1. Do not enlarge the replacement beyond the guide shown in image 1. Infer the "
"existing scene interaction only from visible evidence in image 1. Preserve real foreground "
"occluders and existing contact geometry, but never invent hands, holders, people, stands, "
"pedestals or supports that are not already present. Remove all remnants of the previous "
"object. Preserve the replacement's distinctive shape, proportions, colors, material, "
"label and text. Create physically coherent perspective, lighting, contact shadow, "
"reflection and foreground occlusion. Preserve all unrelated background and scene pixels "
"from image 1. Do not add a frame, card, rectangle, halo, backdrop, duplicate product or "
"extra label around the replacement. "
f"User direction: {prompt.strip() or 'natural context-aware object replacement'}"
)
generator = torch.Generator(device="cuda").manual_seed(int(seed))
with _LOCK, torch.inference_mode():
result = pipe(
prompt=full_prompt,
image=[model_image, product_card.convert("RGB")],
height=model_image.height,
width=model_image.width,
num_inference_steps=int(steps),
generator=generator,
).images[0].convert("RGB")
generated_crop = result.resize(crop_image.size, Image.Resampling.LANCZOS)
generated_crop = Image.blend(
crop_image,
generated_crop,
float(np.clip(strength, 0.35, 1.0)),
)
# Match the generated crop to the untouched scene at the inner mask border.
# This removes exposure/color shifts that otherwise reveal a rectangular or
# brush-shaped patch around the replacement.
mask_array = np.asarray(crop_mask, dtype=np.uint8) > 127
mask_bbox = crop_mask.getbbox()
span = max(mask_bbox[2] - mask_bbox[0], mask_bbox[3] - mask_bbox[1])
erosion_size = max(3, min(21, round(span * 0.035)))
if erosion_size % 2 == 0:
erosion_size += 1
eroded = np.asarray(crop_mask.filter(ImageFilter.MinFilter(erosion_size))) > 127
inner_border = mask_array & ~eroded
if np.count_nonzero(inner_border) >= 16:
original_array = np.asarray(crop_image, dtype=np.float32)
generated_array = np.asarray(generated_crop, dtype=np.float32)
correction = np.median(
original_array[inner_border] - generated_array[inner_border], axis=0
)
generated_array = np.clip(generated_array + correction, 0, 255).astype(np.uint8)
generated_crop = Image.fromarray(generated_array, "RGB")
# The edit model is free to reason globally inside its ROI, but only user-mask
# pixels are committed to the destination scene.
feather = float(np.clip(span * 0.025, 2.0, 12.0))
soft_inside = crop_mask.filter(ImageFilter.GaussianBlur(radius=feather))
blend_mask = Image.fromarray(
np.minimum(np.asarray(crop_mask), np.asarray(soft_inside)).astype(np.uint8),
"L",
)
merged_crop = Image.composite(generated_crop, crop_image, blend_mask)
output = image.copy().convert("RGB")
output.paste(merged_crop, crop_box[:2])
return output