FLUX.2-Klein-Multi-LoRA / image_utils.py
M3st3rJ4k3l's picture
Added new functionalities
abdc5ef verified
Raw
History Blame Contribute Delete
12 kB
"""Pure image helpers β€” no torch, no diffusers, no gradio state.
Owns: EXIF handling, dimension snapping, canvas fitting, editor-composite
extraction, HEIC decoding, PNG metadata embedding.
"""
from __future__ import annotations
import json
import tempfile
from typing import Any
import numpy as np
from PIL import Image, ImageOps, ImageFilter
from PIL.PngImagePlugin import PngInfo
import gradio as gr
# ── HEIC / HEIF support ──────────────────────────────────────────────────────
try:
from pillow_heif import register_heif_opener
register_heif_opener()
except ImportError:
print("pillow-heif not installed β€” HEIC/HEIF uploads will not work. "
"Add `pillow-heif` to requirements.txt.")
# ── EXIF / dimension helpers ─────────────────────────────────────────────────
def fix_orientation(img: Image.Image | None) -> Image.Image | None:
if img is None:
return None
return ImageOps.exif_transpose(img)
def _snap16(v: float) -> int:
"""Snap to a multiple of 16 β€” required by FLUX's VAE."""
return max(16, (int(v) // 16) * 16)
def compute_base_dimensions(image: Image.Image | None) -> tuple[int, int]:
if image is None:
return 1024, 1024
w, h = image.size
scale = min(1024 / w, 1024 / h)
return _snap16(w * scale), _snap16(h * scale)
update_dimensions_on_upload = compute_base_dimensions
def compute_canvas_dimensions(
base_image: Image.Image | None,
canvas_mode: str,
custom_width: int,
custom_height: int,
) -> tuple[int, int]:
if canvas_mode == "Custom":
return _snap16(custom_width), _snap16(custom_height)
return compute_base_dimensions(base_image)
# ── Canvas fitting ──────────────────────────────────────────────────────────
def fit_to_canvas(
img: Image.Image,
width: int,
height: int,
mode: str = "Stretch",
pad_color: str = "#000000",
) -> Image.Image:
"""Return `img` resized to exactly widthΓ—height using the given strategy.
Modes:
- "Stretch" : resize ignoring aspect (current default, may distort)
- "Pad (color)" : scale to fit, pad with `pad_color`
- "Pad (blur)" : scale to fit, pad with a blurred cover of the image
- "Crop (cover)" : scale to cover, center-crop to canvas
"""
img = img.convert("RGB")
if mode == "Stretch":
return img.resize((width, height), Image.LANCZOS)
iw, ih = img.size
if mode == "Pad (color)":
scale = min(width / iw, height / ih)
nw, nh = max(1, int(iw * scale)), max(1, int(ih * scale))
resized = img.resize((nw, nh), Image.LANCZOS)
canvas = Image.new("RGB", (width, height), pad_color)
canvas.paste(resized, ((width - nw) // 2, (height - nh) // 2))
return canvas
if mode == "Pad (blur)":
# Foreground: scale-to-fit
scale = min(width / iw, height / ih)
nw, nh = max(1, int(iw * scale)), max(1, int(ih * scale))
fg = img.resize((nw, nh), Image.LANCZOS)
# Background: scale-to-cover, center-crop, then blur heavily
cscale = max(width / iw, height / ih)
cw, ch = max(1, int(iw * cscale)), max(1, int(ih * cscale))
bg = img.resize((cw, ch), Image.LANCZOS)
bg = bg.crop(((cw - width) // 2, (ch - height) // 2,
(cw - width) // 2 + width, (ch - height) // 2 + height))
bg = bg.filter(ImageFilter.GaussianBlur(radius=32))
bg.paste(fg, ((width - nw) // 2, (height - nh) // 2))
return bg
if mode == "Crop (cover)":
cscale = max(width / iw, height / ih)
nw, nh = max(1, int(iw * cscale)), max(1, int(ih * cscale))
resized = img.resize((nw, nh), Image.LANCZOS)
left = (nw - width) // 2
top = (nh - height) // 2
return resized.crop((left, top, left + width, top + height))
# Unknown mode β†’ fall back to stretch rather than erroring during inference
print(f"[fit_to_canvas] unknown mode {mode!r} β€” falling back to Stretch.")
return img.resize((width, height), Image.LANCZOS)
# ── UI label updates ────────────────────────────────────────────────────────
def on_base_image_change(img) -> str:
if img is None:
return "*No base image uploaded yet*"
try:
pil_img = img if isinstance(img, Image.Image) else Image.open(img)
ow, oh = pil_img.size
bw, bh = compute_base_dimensions(pil_img)
return (
f"Input: **{ow} Γ— {oh}** px β†’ "
f"Auto canvas (pre-upscale): **{bw} Γ— {bh}** px"
)
except Exception as e:
return f"*Could not read dimensions: {e}*"
def on_reference_change(images) -> str:
if not images:
return "πŸ“· No reference images"
count = len(images)
return f"πŸ“· {count} reference image{'s' if count != 1 else ''} uploaded"
# ── Upload round-trip (fixes HEIC preview in main tab) ──────────────────────
def reencode_upload(img):
if img is None:
return None
if not isinstance(img, Image.Image):
try:
img = Image.open(img)
except Exception:
return img
return fix_orientation(img).convert("RGB")
# ── Inference input assembly ────────────────────────────────────────────────
def process_images(base_image, reference_images) -> list[Image.Image]:
pil_images: list[Image.Image] = []
if base_image is not None:
try:
img = base_image if isinstance(base_image, Image.Image) else Image.open(base_image)
pil_images.append(fix_orientation(img).convert("RGB"))
except Exception as e:
print(f"Skipping invalid base image: {e}")
for item in (reference_images or []):
try:
path_or_img = item[0] if isinstance(item, (tuple, list)) else item
if isinstance(path_or_img, Image.Image):
img = path_or_img
elif isinstance(path_or_img, str):
img = Image.open(path_or_img)
else:
img = Image.open(path_or_img.name)
pil_images.append(fix_orientation(img).convert("RGB"))
except Exception as e:
print(f"Skipping invalid reference image: {e}")
return pil_images
# ── ImageEditor helpers ─────────────────────────────────────────────────────
def _editor_composite(editor_value) -> Image.Image:
if not editor_value or editor_value.get("composite") is None:
raise gr.Error("Upload and crop an image in the editor first.")
composite = editor_value["composite"]
if isinstance(composite, np.ndarray):
composite = Image.fromarray(composite)
return composite.convert("RGB")
def send_editor_to_base(editor_value) -> Image.Image:
composite = fix_orientation(_editor_composite(editor_value))
gr.Info("Sent to Base Image")
return composite
def send_editor_to_reference(editor_value, current_gallery) -> list:
composite = fix_orientation(_editor_composite(editor_value))
current = list(current_gallery or [])
current.append(composite)
gr.Info("Added to Reference Images")
return current
def load_heic_to_editor(path):
if not path:
return gr.update()
try:
img = fix_orientation(Image.open(path)).convert("RGB")
except Exception as e:
raise gr.Error(f"Could not decode HEIC/HEIF: {e}")
gr.Info("HEIC loaded into editor.")
return img
# ── Send output β†’ base / reference (gallery-aware) ──────────────────────────
def _resolve_gallery_path(selected_path, gallery_value):
"""Pick the path the Send-to-* buttons should use.
Prefers the user's currently-selected gallery item; falls back to the most
recent (last) item so single-image runs and "didn't click anything" cases
both behave intuitively.
"""
if selected_path:
return selected_path
if not gallery_value:
return None
item = gallery_value[-1]
return item[0] if isinstance(item, (list, tuple)) else item
def send_output_to_base(selected_path, gallery_value):
path = _resolve_gallery_path(selected_path, gallery_value)
if not path:
raise gr.Error("Nothing to send β€” generate an image first.")
img = Image.open(path).convert("RGB")
gr.Info("Output sent to Base Image.")
return img
def send_output_to_reference(selected_path, gallery_value, current_gallery):
path = _resolve_gallery_path(selected_path, gallery_value)
if not path:
raise gr.Error("Nothing to send β€” generate an image first.")
img = Image.open(path).convert("RGB")
current = list(current_gallery or [])
current.append(img)
gr.Info("Output added to Reference Images.")
return current
# ── PNG metadata embedding ──────────────────────────────────────────────────
def _format_parameters_string(meta: dict[str, Any]) -> str:
prompt = meta.get("prompt", "") or ""
fields = [
("Seed", meta.get("seed")),
("Steps", meta.get("steps")),
("CFG scale", meta.get("guidance_scale")),
("Size", f"{meta.get('width')}x{meta.get('height')}"),
("Model", meta.get("model")),
("Upscaler", meta.get("upscale_factor")),
("Canvas mode", meta.get("canvas_mode")),
("Fit mode", meta.get("canvas_fit_mode")),
]
loras = meta.get("loras") or []
if loras:
fields.append(("LoRAs", ", ".join(f"{n}:{w:.2f}" for n, w in loras)))
kv = ", ".join(f"{k}: {v}" for k, v in fields if v not in (None, "", "None"))
return f"{prompt}\n{kv}".strip()
def build_pnginfo(meta: dict[str, Any]) -> PngInfo:
"""Public so bulk processing can reuse it for in-place saves."""
info = PngInfo()
info.add_text("parameters", _format_parameters_string(meta))
for k in ("prompt", "seed", "steps", "guidance_scale", "width", "height",
"model", "upscale_factor", "canvas_mode", "canvas_fit_mode",
"lora_prompt", "custom_prompt"):
info.add_text(k, str(meta.get(k, "")))
info.add_text("loras", json.dumps(meta.get("loras") or []))
return info
def save_with_metadata(image: Image.Image, meta: dict[str, Any],
path: str | None = None) -> str:
"""Save `image` as PNG with embedded generation metadata.
If `path` is given, write there (used by bulk-process to keep predictable
filenames inside its work directory). Otherwise allocate a temp PNG.
"""
if path is None:
tmp = tempfile.NamedTemporaryFile(suffix=".png", delete=False, prefix="flux2_klein_")
tmp.close()
path = tmp.name
image.save(path, format="PNG", pnginfo=build_pnginfo(meta))
return path
# ── Send arbitrary PIL β†’ base / reference (used by the Depth/Pose tab) ──────
def push_pil_to_base(img):
if img is None:
raise gr.Error("Nothing to send β€” generate it first.")
gr.Info("Sent to Base Image.")
return img
def push_pil_to_reference(img, current_gallery):
if img is None:
raise gr.Error("Nothing to send β€” generate it first.")
current = list(current_gallery or [])
current.append(img)
gr.Info("Added to Reference Images.")
return current