abir614/i
0
1"""2IMGFLOW — Server-side image processing3Mirrors all three browser pipeline flows from script.js4 5Flow 1 — Standard: Lanczos upscale → Shopify resize → WebP encode6Flow 2 — No BG: rembg ISNet remove → edge refine → upscale → WebP/PNG7Flow 3 — Smart Resize: auto-detect crop/extend → fill → upscale → WebP8"""9 10import io11import math12import time13import numpy as np14from PIL import Image, ImageFilter15import cv216from scipy.ndimage import gaussian_filter17 18 19# ═══════════════════════════════════════20# FLOW 1 — STANDARD PIPELINE21# ═══════════════════════════════════════22 23def run_flow1(img: Image.Image, cfg: dict) -> dict:24 """Upscale → Shopify resize → WebP encode"""25 t0 = time.time()26 orig_size = _img_size(img)27 28 # 1. Upscale29 img = upscale(img, cfg["factor"], cfg["method"])30 after_up = _img_size(img)31 32 # 2. Shopify resize (cap longest side)33 img = shopify_resize(img, cfg["shopify"])34 after_sh = _img_size(img)35 36 # 3. Encode WebP37 blob = encode_webp(img, cfg["quality"], cfg["max_kb"])38 39 return {40 "blob": blob,41 "ext": "webp",42 "prefix": "shopify",43 "dims": f"{img.width}×{img.height}",44 "log": [45 f"upscaled {orig_size} → {after_up}",46 f"shopify resize → {after_sh}",47 f"webp encode → {len(blob)//1024} KB ({time.time()-t0:.1f}s)",48 ],49 }50 51 52# ═══════════════════════════════════════53# FLOW 2 — NO BACKGROUND54# ═══════════════════════════════════════55 56def run_flow2(img: Image.Image, cfg: dict) -> dict:57 """rembg ISNet BG removal → edge refine → upscale → WebP / PNG"""58 t0 = time.time()59 orig_size = _img_size(img)60 61 # 1. Background removal (lazy import so startup is fast when not used)62 try:63 from rembg import remove, new_session64 except ImportError as e:65 raise RuntimeError(66 f"rembg is not installed or has missing dependencies ({e}). "67 "Run: pip install packaging rembg[gpu]"68 ) from e69 session = new_session(cfg["bg_model"])70 img = remove(img, session=session) # returns RGBA PNG71 img = img.convert("RGBA")72 73 # 2. Edge refinement: alpha threshold + feathering74 img = refine_edges(img, cfg["alpha_threshold"], cfg["feather"])75 after_bg = _img_size(img)76 77 # 3. Upscale (preserve RGBA)78 img = upscale(img, cfg["factor"], cfg["method"])79 after_up = _img_size(img)80 81 # 4. Encode82 use_png = cfg.get("output_format", "webp") == "png"83 if use_png:84 blob = encode_png(img)85 ext = "png"86 else:87 blob = encode_webp(img, cfg["quality"], cfg["max_kb"])88 ext = "webp"89 90 return {91 "blob": blob,92 "ext": ext,93 "prefix": "nobg",94 "dims": f"{img.width}×{img.height}",95 "log": [96 f"BG removed → {after_bg}",97 f"upscaled → {after_up}",98 f"{ext} encode → {len(blob)//1024} KB ({time.time()-t0:.1f}s)",99 ],100 }101 102 103# ═══════════════════════════════════════104# FLOW 3 — SMART RESIZE105# ═══════════════════════════════════════106 107def run_flow3(img: Image.Image, cfg: dict) -> dict:108 """Smart Resize: detect → crop/extend → target dimensions → WebP"""109 t0 = time.time()110 orig_size = _img_size(img)111 tw, th = cfg["resize_w"], cfg["resize_h"]112 mode = cfg.get("resize_mode", "smart-crop-extend")113 114 if mode == "proportional":115 img, decision = proportional_resize(img, tw, th, cfg)116 else:117 img, decision = smart_resize(img, tw, th, cfg)118 119 after_resize = _img_size(img)120 121 # Encode122 blob = encode_webp(img, cfg["quality"], cfg["max_kb"])123 prefix = "fit" if mode == "proportional" else "resize"124 125 return {126 "blob": blob,127 "ext": "webp",128 "prefix": prefix,129 "dims": f"{img.width}×{img.height}",130 "log": [131 f"decision: {decision} {orig_size} → {after_resize}",132 f"webp encode → {len(blob)//1024} KB ({time.time()-t0:.1f}s)",133 ],134 }135 136 137# ═══════════════════════════════════════138# UPSCALE139# ═══════════════════════════════════════140 141def upscale(img: Image.Image, factor: float, method: str) -> Image.Image:142 """Lanczos-3 or bicubic upscale by factor."""143 if factor <= 1.0:144 return img145 nw = round(img.width * factor)146 nh = round(img.height * factor)147 resample = Image.LANCZOS if method == "lanczos" else Image.BICUBIC148 return img.resize((nw, nh), resample=resample)149 150 151def shopify_resize(img: Image.Image, max_dim: int) -> Image.Image:152 """Cap longest side to max_dim, preserve aspect ratio."""153 r = min(max_dim / img.width, max_dim / img.height, 1.0)154 if r >= 1.0:155 return img156 return img.resize((round(img.width * r), round(img.height * r)), Image.LANCZOS)157 158 159# ═══════════════════════════════════════160# EDGE REFINEMENT (Flow 2)161# ═══════════════════════════════════════162 163def refine_edges(img: Image.Image, alpha_threshold: int, feather: int) -> Image.Image:164 """Apply alpha threshold, erosion at boundary, and optional Gaussian feather."""165 arr = np.array(img) # H×W×4 uint8166 167 # 1. Hard threshold168 alpha = arr[:, :, 3].astype(np.float32)169 lo, hi = alpha_threshold, 255 - alpha_threshold170 alpha[alpha <= lo] = 0171 alpha[alpha >= hi] = 255172 173 # 2. Boundary erosion: shrink semi-transparent fringe174 binary = (alpha > 0).astype(np.uint8)175 kernel = np.ones((3, 3), np.uint8)176 eroded = cv2.erode(binary, kernel, iterations=1)177 fringe = (binary > 0) & (eroded == 0)178 alpha[fringe] = np.maximum(0, alpha[fringe] - 80)179 180 # 3. Optional Gaussian feather181 if feather > 0:182 alpha = gaussian_filter(alpha, sigma=feather * 0.45 + 0.5)183 184 arr[:, :, 3] = np.clip(alpha, 0, 255).astype(np.uint8)185 return Image.fromarray(arr, "RGBA")186 187 188# ═══════════════════════════════════════189# SMART RESIZE — crop + extend190# ═══════════════════════════════════════191 192def smart_resize(img: Image.Image, tw: int, th: int, cfg: dict):193 """194 Per-axis smart crop + extend.195 Mirrors smartResize() from script.js exactly.196 """197 sw, sh = img.width, img.height198 t_ar = tw / th199 s_ar = sw / sh200 focus = cfg.get("resize_focus", "smart")201 align = cfg.get("resize_align", "center")202 fill = cfg.get("resize_fill", "extend")203 blend = cfg.get("resize_blend", 40)204 color = cfg.get("fill_color", "#ffffff")205 206 # Detect focal point207 fx, fy = 0.5, 0.4208 if focus == "smart":209 fx, fy = pixel_saliency_center(img)210 else:211 fm = {"center": (.5, .5), "top": (.5, .15), "bottom": (.5, .85),212 "left": (.15, .5), "right": (.85, .5)}213 fx, fy = fm.get(focus, (.5, .5))214 215 # Determine crop region216 crop_w, crop_h = min(sw, tw), min(sh, th)217 crop_x, crop_y = 0, 0218 219 if sw > tw or sh > th:220 if s_ar > t_ar:221 crop_h = min(sh, th)222 crop_w = round(crop_h * t_ar)223 else:224 crop_w = min(sw, tw)225 crop_h = round(crop_w / t_ar)226 crop_w = min(crop_w, sw)227 crop_h = min(crop_h, sh)228 crop_x = round(fx * sw - crop_w / 2)229 crop_y = round(fy * sh - crop_h / 2)230 crop_x = max(0, min(sw - crop_w, crop_x))231 crop_y = max(0, min(sh - crop_h, crop_y))232 233 placed = img.crop((crop_x, crop_y, crop_x + crop_w, crop_y + crop_h))234 235 ox, oy = get_anchor_offset(crop_w, crop_h, tw, th, align)236 needs_fill = crop_w < tw or crop_h < th237 238 # Decision string for log239 if sw < tw and sh < th:240 decision = f"extend both axes → {tw}×{th}"241 elif sw >= tw and sh >= th:242 if abs(s_ar - t_ar) < 0.005:243 decision = f"scale → {tw}×{th}"244 elif s_ar > t_ar:245 decision = f"crop width (source wider) → {tw}×{th}"246 else:247 decision = f"crop height (source taller) → {tw}×{th}"248 else:249 decision = f"mixed crop+extend → {tw}×{th}"250 251 if not needs_fill:252 out = placed.resize((tw, th), Image.LANCZOS) if placed.size != (tw, th) else placed253 return out, decision254 255 # Build output canvas256 has_alpha = img.mode == "RGBA"257 mode = "RGBA" if (has_alpha or fill == "transparent") else "RGB"258 out = Image.new(mode, (tw, th))259 260 if fill == "extend":261 out = fill_seamless_pil(placed, ox, oy, tw, th, blend)262 elif fill == "white":263 out = Image.new(mode, (tw, th), (255, 255, 255, 255) if mode == "RGBA" else (255, 255, 255))264 out.paste(placed, (ox, oy))265 elif fill == "black":266 out = Image.new(mode, (tw, th), (0, 0, 0, 255) if mode == "RGBA" else (0, 0, 0))267 out.paste(placed, (ox, oy))268 elif fill == "transparent":269 out = Image.new("RGBA", (tw, th), (0, 0, 0, 0))270 out.paste(placed, (ox, oy))271 elif fill == "color":272 rgb = _hex_to_rgb(color)273 out = Image.new(mode, (tw, th), rgb)274 out.paste(placed, (ox, oy))275 elif fill == "ai-extend":276 out = fill_lama(placed, ox, oy, tw, th, blend)277 else:278 # fallback: edge extend279 out = fill_seamless_pil(placed, ox, oy, tw, th, blend)280 281 return out, decision282 283 284def proportional_resize(img: Image.Image, tw: int, th: int, cfg: dict):285 """Scale to fit within target, then pad. Mirrors proportionalResize()."""286 sw, sh = img.width, img.height287 ratio = min(tw / sw, th / sh)288 fit_w = round(sw * ratio)289 fit_h = round(sh * ratio)290 scaled = img.resize((fit_w, fit_h), Image.LANCZOS)291 292 fill = cfg.get("resize_fill", "extend")293 align = cfg.get("resize_align", "center")294 color = cfg.get("fill_color", "#ffffff")295 blend = cfg.get("resize_blend", 40)296 297 ox, oy = get_anchor_offset(fit_w, fit_h, tw, th, align)298 mode = "RGBA" if (img.mode == "RGBA" or fill == "transparent") else "RGB"299 300 if fill == "blur":301 out = _blurred_background(img, tw, th)302 out.paste(scaled, (ox, oy))303 elif fill == "white":304 out = Image.new(mode, (tw, th), (255, 255, 255))305 out.paste(scaled, (ox, oy))306 elif fill == "black":307 out = Image.new(mode, (tw, th), (0, 0, 0))308 out.paste(scaled, (ox, oy))309 elif fill == "transparent":310 out = Image.new("RGBA", (tw, th), (0, 0, 0, 0))311 out.paste(scaled, (ox, oy))312 elif fill == "color":313 out = Image.new(mode, (tw, th), _hex_to_rgb(color))314 out.paste(scaled, (ox, oy))315 elif fill == "extend":316 out = fill_seamless_pil(scaled, ox, oy, tw, th, blend)317 elif fill == "ai-extend":318 out = fill_lama(scaled, ox, oy, tw, th, blend)319 else:320 out = fill_seamless_pil(scaled, ox, oy, tw, th, blend)321 322 decision = f"proportional fit: {fit_w}×{fit_h} + padding → {tw}×{th}"323 return out, decision324 325 326def get_anchor_offset(sw: int, sh: int, W: int, H: int, align: str):327 cx = (W - sw) // 2328 cy = (H - sh) // 2329 bx, by = W - sw, H - sh330 return {331 "center": (cx, cy),332 "top-left": (0, 0),333 "top-center": (cx, 0),334 "top-right": (bx, 0),335 "middle-left": (0, cy),336 "middle-right": (bx, cy),337 "bottom-left": (0, by),338 "bottom-center": (cx, by),339 "bottom-right": (bx, by),340 }.get(align, (cx, cy))341 342 343# ═══════════════════════════════════════344# SEAMLESS EXTENSION (edge pixel fill)345# Mirrors fillSeamless() from script.js346# ═══════════════════════════════════════347 348def fill_seamless_pil(src: Image.Image, ox: int, oy: int, W: int, H: int, blend_radius: int) -> Image.Image:349 """350 Place src at (ox,oy) on a W×H canvas.351 Fill extension zones by sampling nearby edge pixels of src (weighted average).352 Fully vectorised with NumPy — no Python pixel loops.353 """354 sw, sh = src.width, src.height355 has_alpha = src.mode == "RGBA"356 src_arr = np.array(src.convert("RGBA") if not has_alpha else src, dtype=np.float32)357 358 STRIP = max(6, min(blend_radius, int(min(sw, sh) * 0.18)))359 weights = np.array([((STRIP - k) / STRIP) ** 1.5 for k in range(STRIP)], dtype=np.float32)360 total_w = float(weights.sum())361 362 # Coordinate grids for full output canvas363 ys, xs = np.mgrid[0:H, 0:W]364 rx = xs - ox365 ry = ys - oy366 in_x = (rx >= 0) & (rx < sw)367 in_y = (ry >= 0) & (ry < sh)368 inside = in_x & in_y369 370 # Clamped source coords (used for interior copy and per-axis clamping)371 sx_clip = np.clip(rx, 0, sw - 1).astype(np.int32)372 sy_clip = np.clip(ry, 0, sh - 1).astype(np.int32)373 374 out_arr = np.zeros((H, W, 4), dtype=np.float32)375 376 # Interior: direct copy377 out_arr[inside] = src_arr[sy_clip[inside], sx_clip[inside]]378 379 # Exterior: weighted strip average — one vectorised pass per k380 exterior = ~inside381 if exterior.any():382 accum = np.zeros((H, W, 4), dtype=np.float32)383 for k in range(STRIP):384 w = weights[k]385 sx_k = np.where(rx < 0, np.minimum(k, sw - 1), np.maximum(sw - 1 - k, 0)).astype(np.int32)386 sy_k = np.where(ry < 0, np.minimum(k, sh - 1), np.maximum(sh - 1 - k, 0)).astype(np.int32)387 # Clamp the in-bounds axis to its natural position388 sx_k = np.where(in_x, sx_clip, sx_k)389 sy_k = np.where(in_y, sy_clip, sy_k)390 accum += src_arr[sy_k, sx_k] * w391 accum /= total_w392 out_arr[exterior] = accum[exterior]393 394 if blend_radius > 0:395 _blend_seam(out_arr, ox, oy, sw, sh, W, H, blend_radius)396 397 out = Image.fromarray(np.clip(out_arr, 0, 255).astype(np.uint8), "RGBA")398 return out if has_alpha else out.convert("RGB")399 400 401def _blend_seam(arr: np.ndarray, ox: int, oy: int, sw: int, sh: int, W: int, H: int, radius: int):402 """Smooth the seam between placed image and fill zone. Vectorised."""403 x1, y1 = ox, oy404 x2, y2 = min(ox + sw, W), min(oy + sh, H)405 if x1 >= x2 or y1 >= y2:406 return407 408 ys, xs = np.mgrid[y1:y2, x1:x2]409 dx = np.minimum(xs - ox, ox + sw - 1 - xs)410 dy = np.minimum(ys - oy, oy + sh - 1 - ys)411 d = np.minimum(dx, dy)412 413 blend_mask = d < radius414 if not blend_mask.any():415 return416 417 t = np.where(blend_mask, d / radius, 1.0)418 smooth = t * t * (3 - 2 * t) # smoothstep419 420 # Neighbour coordinates (the fill-zone pixel on the other side of the seam)421 nx = np.where(dx <= dy,422 np.where(xs < ox + sw // 2, ox - 1, ox + sw),423 xs)424 ny = np.where(dx > dy,425 np.where(ys < oy + sh // 2, oy - 1, oy + sh),426 ys)427 nx = np.clip(nx, 0, W - 1)428 ny = np.clip(ny, 0, H - 1)429 430 sm = smooth[:, :, np.newaxis] # (h, w, 1) for broadcast431 neighbour = arr[ny, nx] # (h, w, 4)432 blended = neighbour * (1 - sm) + arr[y1:y2, x1:x2] * sm433 arr[y1:y2, x1:x2] = np.where(blend_mask[:, :, np.newaxis], blended, arr[y1:y2, x1:x2])434 435 436# ═══════════════════════════════════════437# AI FILL — LaMa Inpainting via iopaint438# ═══════════════════════════════════════439 440# Module-level singleton so the model is loaded once per process441_lama_model = None442 443def _get_lama_model():444 """Lazy-load the LaMa model singleton. Returns None if unavailable."""445 global _lama_model446 if _lama_model is not None:447 return _lama_model448 try:449 import torch450 from iopaint.model.lama import LaMa451 from iopaint.schema import InpaintRequest452 453 device = torch.device("cuda" if torch.cuda.is_available() else "cpu")454 _lama_model = LaMa(device)455 print(f"[INFO] LaMa model loaded on {device}")456 return _lama_model457 except Exception as e:458 print(f"[WARN] LaMa unavailable ({e})")459 return None460 461 462def _lama_inpaint_once(lama, canvas: np.ndarray, mask: np.ndarray, inpaint_cfg) -> np.ndarray:463 """464 Run one LaMa pass at a safe resolution.465 canvas: H×W×3 RGB uint8. mask: H×W uint8 (255=fill, 0=known).466 Returns RGB uint8.467 """468 H, W = canvas.shape[:2]469 MAX_DIM = 1024470 scale = min(MAX_DIM / W, MAX_DIM / H, 1.0)471 lW = max(8, round(W * scale))472 lH = max(8, round(H * scale))473 474 if scale < 1.0:475 c = cv2.resize(canvas, (lW, lH), interpolation=cv2.INTER_AREA)476 m = cv2.resize(mask, (lW, lH), interpolation=cv2.INTER_NEAREST)477 else:478 c, m = canvas.copy(), mask.copy()479 480 m = (m > 127).astype(np.uint8) * 255481 result_bgr = lama._pad_forward(c, m, inpaint_cfg)482 result_bgr = np.clip(result_bgr, 0, 255).astype(np.uint8)483 result_rgb = cv2.cvtColor(result_bgr, cv2.COLOR_BGR2RGB)484 485 if scale < 1.0:486 result_rgb = cv2.resize(result_rgb, (W, H), interpolation=cv2.INTER_LANCZOS4)487 488 return result_rgb489 490 491def fill_lama(src: Image.Image, ox: int, oy: int, W: int, H: int, blend_radius: int) -> Image.Image:492 """493 Content-aware outpainting using a 3-tier fallback chain.494 495 Tier 1 — LaMa tiled: fills extension zones in strips of ~300px per pass,496 feeding each result back as context for the next. This avoids497 asking LaMa to synthesise >30% of the image in one shot, which498 causes blur and incoherence.499 Tier 2 — OpenCV TELEA classical inpainting.500 Tier 3 — Edge-extend fallback (always available).501 """502 sw, sh = src.width, src.height503 has_alpha = src.mode == "RGBA"504 src_rgb = np.array(src.convert("RGB"), dtype=np.uint8)505 506 # ── Clamped source placement bounds ─────────────────────────────────────507 dst_x1 = max(ox, 0); dst_x2 = min(ox + sw, W)508 dst_y1 = max(oy, 0); dst_y2 = min(oy + sh, H)509 src_x1 = dst_x1 - ox; src_x2 = dst_x2 - ox510 src_y1 = dst_y1 - oy; src_y2 = dst_y2 - oy511 512 needs_fill = dst_x1 > 0 or dst_y1 > 0 or dst_x2 < W or dst_y2 < H513 if not needs_fill:514 return src515 516 # ── 1. Edge-extended canvas as starting point ────────────────────────────517 canvas = _build_edge_canvas(src_rgb, ox, oy, W, H, sw, sh)518 519 filled_up: np.ndarray | None = None520 521 # ── Tier 1: LaMa tiled multi-pass ────────────────────────────────────────522 lama = _get_lama_model()523 if lama is not None:524 try:525 from iopaint.schema import InpaintRequest526 try:527 from iopaint.schema import HDStrategy528 hd_strategy = HDStrategy.Original529 except (ImportError, AttributeError):530 hd_strategy = "Original"531 532 inpaint_cfg = InpaintRequest(hd_strategy=hd_strategy)533 534 # TILE_STEP: pixels to expand per pass.535 # We use distance-from-source-edge to determine pass order —536 # no scipy binary_dilation needed (avoids giant kernel OOM).537 TILE_STEP = 300538 current = canvas.copy()539 540 # Compute per-pixel Chebyshev distance from the known source rect.541 # Distance 0 = inside source, distance N = N px away from edge.542 ys, xs = np.mgrid[0:H, 0:W]543 if dst_x2 > dst_x1 and dst_y2 > dst_y1:544 dx = np.maximum(0, np.maximum(dst_x1 - xs, xs - (dst_x2 - 1)))545 dy = np.maximum(0, np.maximum(dst_y1 - ys, ys - (dst_y2 - 1)))546 dist = np.maximum(dx, dy) # Chebyshev distance547 else:548 dist = np.ones((H, W), dtype=np.int32) * max(W, H)549 550 total_fill = int((dist > 0).sum())551 if total_fill == 0:552 filled_up = canvas.astype(np.float32)553 else:554 max_dist = int(dist.max())555 passes = 0556 557 for step_start in range(0, max_dist, TILE_STEP):558 step_end = step_start + TILE_STEP559 # Mask: pixels in this distance band (not yet filled)560 strip_mask = (dist > step_start) & (dist <= step_end)561 # Full fill mask: this strip + anything beyond (context for LaMa)562 full_mask = (dist > step_start)563 564 if not strip_mask.any():565 break566 567 mask_pass = full_mask.astype(np.uint8) * 255568 result = _lama_inpaint_once(lama, current, mask_pass, inpaint_cfg)569 570 # Commit only the strip pixels; keep closer-to-source pixels exact571 current[strip_mask] = result[strip_mask]572 passes += 1573 print(f"[INFO] LaMa pass {passes}: dist {step_start}→{step_end}px, "574 f"{strip_mask.sum()} px filled")575 576 # Re-stamp exact source pixels (done below too, belt+braces)577 if dst_x2 > dst_x1 and dst_y2 > dst_y1:578 current[dst_y1:dst_y2, dst_x1:dst_x2] = src_rgb[src_y1:src_y2, src_x1:src_x2]579 580 # Re-stamp exact source pixels581 if dst_x2 > dst_x1 and dst_y2 > dst_y1:582 current[dst_y1:dst_y2, dst_x1:dst_x2] = src_rgb[src_y1:src_y2, src_x1:src_x2]583 584 filled_up = current.astype(np.float32)585 print(f"[INFO] AI fill: LaMa tiled inpainting used ({passes} passes)")586 587 except Exception as e:588 import traceback589 print(f"[WARN] LaMa inpainting failed:\n{traceback.format_exc()}")590 filled_up = None591 592 # ── Tier 2: OpenCV TELEA inpainting ─────────────────────────────────────593 if filled_up is None:594 try:595 mask_full = np.ones((H, W), dtype=np.uint8) * 255596 if dst_x2 > dst_x1 and dst_y2 > dst_y1:597 mask_full[dst_y1:dst_y2, dst_x1:dst_x2] = 0598 599 MAX_DIM_CV = 512600 scale_cv = min(MAX_DIM_CV / W, MAX_DIM_CV / H, 1.0)601 cvW = max(8, round(W * scale_cv))602 cvH = max(8, round(H * scale_cv))603 604 small_cv = cv2.resize(canvas, (cvW, cvH), interpolation=cv2.INTER_AREA)605 mask_cv = cv2.resize(mask_full, (cvW, cvH), interpolation=cv2.INTER_NEAREST)606 mask_cv = (mask_cv > 127).astype(np.uint8) * 255607 608 result_cv = cv2.inpaint(609 cv2.cvtColor(small_cv, cv2.COLOR_RGB2BGR),610 mask_cv, inpaintRadius=3, flags=cv2.INPAINT_TELEA611 )612 result_cv = cv2.cvtColor(result_cv, cv2.COLOR_BGR2RGB)613 614 if scale_cv < 1.0:615 filled_up = cv2.resize(616 result_cv, (W, H), interpolation=cv2.INTER_LANCZOS4617 ).astype(np.float32)618 else:619 filled_up = result_cv.astype(np.float32)620 621 print("[INFO] AI fill: OpenCV TELEA inpainting used (LaMa unavailable)")622 except Exception as e:623 print(f"[WARN] OpenCV TELEA failed ({e}), falling back to edge fill")624 filled_up = None625 626 # ── Tier 3: Edge-extend fallback ────────────────────────────────────────627 if filled_up is None:628 print("[INFO] AI fill: edge-extend fallback used")629 return fill_seamless_pil(src, ox, oy, W, H, blend_radius)630 631 # ── 4. Re-stamp exact source pixels ─────────────────────────────────────632 if dst_x2 > dst_x1 and dst_y2 > dst_y1:633 filled_up[dst_y1:dst_y2, dst_x1:dst_x2] = \634 src_rgb[src_y1:src_y2, src_x1:src_x2].astype(np.float32)635 636 # ── 5. Seam blend: fade from LaMa fill → exact source pixels ────────────637 # d_v = distance from the nearest edge of the placed region (0 at seam, grows inward)638 # t=0 at seam (keep LaMa fill), t=1 at blend_r pixels inside (full source)639 blend_r = max(8, min(blend_radius, 60))640 sy_start = dst_y1641 sx_start = dst_x1642 ey = dst_y2643 ex = dst_x2644 645 if ey > sy_start and ex > sx_start:646 ys_i, xs_i = np.mgrid[sy_start:ey, sx_start:ex]647 dx_v = np.minimum(xs_i - sx_start, (ex - 1) - xs_i)648 dy_v = np.minimum(ys_i - sy_start, (ey - 1) - ys_i)649 d_v = np.minimum(dx_v, dy_v).astype(np.float32)650 # t=0 → seam edge (use LaMa), t=1 → interior (use source)651 t_v = np.clip(d_v / blend_r, 0.0, 1.0)652 t_v = t_v * t_v * (3.0 - 2.0 * t_v) # smoothstep653 654 tm = t_v[:, :, np.newaxis]655 src_patch = src_rgb[src_y1:src_y2, src_x1:src_x2].astype(np.float32)656 fill_patch = filled_up[sy_start:ey, sx_start:ex].copy()657 # At seam (t=0): fill_patch (LaMa). At interior (t=1): src_patch.658 filled_up[sy_start:ey, sx_start:ex] = src_patch * tm + fill_patch * (1.0 - tm)659 660 result = np.clip(filled_up, 0, 255).astype(np.uint8)661 out = Image.fromarray(result)662 return out if not has_alpha else out.convert("RGBA")663 664 665def _build_edge_canvas(666 src_rgb: np.ndarray, ox: int, oy: int, W: int, H: int, sw: int, sh: int667) -> np.ndarray:668 """669 Place src_rgb at (ox, oy) on a W×H canvas and flood every extension zone670 by clamping to the nearest source edge pixel. Vectorised with NumPy.671 672 Handles negative ox/oy (source larger than canvas on that axis).673 """674 canvas = np.empty((H, W, 3), dtype=np.uint8)675 676 ys = np.arange(H, dtype=np.int32)677 xs = np.arange(W, dtype=np.int32)678 sy = np.clip(ys - oy, 0, sh - 1) # (H,)679 sx = np.clip(xs - ox, 0, sw - 1) # (W,)680 681 # Broadcast fill: each row y gets src[sy[y], sx[:]]682 canvas[:, :] = src_rgb[sy[:, None], sx[None, :]]683 684 # Overwrite the known (visible) region with exact source pixels.685 # Must clamp to canvas bounds when ox/oy are negative.686 dst_x1 = max(ox, 0); dst_x2 = min(ox + sw, W)687 dst_y1 = max(oy, 0); dst_y2 = min(oy + sh, H)688 src_x1 = dst_x1 - ox; src_x2 = dst_x2 - ox689 src_y1 = dst_y1 - oy; src_y2 = dst_y2 - oy690 691 if dst_x2 > dst_x1 and dst_y2 > dst_y1:692 canvas[dst_y1:dst_y2, dst_x1:dst_x2] = src_rgb[src_y1:src_y2, src_x1:src_x2]693 694 return canvas695 696 697# ═══════════════════════════════════════698# PIXEL SALIENCY CENTER699# Mirrors pixelSaliencyCenter() from script.js700# ═══════════════════════════════════════701 702def pixel_saliency_center(img: Image.Image) -> tuple:703 """Return (fx, fy) normalised focal point via pixel saliency."""704 TW = 80705 TH = max(1, round(img.height / img.width * 80))706 small = img.resize((TW, TH), Image.LANCZOS).convert("RGB")707 arr = np.array(small, dtype=np.float32)708 709 r_ch = arr[:, :, 0]; g_ch = arr[:, :, 1]; b_ch = arr[:, :, 2]710 lum = 0.299 * r_ch + 0.587 * g_ch + 0.114 * b_ch711 712 # Colour distance from mean713 mr, mg, mb = r_ch.mean(), g_ch.mean(), b_ch.mean()714 col_dist = np.sqrt((r_ch - mr)**2 + (g_ch - mg)**2 + (b_ch - mb)**2)715 716 # Edge magnitude (Sobel)717 gx = cv2.Sobel(lum, cv2.CV_32F, 1, 0, ksize=3)718 gy = cv2.Sobel(lum, cv2.CV_32F, 0, 1, ksize=3)719 edges = np.sqrt(gx**2 + gy**2)720 721 # Local contrast (std in 3×3) — vectorised via strided view722 from numpy.lib.stride_tricks import sliding_window_view723 windows = sliding_window_view(lum, (3, 3)) # (TH-2, TW-2, 3, 3)724 local_c = np.zeros_like(lum)725 local_c[1:TH-1, 1:TW-1] = windows.reshape(windows.shape[0], windows.shape[1], -1).std(axis=-1)726 727 def norm(a):728 mx = a.max()729 return a / mx if mx > 1e-6 else a730 731 sal = norm(col_dist) * 0.45 + norm(edges) * 0.30 + norm(local_c) * 0.25732 733 # Centre bias734 ys, xs = np.mgrid[0:TH, 0:TW]735 cx = np.abs(xs / TW - 0.5) * 2736 cy = np.abs(ys / TH - 0.5) * 2737 sal *= (1 - np.maximum(cx, cy) * 0.20)738 739 # Gaussian blur740 blurred = gaussian_filter(sal, sigma=6 * 0.45 + 0.5)741 thresh = blurred.max() * 0.60742 mask = blurred >= thresh743 744 if mask.sum() < 1:745 return 0.5, 0.4746 747 sw_sum = blurred[mask].sum()748 fy_val = (np.where(mask)[0] * blurred[mask]).sum() / sw_sum / TH749 fx_val = (np.where(mask)[1] * blurred[mask]).sum() / sw_sum / TW750 return float(fx_val), float(fy_val)751 752 753# ═══════════════════════════════════════754# ENCODERS755# ═══════════════════════════════════════756 757def encode_webp(img: Image.Image, quality: float, max_kb: int) -> bytes:758 """759 Encode to WebP, iteratively reducing quality if > max_kb.760 Mirrors encodeWebP() from script.js.761 """762 q = int(quality * 100) if quality <= 1.0 else int(quality)763 q = max(35, min(100, q))764 max_bytes = max_kb * 1024765 766 for _ in range(20):767 buf = io.BytesIO()768 save_img = img.convert("RGB") if img.mode == "RGBA" else img769 save_img.save(buf, format="WEBP", quality=q, method=4)770 data = buf.getvalue()771 if len(data) <= max_bytes or q <= 35:772 return data773 q = max(35, q - 5)774 775 return data776 777 778def encode_png(img: Image.Image) -> bytes:779 """Lossless PNG encode (for RGBA transparency)."""780 buf = io.BytesIO()781 img.save(buf, format="PNG", optimize=True)782 return buf.getvalue()783 784 785# ═══════════════════════════════════════786# HELPERS787# ═══════════════════════════════════════788 789def _img_size(img: Image.Image) -> str:790 return f"{img.width}×{img.height}"791 792def _hex_to_rgb(hex_color: str) -> tuple:793 h = hex_color.lstrip("#")794 return tuple(int(h[i:i+2], 16) for i in (0, 2, 4))795 796def _blurred_background(img: Image.Image, W: int, H: int) -> Image.Image:797 """Scale-to-cover, then heavy blur + darken. Mirrors drawBlurredBackground()."""798 sw, sh = img.width, img.height799 cover = max(W / sw, H / sh)800 cw, ch = round(sw * cover), round(sh * cover)801 big = img.resize((cw, ch), Image.LANCZOS).convert("RGB")802 ox, oy = (cw - W) // 2, (ch - H) // 2803 bg = big.crop((ox, oy, ox + W, oy + H))804 bg = bg.filter(ImageFilter.GaussianBlur(radius=24))805 # Darken806 arr = np.array(bg, dtype=np.float32)807 arr = arr * 0.6808 return Image.fromarray(arr.clip(0, 255).astype(np.uint8), "RGB")809 