CoolFace
Apppublic

abir614/i

sourceHugging Facemitupdated 4mo agoView on Hugging Face
0likes
processing.py809 linesDownload Raw Back to backend
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