Files
SpriteTool/inset_crop_tool.py
Edward Chang e2a6d4345c feat: add Sprite Merger documentation and Inset Crop Tool integration
- Added comprehensive documentation for Sprite Merger tool (both Chinese and English)
- Added Inset Crop Tool documentation with real-time preview feature
- Updated project structure and API endpoints sections
- Integrated Inset Crop Tool with background removal and intelligent cropping
- Updated navigation and tool registry in tool.py
- All documentation reflects both new features and complete tool suite
2026-05-13 22:49:33 +08:00

404 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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 = """<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="utf-8">
<title>Inset Crop Tool</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
.checker {
background-color: #888;
background-image:
linear-gradient(45deg, #aaa 25%, transparent 25%),
linear-gradient(-45deg, #aaa 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #aaa 75%),
linear-gradient(-45deg, transparent 75%, #aaa 75%);
background-size: 20px 20px;
background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
}
</style>
</head>
<body class="bg-slate-900 text-white min-h-screen p-6">
<div class="max-w-7xl mx-auto space-y-4">
<h1 class="text-2xl font-bold border-b border-slate-600 pb-3">Inset Crop Tool</h1>
<!-- ── Top: Controls ── -->
<div class="bg-slate-800 rounded-2xl p-5">
<div class="flex flex-wrap gap-6 items-end">
<!-- 上傳 -->
<div class="min-w-48">
<label class="block text-xs text-slate-400 mb-1">上傳圖片</label>
<input id="fileInput" type="file" accept="image/*"
class="w-full text-sm file:mr-3 file:py-2 file:px-4 file:rounded-lg file:border-0
file:bg-blue-600 file:text-white file:cursor-pointer hover:file:bg-blue-500">
</div>
<div class="w-px self-stretch bg-slate-600"></div>
<!-- 去底色 -->
<div class="space-y-1">
<p class="text-xs font-semibold text-pink-300">去底色設定</p>
<div class="flex gap-2 items-center">
<input id="colorPicker" type="color" value="#ff00ff"
class="w-10 h-9 rounded cursor-pointer border-0 p-0 bg-transparent">
<input id="colorHex" type="text" value="#ff00ff" maxlength="7"
class="w-24 p-2 rounded-lg bg-slate-700 border border-slate-600 text-white font-mono text-sm">
<div class="flex gap-2 items-center">
<span class="text-xs text-slate-400 whitespace-nowrap">Threshold: <span id="threshLabel">100</span></span>
<input id="threshRange" type="range" min="0" max="442" value="100"
class="w-32 accent-pink-500">
<input id="threshNum" type="number" min="0" max="442" value="100"
class="w-16 p-2 rounded-lg bg-slate-700 border border-slate-600 text-white text-sm">
</div>
</div>
</div>
<div class="w-px self-stretch bg-slate-600"></div>
<!-- 裁切 -->
<div class="space-y-1">
<p class="text-xs font-semibold text-blue-300">裁切設定</p>
<div class="flex gap-2 items-center">
<div>
<label class="block text-xs text-slate-400 mb-1">Columns</label>
<input id="colsInput" type="number" value="4" min="1"
class="w-20 p-2 rounded-lg bg-slate-700 border border-slate-600 text-white text-sm">
</div>
<div>
<label class="block text-xs text-slate-400 mb-1">Rows</label>
<input id="rowsInput" type="number" value="6" min="1"
class="w-20 p-2 rounded-lg bg-slate-700 border border-slate-600 text-white text-sm">
</div>
<div>
<label class="block text-xs text-slate-400 mb-1">內縮 (px)</label>
<input id="insetInput" type="number" value="2" min="0"
class="w-20 p-2 rounded-lg bg-slate-700 border border-slate-600 text-white text-sm">
</div>
</div>
</div>
</div>
<!-- Info -->
<div id="info" class="hidden mt-3 bg-slate-700/50 rounded-lg px-4 py-2 text-sm text-slate-300 flex flex-wrap gap-x-6 gap-y-1"></div>
</div>
<!-- ── Middle: Previews ── -->
<div class="grid grid-cols-2 gap-4">
<div class="bg-slate-800 rounded-2xl p-4 space-y-2">
<p class="text-sm font-semibold text-slate-400">原始圖片</p>
<div class="flex items-center justify-center rounded-lg bg-slate-700 min-h-80 overflow-auto">
<img id="origImg" class="hidden max-w-full object-contain" style="image-rendering:pixelated">
<p id="origPH" class="text-slate-500 text-sm">尚未上傳圖片</p>
</div>
</div>
<div class="bg-slate-800 rounded-2xl p-4 space-y-2">
<div class="flex items-center justify-between">
<p class="text-sm font-semibold text-pink-300">去底色預覽</p>
<span id="previewLoading" class="hidden text-xs text-slate-400 animate-pulse">更新中…</span>
</div>
<div class="checker flex items-center justify-center rounded-lg min-h-80 overflow-auto">
<img id="bgImg" class="hidden max-w-full object-contain" style="image-rendering:pixelated">
<p id="bgPH" class="text-slate-500 text-sm">預覽將在此顯示</p>
</div>
</div>
</div>
<!-- ── Bottom: Download ── -->
<div class="bg-slate-800 rounded-2xl p-4 flex items-center gap-4">
<button id="downloadBtn" disabled onclick="doProcess()"
class="px-10 py-3 bg-blue-600 hover:bg-blue-500
disabled:bg-slate-600 disabled:cursor-not-allowed
rounded-xl font-bold transition text-white">
⬇ 確認並下載 ZIP
</button>
<div id="status" class="hidden text-sm text-slate-400"></div>
</div>
</div>
<script>
let currentFile = null;
let previewDebounce = null;
let previewObjectUrl = null;
const fileInput = document.getElementById('fileInput');
const origImg = document.getElementById('origImg');
const origPH = document.getElementById('origPH');
const bgImg = document.getElementById('bgImg');
const bgPH = document.getElementById('bgPH');
const colorPicker = document.getElementById('colorPicker');
const colorHex = document.getElementById('colorHex');
const threshRange = document.getElementById('threshRange');
const threshNum = document.getElementById('threshNum');
const threshLabel = document.getElementById('threshLabel');
const infoDiv = document.getElementById('info');
const statusDiv = document.getElementById('status');
const downloadBtn = document.getElementById('downloadBtn');
const previewLoading = document.getElementById('previewLoading');
// ── Color picker <-> hex ─────────────────────
colorPicker.addEventListener('input', () => {
colorHex.value = colorPicker.value;
schedulePreview();
});
colorHex.addEventListener('change', () => {
const v = colorHex.value.trim();
if (/^#[0-9a-fA-F]{6}$/.test(v)) {
colorPicker.value = v;
schedulePreview();
}
});
// ── Threshold slider <-> number ──────────────
threshRange.addEventListener('input', () => {
threshNum.value = threshRange.value;
threshLabel.textContent = threshRange.value;
schedulePreview();
});
threshNum.addEventListener('input', () => {
threshRange.value = threshNum.value;
threshLabel.textContent = threshNum.value;
schedulePreview();
});
// ── File upload ──────────────────────────────
fileInput.addEventListener('change', () => {
const f = fileInput.files[0];
if (!f) return;
currentFile = f;
origImg.src = URL.createObjectURL(f);
origImg.classList.remove('hidden');
origPH.classList.add('hidden');
downloadBtn.disabled = false;
schedulePreview();
});
origImg.addEventListener('load', updateInfo);
['colsInput','rowsInput','insetInput'].forEach(id =>
document.getElementById(id).addEventListener('input', updateInfo));
function updateInfo() {
if (!currentFile || !origImg.naturalWidth) return;
const cols = +document.getElementById('colsInput').value || 1;
const rows = +document.getElementById('rowsInput').value || 1;
const inset = +document.getElementById('insetInput').value || 0;
const tw = Math.floor(origImg.naturalWidth / cols);
const th = Math.floor(origImg.naturalHeight / rows);
const cw = tw - inset * 2;
const ch = th - inset * 2;
infoDiv.innerHTML =
`<span>圖片:<b>${origImg.naturalWidth} × ${origImg.naturalHeight}</b></span>` +
`<span>格子:<b>${tw} × ${th}</b>(共 ${cols * rows} 格)</span>` +
`<span>裁切:<b>${cw} × ${ch}</b> → <b>${tw} × ${th}</b></span>` +
`<span>輸出:<b>${cols * rows + 1}</b> 張</span>`;
infoDiv.classList.remove('hidden');
}
// ── Preview BG removal (debounce 400 ms) ─────
function schedulePreview() {
if (!currentFile) return;
clearTimeout(previewDebounce);
previewDebounce = setTimeout(doPreview, 400);
}
async function doPreview() {
if (!currentFile) return;
previewLoading.classList.remove('hidden');
const fd = new FormData();
fd.append('file', currentFile);
fd.append('bg_color', colorHex.value);
fd.append('threshold', threshNum.value);
try {
const resp = await fetch('preview', { method: 'POST', body: fd });
if (!resp.ok) return;
const blob = await resp.blob();
if (previewObjectUrl) URL.revokeObjectURL(previewObjectUrl);
previewObjectUrl = URL.createObjectURL(blob);
bgImg.src = previewObjectUrl;
bgImg.classList.remove('hidden');
bgPH.classList.add('hidden');
} catch (e) {
console.error(e);
} finally {
previewLoading.classList.add('hidden');
}
}
// ── Process & Download ───────────────────────
async function doProcess() {
if (!currentFile) return;
statusDiv.textContent = '處理中…';
statusDiv.classList.remove('hidden');
downloadBtn.disabled = true;
const fd = new FormData();
fd.append('file', currentFile);
fd.append('bg_color', colorHex.value);
fd.append('threshold', threshNum.value);
fd.append('cols', document.getElementById('colsInput').value);
fd.append('rows', document.getElementById('rowsInput').value);
fd.append('inset', document.getElementById('insetInput').value);
try {
const resp = await fetch('process', { method: 'POST', body: fd });
if (!resp.ok) {
statusDiv.textContent = '錯誤:' + await resp.text();
return;
}
const blob = await resp.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'inset_crop_output.zip';
a.click();
URL.revokeObjectURL(url);
statusDiv.textContent = '下載完成!';
} catch (e) {
statusDiv.textContent = '網路錯誤:' + e.message;
} finally {
downloadBtn.disabled = false;
}
}
</script>
</body>
</html>
"""
# ──────────────────────────────────────────────
# 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(...),
):
"""去底色 → 內縮裁切 → 打包 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 + inset, y0 + inset, x0 + tile_w - inset, y0 + 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)