feat: add Sprite Splitter tool and support X/Y offsets in Inset Crop Tool (v1.3.0)
This commit is contained in:
29
README.md
29
README.md
@@ -93,6 +93,7 @@ SpriteTool 是一個整合式的 Web 應用程式,集合了四個專業級工
|
||||
- **即時預覽** - 調整 threshold 時實時顯示去底色效果
|
||||
- **預覽底色檢查** - 可修改預覽背景顏色以檢查邊界精度(純檢查用,不影響導出)
|
||||
- **內縮虛線框** - 在預覽上顯示每個格子的內縮邊界(粉紅色虛線框),視覺化檢查內縮效果
|
||||
- **座標偏移** - 支援 X/Y 軸偏移調整,處理不對齊的網格
|
||||
- **智慧裁切** - 根據行列數和內縮距離精確分割每格
|
||||
- **縮放還原** - 內縮後自動放大回原格尺寸
|
||||
- **批量輸出** - 導出個別格子 PNG + 組合完整圖
|
||||
@@ -102,6 +103,19 @@ SpriteTool 是一個整合式的 Web 應用程式,集合了四個專業級工
|
||||
|
||||
---
|
||||
|
||||
### 📦 Sprite Splitter(精靈分割器)
|
||||
最基礎的圖片分割工具,將 Sprite Sheet 按網格切開並打包。
|
||||
|
||||
**功能特性:**
|
||||
- 根據行列數自動計算格子大小
|
||||
- 實時顯示分割線預覽
|
||||
- 一鍵將所有格子打包為 ZIP 下載
|
||||
- 支援像素藝術縮放顯示(保持銳利)
|
||||
|
||||
**使用場景:** 快速拆解素材包、動畫序列導出
|
||||
|
||||
---
|
||||
|
||||
## 🚀 快速開始
|
||||
|
||||
### 環境需求
|
||||
@@ -133,6 +147,7 @@ python tool.py
|
||||
- **Sprite Flipper**: `http://localhost:8000/flipper/`
|
||||
- **Sprite Merger**: `http://localhost:8000/merger/`
|
||||
- **Inset Crop Tool**: `http://localhost:8000/inset/`
|
||||
- **Sprite Splitter**: `http://localhost:8000/splitter/`
|
||||
|
||||
---
|
||||
|
||||
@@ -148,6 +163,7 @@ SpriteTool/
|
||||
├── rotate.py # Flipper 核心處理邏輯
|
||||
├── sprite_merger.py # Sprite Merger 子應用
|
||||
├── inset_crop_tool.py # Inset Crop Tool 子應用
|
||||
├── sprite_splitter.py # Sprite Splitter 子應用
|
||||
├── remove_pink_background.py # 色彩去除工具(獨立使用)
|
||||
├── output/ # Grid Tool 導出目錄
|
||||
├── shiny_output/ # Shiny Maker 導出目錄
|
||||
@@ -215,6 +231,7 @@ SpriteTool/
|
||||
- 確認去底色效果滿意
|
||||
3. **設定裁切參數**
|
||||
- 輸入圖片的行列數(Cols/Rows)
|
||||
- 調整 **X/Y Offset** 偏移量以對齊網格
|
||||
- 設定四邊內縮距離(px)
|
||||
- 查看計算的格子尺寸及輸出數量
|
||||
4. **確認並下載**
|
||||
@@ -222,6 +239,12 @@ SpriteTool/
|
||||
- 系統執行去底色 → 內縮裁切 → 放大還原
|
||||
- 下載包含所有格子 PNG + 完整組合圖的 ZIP 檔
|
||||
|
||||
### Sprite Splitter 工作流
|
||||
|
||||
1. **上傳圖片** - 選擇要分割的 Sprite Sheet
|
||||
2. **設定網格** - 輸入行列數,預覽圖會即時顯示藍色分割線
|
||||
3. **下載** - 點擊「下載 ZIP」取得所有格子檔案
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 技術架構
|
||||
@@ -275,6 +298,10 @@ SpriteTool/
|
||||
- `POST /inset/preview` - 即時預覽去底色效果
|
||||
- `POST /inset/process` - 執行去底色 + 裁切 + 打包
|
||||
|
||||
### Sprite Splitter
|
||||
- `GET /splitter/` - 首頁
|
||||
- `POST /splitter/process` - 執行圖片網格分割並打包
|
||||
|
||||
---
|
||||
|
||||
## 🎨 UI/UX 特性
|
||||
@@ -337,4 +364,4 @@ SpriteTool/
|
||||
---
|
||||
|
||||
**最後更新**: 2026 年 5 月
|
||||
**版本**: 1.2.0
|
||||
**版本**: 1.3.0
|
||||
|
||||
29
README_EN.md
29
README_EN.md
@@ -91,6 +91,7 @@ Remove Chroma Key background and perform intelligent inset cropping and enlargem
|
||||
- **Real-time Preview** - Instantly view background removal effects while adjusting threshold.
|
||||
- **Preview Background Customization** - Change preview background color to check edge precision (inspection only, does not affect export).
|
||||
- **Inset Guide Lines** - Display dashed lines showing each cell's inset boundary (pink dashed outline) for visual verification of inset effects.
|
||||
- **Coordinate Offset** - Support for X/Y axis offset adjustment to handle misaligned grids.
|
||||
- **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.
|
||||
@@ -100,6 +101,19 @@ Remove Chroma Key background and perform intelligent inset cropping and enlargem
|
||||
|
||||
---
|
||||
|
||||
### 📦 Sprite Splitter
|
||||
The simplest tool to split a Sprite Sheet into grid cells and package them into a ZIP file.
|
||||
|
||||
**Key Features:**
|
||||
- Automatically calculates cell size based on row and column counts.
|
||||
- Real-time preview of grid split lines.
|
||||
- One-click packaging of all cells into a ZIP archive for download.
|
||||
- Pixel-art optimized rendering (maintains sharpness).
|
||||
|
||||
**Use Cases:** Quick decomposition of asset packs, animation sequence export.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Prerequisites
|
||||
@@ -131,6 +145,7 @@ Visit the home page to see all available tools, or access them directly:
|
||||
- **Sprite Flipper**: `http://localhost:8000/flipper/`
|
||||
- **Sprite Merger**: `http://localhost:8000/merger/`
|
||||
- **Inset Crop Tool**: `http://localhost:8000/inset/`
|
||||
- **Sprite Splitter**: `http://localhost:8000/splitter/`
|
||||
|
||||
## 📂 Project Structure
|
||||
|
||||
@@ -144,6 +159,7 @@ SpriteTool/
|
||||
├── rotate.py # Flipper core processing logic
|
||||
├── sprite_merger.py # Sprite Merger sub-app
|
||||
├── inset_crop_tool.py # Inset Crop Tool sub-app
|
||||
├── sprite_splitter.py # Sprite Splitter sub-app
|
||||
├── remove_pink_background.py # Color removal tool (standalone use)
|
||||
├── output/ # Grid Tool export directory
|
||||
├── shiny_output/ # Shiny Maker export directory
|
||||
@@ -211,6 +227,7 @@ SpriteTool/
|
||||
- Confirm the background removal result.
|
||||
3. **Configure Crop Parameters**
|
||||
- Enter grid row and column counts (Cols/Rows).
|
||||
- Adjust **X/Y Offset** to align the grid correctly.
|
||||
- Set inset distance (px) for all edges.
|
||||
- View calculated cell size and output count.
|
||||
4. **Confirm and Download**
|
||||
@@ -218,6 +235,12 @@ SpriteTool/
|
||||
- System executes: Remove background → Inset crop → Enlarge back → Package.
|
||||
- Download ZIP containing all individual cell PNGs + combined image.
|
||||
|
||||
### Sprite Splitter Workflow
|
||||
|
||||
1. **Upload Image** - Select the Sprite Sheet to split.
|
||||
2. **Set Grid** - Enter row and column counts; the preview will show blue split lines instantly.
|
||||
3. **Download** - Click "Download ZIP" to get all individual cell files.
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Technical Architecture
|
||||
@@ -270,6 +293,10 @@ SpriteTool/
|
||||
- `GET /inset/` - Home page.
|
||||
- `POST /inset/preview` - Real-time background removal preview.
|
||||
- `POST /inset/process` - Execute background removal + cropping + packaging.
|
||||
|
||||
### Sprite Splitter
|
||||
- `GET /splitter/` - Home page.
|
||||
- `POST /splitter/process` - Execute grid splitting and packaging.
|
||||
---
|
||||
|
||||
## 🎨 UI/UX Features
|
||||
@@ -332,4 +359,4 @@ Issues and Pull Requests are welcome!
|
||||
---
|
||||
|
||||
**Last Updated**: May 2026
|
||||
**Version**: 1.2.0
|
||||
**Version**: 1.3.0
|
||||
|
||||
@@ -147,12 +147,12 @@ HTML = """<!DOCTYPE html>
|
||||
</div>
|
||||
|
||||
<!-- ── Bottom: Crop Settings & Download ── -->
|
||||
<div class="bg-slate-800 rounded-2xl p-4 flex items-center justify-between gap-6">
|
||||
<div class="bg-slate-800 rounded-2xl p-4">
|
||||
|
||||
<!-- 左邊:裁切設定 -->
|
||||
<div class="space-y-1">
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<p class="text-xs font-semibold text-blue-300">裁切設定</p>
|
||||
<div class="flex gap-2 items-center">
|
||||
<div class="flex flex-wrap gap-2 items-end">
|
||||
<div>
|
||||
<label class="block text-xs text-slate-400 mb-1">Columns</label>
|
||||
<input id="colsInput" type="number" value="4" min="1"
|
||||
@@ -168,11 +168,21 @@ HTML = """<!DOCTYPE html>
|
||||
<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>
|
||||
<label class="block text-xs text-slate-400 mb-1">X 偏移 (px)</label>
|
||||
<input id="offsetXInput" type="number" value="0"
|
||||
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">Y 偏移 (px)</label>
|
||||
<input id="offsetYInput" type="number" value="0"
|
||||
class="w-20 p-2 rounded-lg bg-slate-700 border border-slate-600 text-white text-sm">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右邊:下載按鈕 -->
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<div class="flex flex-col items-center gap-2 mt-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
|
||||
@@ -260,7 +270,7 @@ fileInput.addEventListener('change', () => {
|
||||
});
|
||||
|
||||
origImg.addEventListener('load', updateInfo);
|
||||
['colsInput','rowsInput','insetInput'].forEach(id =>
|
||||
['colsInput','rowsInput','insetInput','offsetXInput','offsetYInput'].forEach(id =>
|
||||
document.getElementById(id).addEventListener('input', () => {
|
||||
updateInfo();
|
||||
drawInsetGrid();
|
||||
@@ -273,6 +283,8 @@ function drawInsetGrid() {
|
||||
const cols = +document.getElementById('colsInput').value || 1;
|
||||
const rows = +document.getElementById('rowsInput').value || 1;
|
||||
const inset = +document.getElementById('insetInput').value || 0;
|
||||
const offsetX = +document.getElementById('offsetXInput').value || 0;
|
||||
const offsetY = +document.getElementById('offsetYInput').value || 0;
|
||||
|
||||
const imgW = bgImg.naturalWidth;
|
||||
const imgH = bgImg.naturalHeight;
|
||||
@@ -304,8 +316,8 @@ function drawInsetGrid() {
|
||||
// 對每個格子繪製內縮邊界框
|
||||
for (let r = 0; r < rows; r++) {
|
||||
for (let c = 0; c < cols; c++) {
|
||||
const x0 = c * tileW;
|
||||
const y0 = r * tileH;
|
||||
const x0 = c * tileW + offsetX;
|
||||
const y0 = r * tileH + offsetY;
|
||||
|
||||
// 繪製內縮矩形框
|
||||
ctx.strokeRect(x0 + inset, y0 + inset, tileW - inset * 2, tileH - inset * 2);
|
||||
@@ -379,6 +391,8 @@ async function doProcess() {
|
||||
fd.append('cols', document.getElementById('colsInput').value);
|
||||
fd.append('rows', document.getElementById('rowsInput').value);
|
||||
fd.append('inset', document.getElementById('insetInput').value);
|
||||
fd.append('offset_x', document.getElementById('offsetXInput').value);
|
||||
fd.append('offset_y', document.getElementById('offsetYInput').value);
|
||||
|
||||
try {
|
||||
const resp = await fetch('process', { method: 'POST', body: fd });
|
||||
@@ -441,6 +455,8 @@ async def process(
|
||||
cols: int = Form(...),
|
||||
rows: int = Form(...),
|
||||
inset: int = Form(...),
|
||||
offset_x: int = Form(0),
|
||||
offset_y: int = Form(0),
|
||||
):
|
||||
"""去底色 → 內縮裁切 → 打包 ZIP 下載。"""
|
||||
raw = await file.read()
|
||||
@@ -461,7 +477,7 @@ async def process(
|
||||
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))
|
||||
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)
|
||||
|
||||
|
||||
296
sprite_splitter.py
Normal file
296
sprite_splitter.py
Normal file
@@ -0,0 +1,296 @@
|
||||
"""
|
||||
Simple Sprite Splitter
|
||||
上傳圖片 → 輸入 cols/rows → 下載分割後的 ZIP
|
||||
"""
|
||||
|
||||
import io
|
||||
import zipfile
|
||||
from fastapi import FastAPI, File, Form, UploadFile
|
||||
from fastapi.responses import HTMLResponse, PlainTextResponse, StreamingResponse
|
||||
from PIL import Image
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
HTML = """<!DOCTYPE html>
|
||||
<html lang="zh-TW">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Sprite Splitter</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">
|
||||
<div class="max-w-6xl mx-auto space-y-6 p-6 pt-20">
|
||||
|
||||
<header class="border-b border-slate-700 pb-2">
|
||||
<h1 class="text-xl font-bold">📦 Sprite Splitter</h1>
|
||||
<p class="text-slate-400 text-sm mt-2">快速分割圖片為網格並打包為 ZIP</p>
|
||||
</header>
|
||||
|
||||
<!-- ── Controls ── -->
|
||||
<div class="bg-slate-800 rounded-2xl p-5 space-y-4">
|
||||
|
||||
<!-- 上傳區域 -->
|
||||
<div class="space-y-2">
|
||||
<label class="block text-xs text-slate-400 font-semibold">上傳圖片</label>
|
||||
<div class="border-2 border-dashed border-slate-600 rounded-xl p-6 text-center hover:border-blue-500 transition cursor-pointer"
|
||||
onclick="document.getElementById('fileInput').click()">
|
||||
<p id="uploadPH" class="text-slate-400">點擊上傳或拖拽圖片</p>
|
||||
<img id="previewImg" class="hidden max-h-40 mx-auto mt-3" style="image-rendering:pixelated">
|
||||
</div>
|
||||
<input id="fileInput" type="file" accept="image/*" class="hidden">
|
||||
</div>
|
||||
|
||||
<!-- 設定區域 -->
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label class="block text-xs text-slate-400 mb-2 font-semibold">Columns</label>
|
||||
<input id="colsInput" type="number" value="4" min="1"
|
||||
class="w-full 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-2 font-semibold">Rows</label>
|
||||
<input id="rowsInput" type="number" value="4" min="1"
|
||||
class="w-full p-2 rounded-lg bg-slate-700 border border-slate-600 text-white text-sm">
|
||||
</div>
|
||||
<div class="flex flex-col justify-end">
|
||||
<button id="downloadBtn" disabled onclick="doDownload()"
|
||||
class="w-full px-4 py-2 bg-blue-600 hover:bg-blue-500 disabled:bg-slate-600
|
||||
disabled:cursor-not-allowed rounded-lg font-semibold transition text-white">
|
||||
⬇ 下載 ZIP
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 資訊顯示 -->
|
||||
<div id="info" class="hidden bg-slate-700/50 rounded-lg px-4 py-2 text-sm text-slate-300 space-y-1"></div>
|
||||
<div id="status" class="hidden text-sm text-slate-400"></div>
|
||||
</div>
|
||||
|
||||
<!-- ── Preview ── -->
|
||||
<div class="bg-slate-800 rounded-2xl p-4">
|
||||
<p class="text-sm font-semibold text-slate-400 mb-3">預覽(分割線)</p>
|
||||
<div class="relative flex items-center justify-center rounded-lg bg-slate-700 min-h-64 overflow-auto">
|
||||
<img id="previewMainImg" class="hidden max-w-full object-contain" style="image-rendering:pixelated">
|
||||
<canvas id="gridCanvas" class="hidden absolute top-0 left-0" style="image-rendering:pixelated;"></canvas>
|
||||
<p id="previewPH" class="text-slate-500 text-sm">尚未上傳圖片</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let currentFile = null;
|
||||
|
||||
const fileInput = document.getElementById('fileInput');
|
||||
const uploadPH = document.getElementById('uploadPH');
|
||||
const previewImg = document.getElementById('previewImg');
|
||||
const previewMainImg = document.getElementById('previewMainImg');
|
||||
const gridCanvas = document.getElementById('gridCanvas');
|
||||
const previewPH = document.getElementById('previewPH');
|
||||
const infoDiv = document.getElementById('info');
|
||||
const statusDiv = document.getElementById('status');
|
||||
const downloadBtn = document.getElementById('downloadBtn');
|
||||
|
||||
// ── File upload ──
|
||||
fileInput.addEventListener('change', () => {
|
||||
const f = fileInput.files[0];
|
||||
if (!f) return;
|
||||
currentFile = f;
|
||||
const url = URL.createObjectURL(f);
|
||||
previewImg.src = url;
|
||||
previewImg.classList.remove('hidden');
|
||||
uploadPH.classList.add('hidden');
|
||||
previewMainImg.src = url;
|
||||
previewMainImg.onload = () => {
|
||||
previewMainImg.classList.remove('hidden');
|
||||
previewPH.classList.add('hidden');
|
||||
updateInfo();
|
||||
drawGrid();
|
||||
};
|
||||
downloadBtn.disabled = false;
|
||||
});
|
||||
|
||||
// ── Drag & drop ──
|
||||
document.addEventListener('dragover', (e) => e.preventDefault());
|
||||
document.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
const f = e.dataTransfer.files[0];
|
||||
if (f && f.type.startsWith('image/')) {
|
||||
fileInput.files = e.dataTransfer.files;
|
||||
fileInput.dispatchEvent(new Event('change'));
|
||||
}
|
||||
});
|
||||
|
||||
// ── Update grid on input change ──
|
||||
['colsInput', 'rowsInput'].forEach(id =>
|
||||
document.getElementById(id).addEventListener('input', () => {
|
||||
updateInfo();
|
||||
drawGrid();
|
||||
}));
|
||||
|
||||
// ── Draw grid lines ──
|
||||
function drawGrid() {
|
||||
if (!previewMainImg.naturalWidth || previewMainImg.classList.contains('hidden')) return;
|
||||
|
||||
const cols = +document.getElementById('colsInput').value || 1;
|
||||
const rows = +document.getElementById('rowsInput').value || 1;
|
||||
|
||||
const imgW = previewMainImg.naturalWidth;
|
||||
const imgH = previewMainImg.naturalHeight;
|
||||
const tileW = imgW / cols;
|
||||
const tileH = imgH / rows;
|
||||
|
||||
gridCanvas.width = imgW;
|
||||
gridCanvas.height = imgH;
|
||||
|
||||
const displayW = previewMainImg.offsetWidth;
|
||||
const displayH = previewMainImg.offsetHeight;
|
||||
const offsetLeft = previewMainImg.offsetLeft;
|
||||
const offsetTop = previewMainImg.offsetTop;
|
||||
|
||||
gridCanvas.style.width = displayW + 'px';
|
||||
gridCanvas.style.height = displayH + 'px';
|
||||
gridCanvas.style.left = offsetLeft + 'px';
|
||||
gridCanvas.style.top = offsetTop + 'px';
|
||||
|
||||
const ctx = gridCanvas.getContext('2d');
|
||||
ctx.clearRect(0, 0, imgW, imgH);
|
||||
ctx.strokeStyle = 'rgba(59, 130, 246, 0.8)';
|
||||
ctx.lineWidth = 1;
|
||||
|
||||
// 縱線
|
||||
for (let c = 1; c < cols; c++) {
|
||||
const x = c * tileW;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, 0);
|
||||
ctx.lineTo(x, imgH);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// 橫線
|
||||
for (let r = 1; r < rows; r++) {
|
||||
const y = r * tileH;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, y);
|
||||
ctx.lineTo(imgW, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
gridCanvas.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// ── Update info ──
|
||||
function updateInfo() {
|
||||
if (!currentFile || !previewMainImg.naturalWidth) return;
|
||||
const cols = +document.getElementById('colsInput').value || 1;
|
||||
const rows = +document.getElementById('rowsInput').value || 1;
|
||||
const tw = Math.floor(previewMainImg.naturalWidth / cols);
|
||||
const th = Math.floor(previewMainImg.naturalHeight / rows);
|
||||
|
||||
infoDiv.innerHTML = `
|
||||
<span>圖片:<b>${previewMainImg.naturalWidth} × ${previewMainImg.naturalHeight}</b></span>
|
||||
<span>每格:<b>${tw} × ${th}</b>(共 ${cols * rows} 格)</span>
|
||||
`;
|
||||
infoDiv.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// ── Download ──
|
||||
async function doDownload() {
|
||||
if (!currentFile) return;
|
||||
statusDiv.textContent = '處理中…';
|
||||
statusDiv.classList.remove('hidden');
|
||||
downloadBtn.disabled = true;
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('file', currentFile);
|
||||
fd.append('cols', document.getElementById('colsInput').value);
|
||||
fd.append('rows', document.getElementById('rowsInput').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 = 'sprite_split.zip';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
statusDiv.textContent = '下載完成!';
|
||||
} catch (e) {
|
||||
statusDiv.textContent = '網路錯誤:' + e.message;
|
||||
} finally {
|
||||
downloadBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def index():
|
||||
return HTML
|
||||
|
||||
|
||||
@app.post("/process")
|
||||
async def process(
|
||||
file: UploadFile = File(...),
|
||||
cols: int = Form(...),
|
||||
rows: int = Form(...),
|
||||
):
|
||||
"""分割圖片並打包為 ZIP。"""
|
||||
raw = await file.read()
|
||||
try:
|
||||
src = Image.open(io.BytesIO(raw))
|
||||
src = src.convert("RGBA")
|
||||
except Exception as e:
|
||||
return PlainTextResponse(f"圖片載入失敗:{str(e)}", status_code=400)
|
||||
|
||||
W, H = src.size
|
||||
tile_w = W // cols
|
||||
tile_h = H // rows
|
||||
|
||||
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, y0, x0 + tile_w, y0 + tile_h))
|
||||
tiles.append(cropped)
|
||||
|
||||
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"tile_r{r:02d}_c{c:02d}.png", tb.getvalue())
|
||||
|
||||
buf.seek(0)
|
||||
return StreamingResponse(
|
||||
buf,
|
||||
media_type="application/zip",
|
||||
headers={"Content-Disposition": "attachment; filename=sprite_split.zip"},
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run("sprite_splitter:app", host="0.0.0.0", port=8004, reload=True)
|
||||
4
tool.py
4
tool.py
@@ -11,6 +11,7 @@ from shiny_maker import app as shiny_app
|
||||
from rotate_webtool import app as flipper_app
|
||||
from sprite_merger import app as merger_app
|
||||
from inset_crop_tool import app as inset_app
|
||||
from sprite_splitter import app as splitter_app
|
||||
|
||||
app = FastAPI(title="Game Dev Suite")
|
||||
|
||||
@@ -25,6 +26,7 @@ NAVBAR_HTML = """
|
||||
<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="/inset/" class="text-sm text-slate-300 hover:text-blue-400 transition">✂️ Inset Crop</a>
|
||||
<a href="/splitter/" class="text-sm text-slate-300 hover:text-blue-400 transition">📦 Splitter</a>
|
||||
</nav>
|
||||
"""
|
||||
|
||||
@@ -57,6 +59,7 @@ app.mount("/shiny", shiny_app)
|
||||
app.mount("/flipper", flipper_app)
|
||||
app.mount("/merger", merger_app)
|
||||
app.mount("/inset", inset_app)
|
||||
app.mount("/splitter", splitter_app)
|
||||
|
||||
# 首頁入口
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
@@ -79,6 +82,7 @@ async def index():
|
||||
<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="/inset/" class="p-6 bg-slate-800 rounded-2xl border border-slate-700 text-center hover:border-blue-500 transition block">✂️ Inset Crop</a>
|
||||
<a href="/splitter/" class="p-6 bg-slate-800 rounded-2xl border border-slate-700 text-center hover:border-blue-500 transition block">📦 Splitter</a>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user