Spaces:
Sleeping
Sleeping
Commit ·
912fe4d
1
Parent(s): 678158e
feat: 高级切图设置与EXIF自适应;更新UI与README
Browse files- README.md +24 -1
- app.py +47 -8
- static/js/app.js +25 -0
- templates/index.html +40 -0
README.md
CHANGED
|
@@ -4,11 +4,12 @@
|
|
| 4 |
|
| 5 |
## 功能特点
|
| 6 |
|
| 7 |
-
- **多种布局**:支持 3x3 (九宫格), 2x2, 3x1, 1x3 等
|
| 8 |
- **高清无损**:后端采用 Python Pillow 库处理,保持原始图片画质。
|
| 9 |
- **一键打包**:自动将切割后的图片打包成 ZIP 下载。
|
| 10 |
- **隐私安全**:图片处理后立即释放,不保存任何用户数据。
|
| 11 |
- **实时预览**:上传即可预览切割效果。
|
|
|
|
| 12 |
|
| 13 |
## 技术栈
|
| 14 |
|
|
@@ -16,6 +17,28 @@
|
|
| 16 |
- **前端**: Vue 3, Tailwind CSS
|
| 17 |
- **部署**: Docker, Hugging Face Spaces
|
| 18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
## 本地运行
|
| 20 |
|
| 21 |
1. 克隆仓库
|
|
|
|
| 4 |
|
| 5 |
## 功能特点
|
| 6 |
|
| 7 |
+
- **多种布局**:支持 3x3 (九宫格), 2x2, 3x1, 1x3, 3x2, 2x3 等。
|
| 8 |
- **高清无损**:后端采用 Python Pillow 库处理,保持原始图片画质。
|
| 9 |
- **一键打包**:自动将切割后的图片打包成 ZIP 下载。
|
| 10 |
- **隐私安全**:图片处理后立即释放,不保存任何用户数据。
|
| 11 |
- **实时预览**:上传即可预览切割效果。
|
| 12 |
+
- **高级设置**:支持正方形裁剪、统一切片尺寸、输出格式(PNG/JPEG/WEBP)、质量调节。
|
| 13 |
|
| 14 |
## 技术栈
|
| 15 |
|
|
|
|
| 17 |
- **前端**: Vue 3, Tailwind CSS
|
| 18 |
- **部署**: Docker, Hugging Face Spaces
|
| 19 |
|
| 20 |
+
## 架构与技术实现(面试解读)
|
| 21 |
+
|
| 22 |
+
- **整体架构**:
|
| 23 |
+
- 前端使用 Vue 3 组合式 API 管理状态与交互,Tailwind 构建响应式 UI。
|
| 24 |
+
- 后端 Flask 暴露两个路由:`/` 渲染页面;`/api/split` 处理图片并返回 ZIP。
|
| 25 |
+
- Docker 化部署到 Hugging Face Spaces,使用 `gunicorn` 作为生产 WSGI。
|
| 26 |
+
- **核心算法**:
|
| 27 |
+
- 加载图片后进行 EXIF 自动旋转(`ImageOps.exif_transpose`)确保方向正确。
|
| 28 |
+
- 可选正方形中心裁剪,提升社交平台九宫格适配性。
|
| 29 |
+
- 两种切分策略:
|
| 30 |
+
- 非严格模式:最后一行/列吸收余数像素,避免丢失信息;
|
| 31 |
+
- 严格模式:居中裁剪为网格整倍数,保证所有切片尺寸完全一致。
|
| 32 |
+
- 输出支持 PNG/JPEG/WEBP,JPEG/WEBP 提供质量参数,并启用优化/渐进编码。
|
| 33 |
+
- **性能与内存**:
|
| 34 |
+
- 以内存流创建 ZIP(`io.BytesIO` + `zipfile`),避免落盘 I/O。
|
| 35 |
+
- 限制上传大小为 16MB(`MAX_CONTENT_LENGTH`),减轻服务端压力。
|
| 36 |
+
- **安全与隐私**:
|
| 37 |
+
- 无状态、即处理即返回,不持久化用户文件。
|
| 38 |
+
- Docker 镜像内使用非 root 用户(uid 1000)。
|
| 39 |
+
- **可扩展性**:
|
| 40 |
+
- 前后端参数映射清晰,易于扩展更多布局或特效(如切片边框、留白)。
|
| 41 |
+
|
| 42 |
## 本地运行
|
| 43 |
|
| 44 |
1. 克隆仓库
|
app.py
CHANGED
|
@@ -1,9 +1,8 @@
|
|
| 1 |
import os
|
| 2 |
import io
|
| 3 |
import zipfile
|
| 4 |
-
import uuid
|
| 5 |
from flask import Flask, render_template, request, send_file, jsonify
|
| 6 |
-
from PIL import Image
|
| 7 |
|
| 8 |
app = Flask(__name__)
|
| 9 |
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16MB max upload
|
|
@@ -25,20 +24,50 @@ def split_image():
|
|
| 25 |
# Get parameters
|
| 26 |
rows = int(request.form.get('rows', 3))
|
| 27 |
cols = int(request.form.get('cols', 3))
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
|
| 29 |
# Load image
|
| 30 |
img = Image.open(file.stream)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
|
| 32 |
# Convert to RGB if necessary (e.g. for RGBA/P images saving as JPEG)
|
| 33 |
# But we'll try to keep original format if possible, or default to PNG/JPG
|
| 34 |
format = img.format or 'PNG'
|
|
|
|
|
|
|
| 35 |
if format not in ['JPEG', 'PNG', 'WEBP']:
|
| 36 |
format = 'PNG'
|
| 37 |
|
| 38 |
# Calculate dimensions
|
| 39 |
width, height = img.size
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
tile_width = width // cols
|
| 41 |
tile_height = height // rows
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
|
| 43 |
# Create ZIP in memory
|
| 44 |
memory_file = io.BytesIO()
|
|
@@ -51,17 +80,27 @@ def split_image():
|
|
| 51 |
right = left + tile_width
|
| 52 |
lower = upper + tile_height
|
| 53 |
|
| 54 |
-
# Handle last row/col to include remainder pixels
|
| 55 |
-
if
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
|
|
|
| 59 |
|
| 60 |
tile = img.crop((left, upper, right, lower))
|
| 61 |
|
| 62 |
# Save tile to bytes
|
| 63 |
tile_bytes = io.BytesIO()
|
| 64 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
tile_bytes.seek(0)
|
| 66 |
|
| 67 |
# Add to zip (1-based index)
|
|
|
|
| 1 |
import os
|
| 2 |
import io
|
| 3 |
import zipfile
|
|
|
|
| 4 |
from flask import Flask, render_template, request, send_file, jsonify
|
| 5 |
+
from PIL import Image, ImageOps
|
| 6 |
|
| 7 |
app = Flask(__name__)
|
| 8 |
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16MB max upload
|
|
|
|
| 24 |
# Get parameters
|
| 25 |
rows = int(request.form.get('rows', 3))
|
| 26 |
cols = int(request.form.get('cols', 3))
|
| 27 |
+
out_format = (request.form.get('format') or '').upper()
|
| 28 |
+
quality = int(request.form.get('quality', 90))
|
| 29 |
+
square = request.form.get('square', 'false') == 'true'
|
| 30 |
+
strict_equal = request.form.get('strict_equal', 'false') == 'true'
|
| 31 |
|
| 32 |
# Load image
|
| 33 |
img = Image.open(file.stream)
|
| 34 |
+
# Auto-orient by EXIF if present
|
| 35 |
+
try:
|
| 36 |
+
img = ImageOps.exif_transpose(img)
|
| 37 |
+
except Exception:
|
| 38 |
+
pass
|
| 39 |
|
| 40 |
# Convert to RGB if necessary (e.g. for RGBA/P images saving as JPEG)
|
| 41 |
# But we'll try to keep original format if possible, or default to PNG/JPG
|
| 42 |
format = img.format or 'PNG'
|
| 43 |
+
if out_format in ['JPEG', 'PNG', 'WEBP']:
|
| 44 |
+
format = out_format
|
| 45 |
if format not in ['JPEG', 'PNG', 'WEBP']:
|
| 46 |
format = 'PNG'
|
| 47 |
|
| 48 |
# Calculate dimensions
|
| 49 |
width, height = img.size
|
| 50 |
+
# Optional square crop (center)
|
| 51 |
+
if square:
|
| 52 |
+
side = min(width, height)
|
| 53 |
+
left = (width - side) // 2
|
| 54 |
+
top = (height - side) // 2
|
| 55 |
+
img = img.crop((left, top, left + side, top + side))
|
| 56 |
+
width, height = img.size
|
| 57 |
+
|
| 58 |
+
# Compute base tile size
|
| 59 |
tile_width = width // cols
|
| 60 |
tile_height = height // rows
|
| 61 |
+
if strict_equal:
|
| 62 |
+
# Crop to exact multiple to make all tiles equal
|
| 63 |
+
exact_w = tile_width * cols
|
| 64 |
+
exact_h = tile_height * rows
|
| 65 |
+
left = (width - exact_w) // 2
|
| 66 |
+
top = (height - exact_h) // 2
|
| 67 |
+
img = img.crop((left, top, left + exact_w, top + exact_h))
|
| 68 |
+
width, height = img.size
|
| 69 |
+
tile_width = width // cols
|
| 70 |
+
tile_height = height // rows
|
| 71 |
|
| 72 |
# Create ZIP in memory
|
| 73 |
memory_file = io.BytesIO()
|
|
|
|
| 80 |
right = left + tile_width
|
| 81 |
lower = upper + tile_height
|
| 82 |
|
| 83 |
+
# Handle last row/col to include remainder pixels when not strict_equal
|
| 84 |
+
if not strict_equal:
|
| 85 |
+
if c == cols - 1:
|
| 86 |
+
right = width
|
| 87 |
+
if r == rows - 1:
|
| 88 |
+
lower = height
|
| 89 |
|
| 90 |
tile = img.crop((left, upper, right, lower))
|
| 91 |
|
| 92 |
# Save tile to bytes
|
| 93 |
tile_bytes = io.BytesIO()
|
| 94 |
+
save_kwargs = {}
|
| 95 |
+
if format == 'JPEG':
|
| 96 |
+
if tile.mode in ('RGBA', 'P'):
|
| 97 |
+
tile = tile.convert('RGB')
|
| 98 |
+
save_kwargs['quality'] = max(1, min(quality, 95))
|
| 99 |
+
save_kwargs['optimize'] = True
|
| 100 |
+
save_kwargs['progressive'] = True
|
| 101 |
+
elif format == 'WEBP':
|
| 102 |
+
save_kwargs['quality'] = max(1, min(quality, 95))
|
| 103 |
+
tile.save(tile_bytes, format=format, **save_kwargs)
|
| 104 |
tile_bytes.seek(0)
|
| 105 |
|
| 106 |
# Add to zip (1-based index)
|
static/js/app.js
CHANGED
|
@@ -6,6 +6,10 @@ createApp({
|
|
| 6 |
const imageFile = ref(null);
|
| 7 |
const loading = ref(false);
|
| 8 |
const imageInfo = ref({ width: 0, height: 0 });
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
const modes = [
|
| 11 |
{ label: '3 x 3 (九宫格)', rows: 3, cols: 3 },
|
|
@@ -25,6 +29,18 @@ createApp({
|
|
| 25 |
};
|
| 26 |
});
|
| 27 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
const handleFileSelect = (event) => {
|
| 29 |
const file = event.target.files[0];
|
| 30 |
processFile(file);
|
|
@@ -75,6 +91,10 @@ createApp({
|
|
| 75 |
formData.append('file', imageFile.value);
|
| 76 |
formData.append('rows', currentMode.value.rows);
|
| 77 |
formData.append('cols', currentMode.value.cols);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
|
| 79 |
try {
|
| 80 |
const response = await fetch('/api/split', {
|
|
@@ -112,7 +132,12 @@ createApp({
|
|
| 112 |
modes,
|
| 113 |
currentMode,
|
| 114 |
gridStyle,
|
|
|
|
| 115 |
imageInfo,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
handleFileSelect,
|
| 117 |
handleDrop,
|
| 118 |
selectMode,
|
|
|
|
| 6 |
const imageFile = ref(null);
|
| 7 |
const loading = ref(false);
|
| 8 |
const imageInfo = ref({ width: 0, height: 0 });
|
| 9 |
+
const outFormat = ref('PNG');
|
| 10 |
+
const quality = ref(90);
|
| 11 |
+
const square = ref(false);
|
| 12 |
+
const strictEqual = ref(false);
|
| 13 |
|
| 14 |
const modes = [
|
| 15 |
{ label: '3 x 3 (九宫格)', rows: 3, cols: 3 },
|
|
|
|
| 29 |
};
|
| 30 |
});
|
| 31 |
|
| 32 |
+
const estimatedTile = computed(() => {
|
| 33 |
+
let w = imageInfo.value.width;
|
| 34 |
+
let h = imageInfo.value.height;
|
| 35 |
+
if (square.value) {
|
| 36 |
+
const side = Math.min(w, h);
|
| 37 |
+
w = side; h = side;
|
| 38 |
+
}
|
| 39 |
+
const tw = Math.floor(w / currentMode.value.cols);
|
| 40 |
+
const th = Math.floor(h / currentMode.value.rows);
|
| 41 |
+
return { width: tw, height: th };
|
| 42 |
+
});
|
| 43 |
+
|
| 44 |
const handleFileSelect = (event) => {
|
| 45 |
const file = event.target.files[0];
|
| 46 |
processFile(file);
|
|
|
|
| 91 |
formData.append('file', imageFile.value);
|
| 92 |
formData.append('rows', currentMode.value.rows);
|
| 93 |
formData.append('cols', currentMode.value.cols);
|
| 94 |
+
formData.append('format', outFormat.value);
|
| 95 |
+
formData.append('quality', quality.value);
|
| 96 |
+
formData.append('square', square.value ? 'true' : 'false');
|
| 97 |
+
formData.append('strict_equal', strictEqual.value ? 'true' : 'false');
|
| 98 |
|
| 99 |
try {
|
| 100 |
const response = await fetch('/api/split', {
|
|
|
|
| 132 |
modes,
|
| 133 |
currentMode,
|
| 134 |
gridStyle,
|
| 135 |
+
estimatedTile,
|
| 136 |
imageInfo,
|
| 137 |
+
outFormat,
|
| 138 |
+
quality,
|
| 139 |
+
square,
|
| 140 |
+
strictEqual,
|
| 141 |
handleFileSelect,
|
| 142 |
handleDrop,
|
| 143 |
selectMode,
|
templates/index.html
CHANGED
|
@@ -85,6 +85,42 @@
|
|
| 85 |
</div>
|
| 86 |
</div>
|
| 87 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
<div class="bg-gray-50 p-4 rounded-lg">
|
| 89 |
<h4 class="font-medium text-gray-700 mb-2">预览信息</h4>
|
| 90 |
<p class="text-sm text-gray-500 flex justify-between">
|
|
@@ -95,6 +131,10 @@
|
|
| 95 |
<span>切片数量:</span>
|
| 96 |
<span>{{ currentMode.rows * currentMode.cols }} 张</span>
|
| 97 |
</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
</div>
|
| 99 |
|
| 100 |
<div class="flex flex-col gap-3">
|
|
|
|
| 85 |
</div>
|
| 86 |
</div>
|
| 87 |
|
| 88 |
+
<div class="bg-gray-50 p-4 rounded-lg">
|
| 89 |
+
<label class="block text-sm font-medium text-gray-700 mb-3">高级设置</label>
|
| 90 |
+
<div class="space-y-3">
|
| 91 |
+
<div class="flex items-center justify-between">
|
| 92 |
+
<span class="text-sm text-gray-600">正方形裁剪</span>
|
| 93 |
+
<label class="inline-flex items-center cursor-pointer">
|
| 94 |
+
<input type="checkbox" class="sr-only peer" v-model="square">
|
| 95 |
+
<div class="w-11 h-6 bg-gray-200 rounded-full peer peer-checked:bg-blue-600 relative transition">
|
| 96 |
+
<div class="absolute bg-white w-5 h-5 rounded-full top-0.5 left-0.5 transition peer-checked:left-5 shadow"></div>
|
| 97 |
+
</div>
|
| 98 |
+
</label>
|
| 99 |
+
</div>
|
| 100 |
+
<div class="flex items-center justify-between">
|
| 101 |
+
<span class="text-sm text-gray-600">统一切片尺寸</span>
|
| 102 |
+
<label class="inline-flex items-center cursor-pointer">
|
| 103 |
+
<input type="checkbox" class="sr-only peer" v-model="strictEqual">
|
| 104 |
+
<div class="w-11 h-6 bg-gray-200 rounded-full peer peer-checked:bg-blue-600 relative transition">
|
| 105 |
+
<div class="absolute bg-white w-5 h-5 rounded-full top-0.5 left-0.5 transition peer-checked:left-5 shadow"></div>
|
| 106 |
+
</div>
|
| 107 |
+
</label>
|
| 108 |
+
</div>
|
| 109 |
+
<div>
|
| 110 |
+
<label class="text-sm text-gray-600">输出格式</label>
|
| 111 |
+
<select v-model="outFormat" class="mt-1 w-full border rounded-md px-2 py-1 text-sm">
|
| 112 |
+
<option value="PNG">PNG(无损,体积较大)</option>
|
| 113 |
+
<option value="JPEG">JPEG(高兼容,质量可调)</option>
|
| 114 |
+
<option value="WEBP">WEBP(体积小,质量可调)</option>
|
| 115 |
+
</select>
|
| 116 |
+
</div>
|
| 117 |
+
<div v-if="outFormat !== 'PNG'">
|
| 118 |
+
<label class="text-sm text-gray-600">质量({{ quality }})</label>
|
| 119 |
+
<input type="range" min="50" max="95" v-model="quality" class="w-full">
|
| 120 |
+
</div>
|
| 121 |
+
</div>
|
| 122 |
+
</div>
|
| 123 |
+
|
| 124 |
<div class="bg-gray-50 p-4 rounded-lg">
|
| 125 |
<h4 class="font-medium text-gray-700 mb-2">预览信息</h4>
|
| 126 |
<p class="text-sm text-gray-500 flex justify-between">
|
|
|
|
| 131 |
<span>切片数量:</span>
|
| 132 |
<span>{{ currentMode.rows * currentMode.cols }} 张</span>
|
| 133 |
</p>
|
| 134 |
+
<p class="text-sm text-gray-500 flex justify-between mt-1">
|
| 135 |
+
<span>预计单片尺寸:</span>
|
| 136 |
+
<span>{{ estimatedTile.width }} x {{ estimatedTile.height }}</span>
|
| 137 |
+
</p>
|
| 138 |
</div>
|
| 139 |
|
| 140 |
<div class="flex flex-col gap-3">
|