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
This commit is contained in:
71
README.md
71
README.md
@@ -69,6 +69,37 @@ SpriteTool 是一個整合式的 Web 應用程式,集合了四個專業級工
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### 🧩 Sprite Merger(精靈合併工具)
|
||||||
|
將來源 Sprite 精確合併到主 Sprite Sheet 的指定格位。
|
||||||
|
|
||||||
|
**功能特性:**
|
||||||
|
- 上傳主圖和來源圖片
|
||||||
|
- 設定主圖的行列數,自動計算格子尺寸
|
||||||
|
- 指定目標格位座標(Column / Row)
|
||||||
|
- 自動對齊並合併來源圖到目標位置
|
||||||
|
- 保留透明度和色彩資訊
|
||||||
|
- 即時預覽合併結果
|
||||||
|
- 一鍵下載完成的圖片
|
||||||
|
|
||||||
|
**使用場景:** 角色變體組裝、Tilemap 元素組合、Sprite Sheet 內容更新
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ✂️ Inset Crop Tool(內縮裁切工具)
|
||||||
|
去除 Chroma Key 底色後,對 Sprite Sheet 進行內縮裁切及放大處理。
|
||||||
|
|
||||||
|
**功能特性:**
|
||||||
|
- **色彩去除** - 自訂底色(預設 #ff00ff 粉紅色)及去除閾值
|
||||||
|
- **即時預覽** - 調整 threshold 時實時顯示去底色效果
|
||||||
|
- **智慧裁切** - 根據行列數和內縮距離精確分割每格
|
||||||
|
- **縮放還原** - 內縮後自動放大回原格尺寸
|
||||||
|
- **批量輸出** - 導出個別格子 PNG + 組合完整圖
|
||||||
|
- 支援 ZIP 壓縮打包下載
|
||||||
|
|
||||||
|
**使用場景:** Sprite Sheet 去底色處理、像素藝術格式標準化、動畫幀序列優化
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 🚀 快速開始
|
## 🚀 快速開始
|
||||||
|
|
||||||
### 環境需求
|
### 環境需求
|
||||||
@@ -98,6 +129,8 @@ python tool.py
|
|||||||
- **Sprite Picker**: `http://localhost:8000/picker/`
|
- **Sprite Picker**: `http://localhost:8000/picker/`
|
||||||
- **Shiny Maker**: `http://localhost:8000/shiny/`
|
- **Shiny Maker**: `http://localhost:8000/shiny/`
|
||||||
- **Sprite Flipper**: `http://localhost:8000/flipper/`
|
- **Sprite Flipper**: `http://localhost:8000/flipper/`
|
||||||
|
- **Sprite Merger**: `http://localhost:8000/merger/`
|
||||||
|
- **Inset Crop Tool**: `http://localhost:8000/inset/`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -111,10 +144,15 @@ SpriteTool/
|
|||||||
├── shiny_maker.py # Shiny Monster Maker 子應用
|
├── shiny_maker.py # Shiny Monster Maker 子應用
|
||||||
├── rotate_webtool.py # Sprite Flipper 子應用
|
├── rotate_webtool.py # Sprite Flipper 子應用
|
||||||
├── rotate.py # Flipper 核心處理邏輯
|
├── rotate.py # Flipper 核心處理邏輯
|
||||||
|
├── sprite_merger.py # Sprite Merger 子應用
|
||||||
|
├── inset_crop_tool.py # Inset Crop Tool 子應用
|
||||||
|
├── remove_pink_background.py # 色彩去除工具(獨立使用)
|
||||||
├── output/ # Grid Tool 導出目錄
|
├── output/ # Grid Tool 導出目錄
|
||||||
├── shiny_output/ # Shiny Maker 導出目錄
|
├── shiny_output/ # Shiny Maker 導出目錄
|
||||||
└── README.md # 本文件
|
└── README.md # 本文件
|
||||||
```
|
```
|
||||||
|
└── README.md # 本文件
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -160,6 +198,30 @@ SpriteTool/
|
|||||||
4. **檢查結果** - 下方會顯示翻轉後的預覽圖
|
4. **檢查結果** - 下方會顯示翻轉後的預覽圖
|
||||||
5. **下載** - 點擊下載按鈕取得成品
|
5. **下載** - 點擊下載按鈕取得成品
|
||||||
|
|
||||||
|
### Sprite Merger 工作流
|
||||||
|
|
||||||
|
1. **上傳圖片** - 選擇主 Sprite Sheet 和來源圖片
|
||||||
|
2. **設定主圖網格** - 輸入主圖的行列數
|
||||||
|
3. **指定目標位置** - 輸入目標 Column 和 Row 編號(從 0 開始)
|
||||||
|
4. **檢查預覽** - 查看合併後的預覽結果
|
||||||
|
5. **下載** - 點擊下載按鈕取得合併後的圖片
|
||||||
|
|
||||||
|
### Inset Crop Tool 工作流
|
||||||
|
|
||||||
|
1. **上傳 Sprite Sheet** - 選擇含有底色的圖片
|
||||||
|
2. **調整去底色設定**
|
||||||
|
- 選擇或輸入底色(如 #ff00ff)
|
||||||
|
- 調整 Threshold 閾值,觀察右側實時預覽
|
||||||
|
- 確認去底色效果滿意
|
||||||
|
3. **設定裁切參數**
|
||||||
|
- 輸入圖片的行列數(Cols/Rows)
|
||||||
|
- 設定四邊內縮距離(px)
|
||||||
|
- 查看計算的格子尺寸及輸出數量
|
||||||
|
4. **確認並下載**
|
||||||
|
- 點擊「確認並下載 ZIP」按鈕
|
||||||
|
- 系統執行去底色 → 內縮裁切 → 放大還原
|
||||||
|
- 下載包含所有格子 PNG + 完整組合圖的 ZIP 檔
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🛠️ 技術架構
|
## 🛠️ 技術架構
|
||||||
@@ -204,6 +266,15 @@ SpriteTool/
|
|||||||
- `GET /flipper/` - 首頁
|
- `GET /flipper/` - 首頁
|
||||||
- `POST /flipper/flip` - 執行精靈翻轉處理
|
- `POST /flipper/flip` - 執行精靈翻轉處理
|
||||||
|
|
||||||
|
### Sprite Merger
|
||||||
|
- `GET /merger/` - 首頁
|
||||||
|
- `POST /merger/merge` - 執行精靈合併處理
|
||||||
|
|
||||||
|
### Inset Crop Tool
|
||||||
|
- `GET /inset/` - 首頁
|
||||||
|
- `POST /inset/preview` - 即時預覽去底色效果
|
||||||
|
- `POST /inset/process` - 執行去底色 + 裁切 + 打包
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🎨 UI/UX 特性
|
## 🎨 UI/UX 特性
|
||||||
|
|||||||
70
README_EN.md
70
README_EN.md
@@ -67,6 +67,37 @@ Quickly perform horizontal flipping (mirroring) for each animation cell in a Spr
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### 🧩 Sprite Merger
|
||||||
|
Precisely merge source Sprite into the target cell of a main Sprite Sheet.
|
||||||
|
|
||||||
|
**Key Features:**
|
||||||
|
- Upload main image and source sprite.
|
||||||
|
- Set main image row/column counts with automatic cell size calculation.
|
||||||
|
- Specify target cell coordinates (Column / Row).
|
||||||
|
- Automatic alignment and merge of source sprite to target position.
|
||||||
|
- Preserves transparency and color information.
|
||||||
|
- Real-time preview of merge result.
|
||||||
|
- One-click download of completed image.
|
||||||
|
|
||||||
|
**Use Cases:** Character variant assembly, Tilemap element composition, Sprite Sheet content updates.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ✂️ Inset Crop Tool
|
||||||
|
Remove Chroma Key background and perform intelligent inset cropping and enlargement on Sprite Sheets.
|
||||||
|
|
||||||
|
**Key Features:**
|
||||||
|
- **Color Removal** - Custom chroma key (default #ff00ff pink) and threshold adjustment.
|
||||||
|
- **Real-time Preview** - Instantly view background removal effects while adjusting threshold.
|
||||||
|
- **Smart Cropping** - Precisely divide cells based on rows/columns and inset distance.
|
||||||
|
- **Enlarge After Crop** - Automatically enlarge back to original cell size after inset cropping.
|
||||||
|
- **Batch Export** - Export individual cell PNGs + combined full image.
|
||||||
|
- **ZIP Archive** - Download all results in a single ZIP package.
|
||||||
|
|
||||||
|
**Use Cases:** Sprite Sheet background removal, pixel art standardization, animation frame sequence optimization.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 🚀 Quick Start
|
## 🚀 Quick Start
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
@@ -96,8 +127,8 @@ Visit the home page to see all available tools, or access them directly:
|
|||||||
- **Sprite Picker**: `http://localhost:8000/picker/`
|
- **Sprite Picker**: `http://localhost:8000/picker/`
|
||||||
- **Shiny Maker**: `http://localhost:8000/shiny/`
|
- **Shiny Maker**: `http://localhost:8000/shiny/`
|
||||||
- **Sprite Flipper**: `http://localhost:8000/flipper/`
|
- **Sprite Flipper**: `http://localhost:8000/flipper/`
|
||||||
|
- **Sprite Merger**: `http://localhost:8000/merger/`
|
||||||
---
|
- **Inset Crop Tool**: `http://localhost:8000/inset/`
|
||||||
|
|
||||||
## 📂 Project Structure
|
## 📂 Project Structure
|
||||||
|
|
||||||
@@ -109,6 +140,9 @@ SpriteTool/
|
|||||||
├── shiny_maker.py # Shiny Monster Maker sub-app
|
├── shiny_maker.py # Shiny Monster Maker sub-app
|
||||||
├── rotate_webtool.py # Sprite Flipper sub-app
|
├── rotate_webtool.py # Sprite Flipper sub-app
|
||||||
├── rotate.py # Flipper core processing logic
|
├── rotate.py # Flipper core processing logic
|
||||||
|
├── sprite_merger.py # Sprite Merger sub-app
|
||||||
|
├── inset_crop_tool.py # Inset Crop Tool sub-app
|
||||||
|
├── remove_pink_background.py # Color removal tool (standalone use)
|
||||||
├── output/ # Grid Tool export directory
|
├── output/ # Grid Tool export directory
|
||||||
├── shiny_output/ # Shiny Maker export directory
|
├── shiny_output/ # Shiny Maker export directory
|
||||||
└── README.md # This file
|
└── README.md # This file
|
||||||
@@ -158,6 +192,30 @@ SpriteTool/
|
|||||||
4. **Check Result** - View the flipped preview image below.
|
4. **Check Result** - View the flipped preview image below.
|
||||||
5. **Download** - Click the download button to get the result.
|
5. **Download** - Click the download button to get the result.
|
||||||
|
|
||||||
|
### Sprite Merger Workflow
|
||||||
|
|
||||||
|
1. **Upload Images** - Select the main Sprite Sheet and source sprite.
|
||||||
|
2. **Set Main Grid** - Enter the row and column counts for the main image.
|
||||||
|
3. **Specify Target Position** - Enter the target column and row indices (starting from 0).
|
||||||
|
4. **Preview Merge** - View the merge result preview.
|
||||||
|
5. **Download** - Click the download button to get the merged image.
|
||||||
|
|
||||||
|
### Inset Crop Tool Workflow
|
||||||
|
|
||||||
|
1. **Upload Sprite Sheet** - Select an image with chroma key background.
|
||||||
|
2. **Adjust Background Removal Settings**
|
||||||
|
- Choose or input the background color (e.g., #ff00ff).
|
||||||
|
- Adjust Threshold slider and observe real-time preview on the right.
|
||||||
|
- Confirm the background removal result.
|
||||||
|
3. **Configure Crop Parameters**
|
||||||
|
- Enter grid row and column counts (Cols/Rows).
|
||||||
|
- Set inset distance (px) for all edges.
|
||||||
|
- View calculated cell size and output count.
|
||||||
|
4. **Confirm and Download**
|
||||||
|
- Click "Confirm and Download ZIP" button.
|
||||||
|
- System executes: Remove background → Inset crop → Enlarge back → Package.
|
||||||
|
- Download ZIP containing all individual cell PNGs + combined image.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🛠️ Technical Architecture
|
## 🛠️ Technical Architecture
|
||||||
@@ -202,6 +260,14 @@ SpriteTool/
|
|||||||
- `GET /flipper/` - Home page.
|
- `GET /flipper/` - Home page.
|
||||||
- `POST /flipper/flip` - Execute sprite flipping process.
|
- `POST /flipper/flip` - Execute sprite flipping process.
|
||||||
|
|
||||||
|
### Sprite Merger
|
||||||
|
- `GET /merger/` - Home page.
|
||||||
|
- `POST /merger/merge` - Execute sprite merge process.
|
||||||
|
|
||||||
|
### Inset Crop Tool
|
||||||
|
- `GET /inset/` - Home page.
|
||||||
|
- `POST /inset/preview` - Real-time background removal preview.
|
||||||
|
- `POST /inset/process` - Execute background removal + cropping + packaging.
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🎨 UI/UX Features
|
## 🎨 UI/UX Features
|
||||||
|
|||||||
403
inset_crop_tool.py
Normal file
403
inset_crop_tool.py
Normal file
@@ -0,0 +1,403 @@
|
|||||||
|
"""
|
||||||
|
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)
|
||||||
4
tool.py
4
tool.py
@@ -10,6 +10,7 @@ from sprite_webtool import app as picker_app
|
|||||||
from shiny_maker import app as shiny_app
|
from shiny_maker import app as shiny_app
|
||||||
from rotate_webtool import app as flipper_app
|
from rotate_webtool import app as flipper_app
|
||||||
from sprite_merger import app as merger_app
|
from sprite_merger import app as merger_app
|
||||||
|
from inset_crop_tool import app as inset_app
|
||||||
|
|
||||||
app = FastAPI(title="Game Dev Suite")
|
app = FastAPI(title="Game Dev Suite")
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ NAVBAR_HTML = """
|
|||||||
<a href="/shiny/" class="text-sm text-slate-300 hover:text-blue-400 transition">✨ Shiny Maker</a>
|
<a href="/shiny/" class="text-sm text-slate-300 hover:text-blue-400 transition">✨ Shiny Maker</a>
|
||||||
<a href="/flipper/" class="text-sm text-slate-300 hover:text-blue-400 transition">🔄 Flipper</a>
|
<a href="/flipper/" class="text-sm text-slate-300 hover:text-blue-400 transition">🔄 Flipper</a>
|
||||||
<a href="/merger/" class="text-sm text-slate-300 hover:text-blue-400 transition">🧩 Merger</a>
|
<a href="/merger/" class="text-sm text-slate-300 hover:text-blue-400 transition">🧩 Merger</a>
|
||||||
|
<a href="/inset/" class="text-sm text-slate-300 hover:text-blue-400 transition">✂️ Inset Crop</a>
|
||||||
</nav>
|
</nav>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -58,6 +60,7 @@ app.mount("/picker", picker_app)
|
|||||||
app.mount("/shiny", shiny_app)
|
app.mount("/shiny", shiny_app)
|
||||||
app.mount("/flipper", flipper_app)
|
app.mount("/flipper", flipper_app)
|
||||||
app.mount("/merger", merger_app)
|
app.mount("/merger", merger_app)
|
||||||
|
app.mount("/inset", inset_app)
|
||||||
|
|
||||||
# 首頁入口
|
# 首頁入口
|
||||||
@app.get("/", response_class=HTMLResponse)
|
@app.get("/", response_class=HTMLResponse)
|
||||||
@@ -80,6 +83,7 @@ async def index():
|
|||||||
<a href="/shiny/" class="p-6 bg-slate-800 rounded-2xl border border-slate-700 text-center hover:border-blue-500 transition block">✨ Shiny Maker</a>
|
<a href="/shiny/" class="p-6 bg-slate-800 rounded-2xl border border-slate-700 text-center hover:border-blue-500 transition block">✨ Shiny Maker</a>
|
||||||
<a href="/flipper/" class="p-6 bg-slate-800 rounded-2xl border border-slate-700 text-center hover:border-blue-500 transition block">🔄 Flipper</a>
|
<a href="/flipper/" class="p-6 bg-slate-800 rounded-2xl border border-slate-700 text-center hover:border-blue-500 transition block">🔄 Flipper</a>
|
||||||
<a href="/merger/" class="p-6 bg-slate-800 rounded-2xl border border-slate-700 text-center hover:border-blue-500 transition block">🧩 Merger</a>
|
<a href="/merger/" class="p-6 bg-slate-800 rounded-2xl border border-slate-700 text-center hover:border-blue-500 transition block">🧩 Merger</a>
|
||||||
|
<a href="/inset/" class="p-6 bg-slate-800 rounded-2xl border border-slate-700 text-center hover:border-blue-500 transition block">✂️ Inset Crop</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
Reference in New Issue
Block a user