i / backend /processing.py
abir614's picture
Upload backend/processing.py with huggingface_hub
100d06c verified
"""
IMGFLOW β€” Server-side image processing
Mirrors all three browser pipeline flows from script.js
Flow 1 β€” Standard: Lanczos upscale β†’ Shopify resize β†’ WebP encode
Flow 2 β€” No BG: rembg ISNet remove β†’ edge refine β†’ upscale β†’ WebP/PNG
Flow 3 β€” Smart Resize: auto-detect crop/extend β†’ fill β†’ upscale β†’ WebP
"""
import io
import math
import time
import numpy as np
from PIL import Image, ImageFilter
import cv2
from scipy.ndimage import gaussian_filter
# ═══════════════════════════════════════
# FLOW 1 β€” STANDARD PIPELINE
# ═══════════════════════════════════════
def run_flow1(img: Image.Image, cfg: dict) -> dict:
"""Upscale β†’ Shopify resize β†’ WebP encode"""
t0 = time.time()
orig_size = _img_size(img)
# 1. Upscale
img = upscale(img, cfg["factor"], cfg["method"])
after_up = _img_size(img)
# 2. Shopify resize (cap longest side)
img = shopify_resize(img, cfg["shopify"])
after_sh = _img_size(img)
# 3. Encode WebP
blob = encode_webp(img, cfg["quality"], cfg["max_kb"])
return {
"blob": blob,
"ext": "webp",
"prefix": "shopify",
"dims": f"{img.width}Γ—{img.height}",
"log": [
f"upscaled {orig_size} β†’ {after_up}",
f"shopify resize β†’ {after_sh}",
f"webp encode β†’ {len(blob)//1024} KB ({time.time()-t0:.1f}s)",
],
}
# ═══════════════════════════════════════
# FLOW 2 β€” NO BACKGROUND
# ═══════════════════════════════════════
def run_flow2(img: Image.Image, cfg: dict) -> dict:
"""rembg ISNet BG removal β†’ edge refine β†’ upscale β†’ WebP / PNG"""
t0 = time.time()
orig_size = _img_size(img)
# 1. Background removal (lazy import so startup is fast when not used)
try:
from rembg import remove, new_session
except ImportError as e:
raise RuntimeError(
f"rembg is not installed or has missing dependencies ({e}). "
"Run: pip install packaging rembg[gpu]"
) from e
session = new_session(cfg["bg_model"])
img = remove(img, session=session) # returns RGBA PNG
img = img.convert("RGBA")
# 2. Edge refinement: alpha threshold + feathering
img = refine_edges(img, cfg["alpha_threshold"], cfg["feather"])
after_bg = _img_size(img)
# 3. Upscale (preserve RGBA)
img = upscale(img, cfg["factor"], cfg["method"])
after_up = _img_size(img)
# 4. Encode
use_png = cfg.get("output_format", "webp") == "png"
if use_png:
blob = encode_png(img)
ext = "png"
else:
blob = encode_webp(img, cfg["quality"], cfg["max_kb"])
ext = "webp"
return {
"blob": blob,
"ext": ext,
"prefix": "nobg",
"dims": f"{img.width}Γ—{img.height}",
"log": [
f"BG removed β†’ {after_bg}",
f"upscaled β†’ {after_up}",
f"{ext} encode β†’ {len(blob)//1024} KB ({time.time()-t0:.1f}s)",
],
}
# ═══════════════════════════════════════
# FLOW 3 β€” SMART RESIZE
# ═══════════════════════════════════════
def run_flow3(img: Image.Image, cfg: dict) -> dict:
"""Smart Resize: detect β†’ crop/extend β†’ target dimensions β†’ WebP"""
t0 = time.time()
orig_size = _img_size(img)
tw, th = cfg["resize_w"], cfg["resize_h"]
mode = cfg.get("resize_mode", "smart-crop-extend")
if mode == "proportional":
img, decision = proportional_resize(img, tw, th, cfg)
else:
img, decision = smart_resize(img, tw, th, cfg)
after_resize = _img_size(img)
# Encode
blob = encode_webp(img, cfg["quality"], cfg["max_kb"])
prefix = "fit" if mode == "proportional" else "resize"
return {
"blob": blob,
"ext": "webp",
"prefix": prefix,
"dims": f"{img.width}Γ—{img.height}",
"log": [
f"decision: {decision} {orig_size} β†’ {after_resize}",
f"webp encode β†’ {len(blob)//1024} KB ({time.time()-t0:.1f}s)",
],
}
# ═══════════════════════════════════════
# UPSCALE
# ═══════════════════════════════════════
def upscale(img: Image.Image, factor: float, method: str) -> Image.Image:
"""Lanczos-3 or bicubic upscale by factor."""
if factor <= 1.0:
return img
nw = round(img.width * factor)
nh = round(img.height * factor)
resample = Image.LANCZOS if method == "lanczos" else Image.BICUBIC
return img.resize((nw, nh), resample=resample)
def shopify_resize(img: Image.Image, max_dim: int) -> Image.Image:
"""Cap longest side to max_dim, preserve aspect ratio."""
r = min(max_dim / img.width, max_dim / img.height, 1.0)
if r >= 1.0:
return img
return img.resize((round(img.width * r), round(img.height * r)), Image.LANCZOS)
# ═══════════════════════════════════════
# EDGE REFINEMENT (Flow 2)
# ═══════════════════════════════════════
def refine_edges(img: Image.Image, alpha_threshold: int, feather: int) -> Image.Image:
"""Apply alpha threshold, erosion at boundary, and optional Gaussian feather."""
arr = np.array(img) # HΓ—WΓ—4 uint8
# 1. Hard threshold
alpha = arr[:, :, 3].astype(np.float32)
lo, hi = alpha_threshold, 255 - alpha_threshold
alpha[alpha <= lo] = 0
alpha[alpha >= hi] = 255
# 2. Boundary erosion: shrink semi-transparent fringe
binary = (alpha > 0).astype(np.uint8)
kernel = np.ones((3, 3), np.uint8)
eroded = cv2.erode(binary, kernel, iterations=1)
fringe = (binary > 0) & (eroded == 0)
alpha[fringe] = np.maximum(0, alpha[fringe] - 80)
# 3. Optional Gaussian feather
if feather > 0:
alpha = gaussian_filter(alpha, sigma=feather * 0.45 + 0.5)
arr[:, :, 3] = np.clip(alpha, 0, 255).astype(np.uint8)
return Image.fromarray(arr, "RGBA")
# ═══════════════════════════════════════
# SMART RESIZE β€” crop + extend
# ═══════════════════════════════════════
def smart_resize(img: Image.Image, tw: int, th: int, cfg: dict):
"""
Per-axis smart crop + extend.
Mirrors smartResize() from script.js exactly.
"""
sw, sh = img.width, img.height
t_ar = tw / th
s_ar = sw / sh
focus = cfg.get("resize_focus", "smart")
align = cfg.get("resize_align", "center")
fill = cfg.get("resize_fill", "extend")
blend = cfg.get("resize_blend", 40)
color = cfg.get("fill_color", "#ffffff")
# Detect focal point
fx, fy = 0.5, 0.4
if focus == "smart":
fx, fy = pixel_saliency_center(img)
else:
fm = {"center": (.5, .5), "top": (.5, .15), "bottom": (.5, .85),
"left": (.15, .5), "right": (.85, .5)}
fx, fy = fm.get(focus, (.5, .5))
# Determine crop region
crop_w, crop_h = min(sw, tw), min(sh, th)
crop_x, crop_y = 0, 0
if sw > tw or sh > th:
if s_ar > t_ar:
crop_h = min(sh, th)
crop_w = round(crop_h * t_ar)
else:
crop_w = min(sw, tw)
crop_h = round(crop_w / t_ar)
crop_w = min(crop_w, sw)
crop_h = min(crop_h, sh)
crop_x = round(fx * sw - crop_w / 2)
crop_y = round(fy * sh - crop_h / 2)
crop_x = max(0, min(sw - crop_w, crop_x))
crop_y = max(0, min(sh - crop_h, crop_y))
placed = img.crop((crop_x, crop_y, crop_x + crop_w, crop_y + crop_h))
ox, oy = get_anchor_offset(crop_w, crop_h, tw, th, align)
needs_fill = crop_w < tw or crop_h < th
# Decision string for log
if sw < tw and sh < th:
decision = f"extend both axes β†’ {tw}Γ—{th}"
elif sw >= tw and sh >= th:
if abs(s_ar - t_ar) < 0.005:
decision = f"scale β†’ {tw}Γ—{th}"
elif s_ar > t_ar:
decision = f"crop width (source wider) β†’ {tw}Γ—{th}"
else:
decision = f"crop height (source taller) β†’ {tw}Γ—{th}"
else:
decision = f"mixed crop+extend β†’ {tw}Γ—{th}"
if not needs_fill:
out = placed.resize((tw, th), Image.LANCZOS) if placed.size != (tw, th) else placed
return out, decision
# Build output canvas
has_alpha = img.mode == "RGBA"
mode = "RGBA" if (has_alpha or fill == "transparent") else "RGB"
out = Image.new(mode, (tw, th))
if fill == "extend":
out = fill_seamless_pil(placed, ox, oy, tw, th, blend)
elif fill == "white":
out = Image.new(mode, (tw, th), (255, 255, 255, 255) if mode == "RGBA" else (255, 255, 255))
out.paste(placed, (ox, oy))
elif fill == "black":
out = Image.new(mode, (tw, th), (0, 0, 0, 255) if mode == "RGBA" else (0, 0, 0))
out.paste(placed, (ox, oy))
elif fill == "transparent":
out = Image.new("RGBA", (tw, th), (0, 0, 0, 0))
out.paste(placed, (ox, oy))
elif fill == "color":
rgb = _hex_to_rgb(color)
out = Image.new(mode, (tw, th), rgb)
out.paste(placed, (ox, oy))
elif fill == "ai-extend":
out = fill_lama(placed, ox, oy, tw, th, blend)
else:
# fallback: edge extend
out = fill_seamless_pil(placed, ox, oy, tw, th, blend)
return out, decision
def proportional_resize(img: Image.Image, tw: int, th: int, cfg: dict):
"""Scale to fit within target, then pad. Mirrors proportionalResize()."""
sw, sh = img.width, img.height
ratio = min(tw / sw, th / sh)
fit_w = round(sw * ratio)
fit_h = round(sh * ratio)
scaled = img.resize((fit_w, fit_h), Image.LANCZOS)
fill = cfg.get("resize_fill", "extend")
align = cfg.get("resize_align", "center")
color = cfg.get("fill_color", "#ffffff")
blend = cfg.get("resize_blend", 40)
ox, oy = get_anchor_offset(fit_w, fit_h, tw, th, align)
mode = "RGBA" if (img.mode == "RGBA" or fill == "transparent") else "RGB"
if fill == "blur":
out = _blurred_background(img, tw, th)
out.paste(scaled, (ox, oy))
elif fill == "white":
out = Image.new(mode, (tw, th), (255, 255, 255))
out.paste(scaled, (ox, oy))
elif fill == "black":
out = Image.new(mode, (tw, th), (0, 0, 0))
out.paste(scaled, (ox, oy))
elif fill == "transparent":
out = Image.new("RGBA", (tw, th), (0, 0, 0, 0))
out.paste(scaled, (ox, oy))
elif fill == "color":
out = Image.new(mode, (tw, th), _hex_to_rgb(color))
out.paste(scaled, (ox, oy))
elif fill == "extend":
out = fill_seamless_pil(scaled, ox, oy, tw, th, blend)
elif fill == "ai-extend":
out = fill_lama(scaled, ox, oy, tw, th, blend)
else:
out = fill_seamless_pil(scaled, ox, oy, tw, th, blend)
decision = f"proportional fit: {fit_w}Γ—{fit_h} + padding β†’ {tw}Γ—{th}"
return out, decision
def get_anchor_offset(sw: int, sh: int, W: int, H: int, align: str):
cx = (W - sw) // 2
cy = (H - sh) // 2
bx, by = W - sw, H - sh
return {
"center": (cx, cy),
"top-left": (0, 0),
"top-center": (cx, 0),
"top-right": (bx, 0),
"middle-left": (0, cy),
"middle-right": (bx, cy),
"bottom-left": (0, by),
"bottom-center": (cx, by),
"bottom-right": (bx, by),
}.get(align, (cx, cy))
# ═══════════════════════════════════════
# SEAMLESS EXTENSION (edge pixel fill)
# Mirrors fillSeamless() from script.js
# ═══════════════════════════════════════
def fill_seamless_pil(src: Image.Image, ox: int, oy: int, W: int, H: int, blend_radius: int) -> Image.Image:
"""
Place src at (ox,oy) on a WΓ—H canvas.
Fill extension zones by sampling nearby edge pixels of src (weighted average).
Fully vectorised with NumPy β€” no Python pixel loops.
"""
sw, sh = src.width, src.height
has_alpha = src.mode == "RGBA"
src_arr = np.array(src.convert("RGBA") if not has_alpha else src, dtype=np.float32)
STRIP = max(6, min(blend_radius, int(min(sw, sh) * 0.18)))
weights = np.array([((STRIP - k) / STRIP) ** 1.5 for k in range(STRIP)], dtype=np.float32)
total_w = float(weights.sum())
# Coordinate grids for full output canvas
ys, xs = np.mgrid[0:H, 0:W]
rx = xs - ox
ry = ys - oy
in_x = (rx >= 0) & (rx < sw)
in_y = (ry >= 0) & (ry < sh)
inside = in_x & in_y
# Clamped source coords (used for interior copy and per-axis clamping)
sx_clip = np.clip(rx, 0, sw - 1).astype(np.int32)
sy_clip = np.clip(ry, 0, sh - 1).astype(np.int32)
out_arr = np.zeros((H, W, 4), dtype=np.float32)
# Interior: direct copy
out_arr[inside] = src_arr[sy_clip[inside], sx_clip[inside]]
# Exterior: weighted strip average β€” one vectorised pass per k
exterior = ~inside
if exterior.any():
accum = np.zeros((H, W, 4), dtype=np.float32)
for k in range(STRIP):
w = weights[k]
sx_k = np.where(rx < 0, np.minimum(k, sw - 1), np.maximum(sw - 1 - k, 0)).astype(np.int32)
sy_k = np.where(ry < 0, np.minimum(k, sh - 1), np.maximum(sh - 1 - k, 0)).astype(np.int32)
# Clamp the in-bounds axis to its natural position
sx_k = np.where(in_x, sx_clip, sx_k)
sy_k = np.where(in_y, sy_clip, sy_k)
accum += src_arr[sy_k, sx_k] * w
accum /= total_w
out_arr[exterior] = accum[exterior]
if blend_radius > 0:
_blend_seam(out_arr, ox, oy, sw, sh, W, H, blend_radius)
out = Image.fromarray(np.clip(out_arr, 0, 255).astype(np.uint8), "RGBA")
return out if has_alpha else out.convert("RGB")
def _blend_seam(arr: np.ndarray, ox: int, oy: int, sw: int, sh: int, W: int, H: int, radius: int):
"""Smooth the seam between placed image and fill zone. Vectorised."""
x1, y1 = ox, oy
x2, y2 = min(ox + sw, W), min(oy + sh, H)
if x1 >= x2 or y1 >= y2:
return
ys, xs = np.mgrid[y1:y2, x1:x2]
dx = np.minimum(xs - ox, ox + sw - 1 - xs)
dy = np.minimum(ys - oy, oy + sh - 1 - ys)
d = np.minimum(dx, dy)
blend_mask = d < radius
if not blend_mask.any():
return
t = np.where(blend_mask, d / radius, 1.0)
smooth = t * t * (3 - 2 * t) # smoothstep
# Neighbour coordinates (the fill-zone pixel on the other side of the seam)
nx = np.where(dx <= dy,
np.where(xs < ox + sw // 2, ox - 1, ox + sw),
xs)
ny = np.where(dx > dy,
np.where(ys < oy + sh // 2, oy - 1, oy + sh),
ys)
nx = np.clip(nx, 0, W - 1)
ny = np.clip(ny, 0, H - 1)
sm = smooth[:, :, np.newaxis] # (h, w, 1) for broadcast
neighbour = arr[ny, nx] # (h, w, 4)
blended = neighbour * (1 - sm) + arr[y1:y2, x1:x2] * sm
arr[y1:y2, x1:x2] = np.where(blend_mask[:, :, np.newaxis], blended, arr[y1:y2, x1:x2])
# ═══════════════════════════════════════
# AI FILL β€” LaMa Inpainting via iopaint
# ═══════════════════════════════════════
# Module-level singleton so the model is loaded once per process
_lama_model = None
def _get_lama_model():
"""Lazy-load the LaMa model singleton. Returns None if unavailable."""
global _lama_model
if _lama_model is not None:
return _lama_model
try:
import torch
from iopaint.model.lama import LaMa
from iopaint.schema import InpaintRequest
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
_lama_model = LaMa(device)
print(f"[INFO] LaMa model loaded on {device}")
return _lama_model
except Exception as e:
print(f"[WARN] LaMa unavailable ({e})")
return None
def _lama_inpaint_once(lama, canvas: np.ndarray, mask: np.ndarray, inpaint_cfg) -> np.ndarray:
"""
Run one LaMa pass at a safe resolution.
canvas: HΓ—WΓ—3 RGB uint8. mask: HΓ—W uint8 (255=fill, 0=known).
Returns RGB uint8.
"""
H, W = canvas.shape[:2]
MAX_DIM = 1024
scale = min(MAX_DIM / W, MAX_DIM / H, 1.0)
lW = max(8, round(W * scale))
lH = max(8, round(H * scale))
if scale < 1.0:
c = cv2.resize(canvas, (lW, lH), interpolation=cv2.INTER_AREA)
m = cv2.resize(mask, (lW, lH), interpolation=cv2.INTER_NEAREST)
else:
c, m = canvas.copy(), mask.copy()
m = (m > 127).astype(np.uint8) * 255
result_bgr = lama._pad_forward(c, m, inpaint_cfg)
result_bgr = np.clip(result_bgr, 0, 255).astype(np.uint8)
result_rgb = cv2.cvtColor(result_bgr, cv2.COLOR_BGR2RGB)
if scale < 1.0:
result_rgb = cv2.resize(result_rgb, (W, H), interpolation=cv2.INTER_LANCZOS4)
return result_rgb
def fill_lama(src: Image.Image, ox: int, oy: int, W: int, H: int, blend_radius: int) -> Image.Image:
"""
Content-aware outpainting using a 3-tier fallback chain.
Tier 1 β€” LaMa tiled: fills extension zones in strips of ~300px per pass,
feeding each result back as context for the next. This avoids
asking LaMa to synthesise >30% of the image in one shot, which
causes blur and incoherence.
Tier 2 β€” OpenCV TELEA classical inpainting.
Tier 3 β€” Edge-extend fallback (always available).
"""
sw, sh = src.width, src.height
has_alpha = src.mode == "RGBA"
src_rgb = np.array(src.convert("RGB"), dtype=np.uint8)
# ── Clamped source placement bounds ─────────────────────────────────────
dst_x1 = max(ox, 0); dst_x2 = min(ox + sw, W)
dst_y1 = max(oy, 0); dst_y2 = min(oy + sh, H)
src_x1 = dst_x1 - ox; src_x2 = dst_x2 - ox
src_y1 = dst_y1 - oy; src_y2 = dst_y2 - oy
needs_fill = dst_x1 > 0 or dst_y1 > 0 or dst_x2 < W or dst_y2 < H
if not needs_fill:
return src
# ── 1. Edge-extended canvas as starting point ────────────────────────────
canvas = _build_edge_canvas(src_rgb, ox, oy, W, H, sw, sh)
filled_up: np.ndarray | None = None
# ── Tier 1: LaMa tiled multi-pass ────────────────────────────────────────
lama = _get_lama_model()
if lama is not None:
try:
from iopaint.schema import InpaintRequest
try:
from iopaint.schema import HDStrategy
hd_strategy = HDStrategy.Original
except (ImportError, AttributeError):
hd_strategy = "Original"
inpaint_cfg = InpaintRequest(hd_strategy=hd_strategy)
# TILE_STEP: pixels to expand per pass.
# We use distance-from-source-edge to determine pass order β€”
# no scipy binary_dilation needed (avoids giant kernel OOM).
TILE_STEP = 300
current = canvas.copy()
# Compute per-pixel Chebyshev distance from the known source rect.
# Distance 0 = inside source, distance N = N px away from edge.
ys, xs = np.mgrid[0:H, 0:W]
if dst_x2 > dst_x1 and dst_y2 > dst_y1:
dx = np.maximum(0, np.maximum(dst_x1 - xs, xs - (dst_x2 - 1)))
dy = np.maximum(0, np.maximum(dst_y1 - ys, ys - (dst_y2 - 1)))
dist = np.maximum(dx, dy) # Chebyshev distance
else:
dist = np.ones((H, W), dtype=np.int32) * max(W, H)
total_fill = int((dist > 0).sum())
if total_fill == 0:
filled_up = canvas.astype(np.float32)
else:
max_dist = int(dist.max())
passes = 0
for step_start in range(0, max_dist, TILE_STEP):
step_end = step_start + TILE_STEP
# Mask: pixels in this distance band (not yet filled)
strip_mask = (dist > step_start) & (dist <= step_end)
# Full fill mask: this strip + anything beyond (context for LaMa)
full_mask = (dist > step_start)
if not strip_mask.any():
break
mask_pass = full_mask.astype(np.uint8) * 255
result = _lama_inpaint_once(lama, current, mask_pass, inpaint_cfg)
# Commit only the strip pixels; keep closer-to-source pixels exact
current[strip_mask] = result[strip_mask]
passes += 1
print(f"[INFO] LaMa pass {passes}: dist {step_start}β†’{step_end}px, "
f"{strip_mask.sum()} px filled")
# Re-stamp exact source pixels (done below too, belt+braces)
if dst_x2 > dst_x1 and dst_y2 > dst_y1:
current[dst_y1:dst_y2, dst_x1:dst_x2] = src_rgb[src_y1:src_y2, src_x1:src_x2]
# Re-stamp exact source pixels
if dst_x2 > dst_x1 and dst_y2 > dst_y1:
current[dst_y1:dst_y2, dst_x1:dst_x2] = src_rgb[src_y1:src_y2, src_x1:src_x2]
filled_up = current.astype(np.float32)
print(f"[INFO] AI fill: LaMa tiled inpainting used ({passes} passes)")
except Exception as e:
import traceback
print(f"[WARN] LaMa inpainting failed:\n{traceback.format_exc()}")
filled_up = None
# ── Tier 2: OpenCV TELEA inpainting ─────────────────────────────────────
if filled_up is None:
try:
mask_full = np.ones((H, W), dtype=np.uint8) * 255
if dst_x2 > dst_x1 and dst_y2 > dst_y1:
mask_full[dst_y1:dst_y2, dst_x1:dst_x2] = 0
MAX_DIM_CV = 512
scale_cv = min(MAX_DIM_CV / W, MAX_DIM_CV / H, 1.0)
cvW = max(8, round(W * scale_cv))
cvH = max(8, round(H * scale_cv))
small_cv = cv2.resize(canvas, (cvW, cvH), interpolation=cv2.INTER_AREA)
mask_cv = cv2.resize(mask_full, (cvW, cvH), interpolation=cv2.INTER_NEAREST)
mask_cv = (mask_cv > 127).astype(np.uint8) * 255
result_cv = cv2.inpaint(
cv2.cvtColor(small_cv, cv2.COLOR_RGB2BGR),
mask_cv, inpaintRadius=3, flags=cv2.INPAINT_TELEA
)
result_cv = cv2.cvtColor(result_cv, cv2.COLOR_BGR2RGB)
if scale_cv < 1.0:
filled_up = cv2.resize(
result_cv, (W, H), interpolation=cv2.INTER_LANCZOS4
).astype(np.float32)
else:
filled_up = result_cv.astype(np.float32)
print("[INFO] AI fill: OpenCV TELEA inpainting used (LaMa unavailable)")
except Exception as e:
print(f"[WARN] OpenCV TELEA failed ({e}), falling back to edge fill")
filled_up = None
# ── Tier 3: Edge-extend fallback ────────────────────────────────────────
if filled_up is None:
print("[INFO] AI fill: edge-extend fallback used")
return fill_seamless_pil(src, ox, oy, W, H, blend_radius)
# ── 4. Re-stamp exact source pixels ─────────────────────────────────────
if dst_x2 > dst_x1 and dst_y2 > dst_y1:
filled_up[dst_y1:dst_y2, dst_x1:dst_x2] = \
src_rgb[src_y1:src_y2, src_x1:src_x2].astype(np.float32)
# ── 5. Seam blend: fade from LaMa fill β†’ exact source pixels ────────────
# d_v = distance from the nearest edge of the placed region (0 at seam, grows inward)
# t=0 at seam (keep LaMa fill), t=1 at blend_r pixels inside (full source)
blend_r = max(8, min(blend_radius, 60))
sy_start = dst_y1
sx_start = dst_x1
ey = dst_y2
ex = dst_x2
if ey > sy_start and ex > sx_start:
ys_i, xs_i = np.mgrid[sy_start:ey, sx_start:ex]
dx_v = np.minimum(xs_i - sx_start, (ex - 1) - xs_i)
dy_v = np.minimum(ys_i - sy_start, (ey - 1) - ys_i)
d_v = np.minimum(dx_v, dy_v).astype(np.float32)
# t=0 β†’ seam edge (use LaMa), t=1 β†’ interior (use source)
t_v = np.clip(d_v / blend_r, 0.0, 1.0)
t_v = t_v * t_v * (3.0 - 2.0 * t_v) # smoothstep
tm = t_v[:, :, np.newaxis]
src_patch = src_rgb[src_y1:src_y2, src_x1:src_x2].astype(np.float32)
fill_patch = filled_up[sy_start:ey, sx_start:ex].copy()
# At seam (t=0): fill_patch (LaMa). At interior (t=1): src_patch.
filled_up[sy_start:ey, sx_start:ex] = src_patch * tm + fill_patch * (1.0 - tm)
result = np.clip(filled_up, 0, 255).astype(np.uint8)
out = Image.fromarray(result)
return out if not has_alpha else out.convert("RGBA")
def _build_edge_canvas(
src_rgb: np.ndarray, ox: int, oy: int, W: int, H: int, sw: int, sh: int
) -> np.ndarray:
"""
Place src_rgb at (ox, oy) on a WΓ—H canvas and flood every extension zone
by clamping to the nearest source edge pixel. Vectorised with NumPy.
Handles negative ox/oy (source larger than canvas on that axis).
"""
canvas = np.empty((H, W, 3), dtype=np.uint8)
ys = np.arange(H, dtype=np.int32)
xs = np.arange(W, dtype=np.int32)
sy = np.clip(ys - oy, 0, sh - 1) # (H,)
sx = np.clip(xs - ox, 0, sw - 1) # (W,)
# Broadcast fill: each row y gets src[sy[y], sx[:]]
canvas[:, :] = src_rgb[sy[:, None], sx[None, :]]
# Overwrite the known (visible) region with exact source pixels.
# Must clamp to canvas bounds when ox/oy are negative.
dst_x1 = max(ox, 0); dst_x2 = min(ox + sw, W)
dst_y1 = max(oy, 0); dst_y2 = min(oy + sh, H)
src_x1 = dst_x1 - ox; src_x2 = dst_x2 - ox
src_y1 = dst_y1 - oy; src_y2 = dst_y2 - oy
if dst_x2 > dst_x1 and dst_y2 > dst_y1:
canvas[dst_y1:dst_y2, dst_x1:dst_x2] = src_rgb[src_y1:src_y2, src_x1:src_x2]
return canvas
# ═══════════════════════════════════════
# PIXEL SALIENCY CENTER
# Mirrors pixelSaliencyCenter() from script.js
# ═══════════════════════════════════════
def pixel_saliency_center(img: Image.Image) -> tuple:
"""Return (fx, fy) normalised focal point via pixel saliency."""
TW = 80
TH = max(1, round(img.height / img.width * 80))
small = img.resize((TW, TH), Image.LANCZOS).convert("RGB")
arr = np.array(small, dtype=np.float32)
r_ch = arr[:, :, 0]; g_ch = arr[:, :, 1]; b_ch = arr[:, :, 2]
lum = 0.299 * r_ch + 0.587 * g_ch + 0.114 * b_ch
# Colour distance from mean
mr, mg, mb = r_ch.mean(), g_ch.mean(), b_ch.mean()
col_dist = np.sqrt((r_ch - mr)**2 + (g_ch - mg)**2 + (b_ch - mb)**2)
# Edge magnitude (Sobel)
gx = cv2.Sobel(lum, cv2.CV_32F, 1, 0, ksize=3)
gy = cv2.Sobel(lum, cv2.CV_32F, 0, 1, ksize=3)
edges = np.sqrt(gx**2 + gy**2)
# Local contrast (std in 3Γ—3) β€” vectorised via strided view
from numpy.lib.stride_tricks import sliding_window_view
windows = sliding_window_view(lum, (3, 3)) # (TH-2, TW-2, 3, 3)
local_c = np.zeros_like(lum)
local_c[1:TH-1, 1:TW-1] = windows.reshape(windows.shape[0], windows.shape[1], -1).std(axis=-1)
def norm(a):
mx = a.max()
return a / mx if mx > 1e-6 else a
sal = norm(col_dist) * 0.45 + norm(edges) * 0.30 + norm(local_c) * 0.25
# Centre bias
ys, xs = np.mgrid[0:TH, 0:TW]
cx = np.abs(xs / TW - 0.5) * 2
cy = np.abs(ys / TH - 0.5) * 2
sal *= (1 - np.maximum(cx, cy) * 0.20)
# Gaussian blur
blurred = gaussian_filter(sal, sigma=6 * 0.45 + 0.5)
thresh = blurred.max() * 0.60
mask = blurred >= thresh
if mask.sum() < 1:
return 0.5, 0.4
sw_sum = blurred[mask].sum()
fy_val = (np.where(mask)[0] * blurred[mask]).sum() / sw_sum / TH
fx_val = (np.where(mask)[1] * blurred[mask]).sum() / sw_sum / TW
return float(fx_val), float(fy_val)
# ═══════════════════════════════════════
# ENCODERS
# ═══════════════════════════════════════
def encode_webp(img: Image.Image, quality: float, max_kb: int) -> bytes:
"""
Encode to WebP, iteratively reducing quality if > max_kb.
Mirrors encodeWebP() from script.js.
"""
q = int(quality * 100) if quality <= 1.0 else int(quality)
q = max(35, min(100, q))
max_bytes = max_kb * 1024
for _ in range(20):
buf = io.BytesIO()
save_img = img.convert("RGB") if img.mode == "RGBA" else img
save_img.save(buf, format="WEBP", quality=q, method=4)
data = buf.getvalue()
if len(data) <= max_bytes or q <= 35:
return data
q = max(35, q - 5)
return data
def encode_png(img: Image.Image) -> bytes:
"""Lossless PNG encode (for RGBA transparency)."""
buf = io.BytesIO()
img.save(buf, format="PNG", optimize=True)
return buf.getvalue()
# ═══════════════════════════════════════
# HELPERS
# ═══════════════════════════════════════
def _img_size(img: Image.Image) -> str:
return f"{img.width}Γ—{img.height}"
def _hex_to_rgb(hex_color: str) -> tuple:
h = hex_color.lstrip("#")
return tuple(int(h[i:i+2], 16) for i in (0, 2, 4))
def _blurred_background(img: Image.Image, W: int, H: int) -> Image.Image:
"""Scale-to-cover, then heavy blur + darken. Mirrors drawBlurredBackground()."""
sw, sh = img.width, img.height
cover = max(W / sw, H / sh)
cw, ch = round(sw * cover), round(sh * cover)
big = img.resize((cw, ch), Image.LANCZOS).convert("RGB")
ox, oy = (cw - W) // 2, (ch - H) // 2
bg = big.crop((ox, oy, ox + W, oy + H))
bg = bg.filter(ImageFilter.GaussianBlur(radius=24))
# Darken
arr = np.array(bg, dtype=np.float32)
arr = arr * 0.6
return Image.fromarray(arr.clip(0, 255).astype(np.uint8), "RGB")