""" Inset Crop Tool + Pink BG Remover 1. 上傳圖片,設定底色與 threshold → 即時預覽去底色效果 2. 確認後設定 cols / rows / inset → 下載 ZIP """ from __future__ import annotations import io import zipfile import numpy as np from fastapi import FastAPI, File, Form, UploadFile from fastapi.responses import HTMLResponse, PlainTextResponse, StreamingResponse from PIL import Image app = FastAPI() # ────────────────────────────────────────────── # BG Removal helper (from remove_pink_background.py) # ────────────────────────────────────────────── def _parse_hex(value: str) -> tuple[int, int, int]: v = value.strip().lstrip("#") if len(v) != 6: raise ValueError("需要 6 位 hex 色碼,例如 #ff00ff") return int(v[0:2], 16), int(v[2:4], 16), int(v[4:6], 16) def remove_background(img: Image.Image, bg_color: str, threshold: float) -> Image.Image: """將 img 中 RGB 距離 bg_color 在 threshold 內的像素設為透明。""" img = img.convert("RGBA") pixels = np.array(img, dtype=np.int32) key = np.array(_parse_hex(bg_color), dtype=np.int32) dist = np.sqrt(np.sum((pixels[..., :3] - key) ** 2, axis=-1)) out = pixels.copy() out[..., 3] = np.where(dist <= threshold, 0, pixels[..., 3]) return Image.fromarray(out.astype(np.uint8), "RGBA") # ────────────────────────────────────────────── # HTML 前端 # ────────────────────────────────────────────── HTML = """ Inset Crop Tool

✂️ Inset Crop

移除背景色並內縮邊界,自動切割 Sprite Sheet 為個別方塊

去底色設定

Threshold: 100

預覽底色(檢查用)

僅用於檢查,不影響匯出

原始圖片

尚未上傳圖片

去底色預覽(底色檢查用 + 內縮虛線)

預覽將在此顯示

裁切設定

""" # ────────────────────────────────────────────── # Routes # ────────────────────────────────────────────── @app.get("/", response_class=HTMLResponse) async def index(): return HTML @app.post("/preview") async def preview_bg( file: UploadFile = File(...), bg_color: str = Form("#ff00ff"), threshold: float = Form(100.0), ): """去底色後回傳 PNG,供前端即時預覽。""" raw = await file.read() try: src = Image.open(io.BytesIO(raw)) result = remove_background(src, bg_color, threshold) except ValueError as e: return PlainTextResponse(str(e), status_code=400) buf = io.BytesIO() result.save(buf, format="PNG") buf.seek(0) return StreamingResponse(buf, media_type="image/png") @app.post("/process") async def process( file: UploadFile = File(...), bg_color: str = Form("#ff00ff"), threshold: float = Form(100.0), cols: int = Form(...), rows: int = Form(...), inset: int = Form(...), offset_x: int = Form(0), offset_y: int = Form(0), ): """去底色 → 內縮裁切 → 打包 ZIP 下載。""" raw = await file.read() try: src = Image.open(io.BytesIO(raw)) src = remove_background(src, bg_color, threshold) except ValueError as e: return PlainTextResponse(str(e), status_code=400) W, H = src.size tile_w = W // cols tile_h = H // rows if inset * 2 >= tile_w or inset * 2 >= tile_h: return PlainTextResponse("內縮距離過大,超出格子尺寸", status_code=400) tiles: list[Image.Image] = [] for r in range(rows): for c in range(cols): x0, y0 = c * tile_w, r * tile_h cropped = src.crop((x0 + offset_x + inset, y0 + offset_y + inset, x0 + offset_x + tile_w - inset, y0 + offset_y + tile_h - inset)) resized = cropped.resize((tile_w, tile_h), Image.NEAREST) tiles.append(resized) combined = Image.new("RGBA", (cols * tile_w, rows * tile_h), (0, 0, 0, 0)) for idx, tile in enumerate(tiles): r, c = divmod(idx, cols) combined.paste(tile, (c * tile_w, r * tile_h)) buf = io.BytesIO() with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: for idx, tile in enumerate(tiles): r, c = divmod(idx, cols) tb = io.BytesIO() tile.save(tb, format="PNG") zf.writestr(f"tiles/tile_r{r:02d}_c{c:02d}.png", tb.getvalue()) cb = io.BytesIO() combined.save(cb, format="PNG") zf.writestr("combined.png", cb.getvalue()) buf.seek(0) return StreamingResponse( buf, media_type="application/zip", headers={"Content-Disposition": "attachment; filename=inset_crop_output.zip"}, ) # ────────────────────────────────────────────── if __name__ == "__main__": import uvicorn uvicorn.run("inset_crop_tool:app", host="0.0.0.0", port=8003, reload=True)