duqing2026 commited on
Commit
912fe4d
·
1 Parent(s): 678158e

feat: 高级切图设置与EXIF自适应;更新UI与README

Browse files
Files changed (4) hide show
  1. README.md +24 -1
  2. app.py +47 -8
  3. static/js/app.js +25 -0
  4. 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 c == cols - 1:
56
- right = width
57
- if r == rows - 1:
58
- lower = height
 
59
 
60
  tile = img.crop((left, upper, right, lower))
61
 
62
  # Save tile to bytes
63
  tile_bytes = io.BytesIO()
64
- tile.save(tile_bytes, format=format)
 
 
 
 
 
 
 
 
 
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">