duqing2026 commited on
Commit
8b1c497
·
1 Parent(s): af703bb
Files changed (4) hide show
  1. Dockerfile +34 -0
  2. app.py +163 -0
  3. requirements.txt +3 -0
  4. templates/index.html +203 -0
Dockerfile ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.9-slim
2
+
3
+ # Set working directory
4
+ WORKDIR /app
5
+
6
+ # Install system dependencies (if any needed for Pillow)
7
+ # libjpeg-dev zlib1g-dev are usually included in slim or wheels handle it,
8
+ # but good to be safe if we were building from source.
9
+ # For standard Pillow wheels, slim is fine.
10
+ # We might need fonts for watermark if we want better than default.
11
+ RUN apt-get update && apt-get install -y --no-install-recommends \
12
+ fonts-dejavu-core \
13
+ && rm -rf /var/lib/apt/lists/*
14
+
15
+ # Copy requirements
16
+ COPY requirements.txt .
17
+
18
+ # Install python dependencies
19
+ RUN pip install --no-cache-dir -r requirements.txt
20
+
21
+ # Copy application code
22
+ COPY . .
23
+
24
+ # Create a non-root user (mandatory for HF Spaces)
25
+ RUN useradd -m -u 1000 user
26
+ USER user
27
+ ENV HOME=/home/user \
28
+ PATH=/home/user/.local/bin:$PATH
29
+
30
+ # Expose the port
31
+ EXPOSE 7860
32
+
33
+ # Command to run the app using Gunicorn
34
+ CMD ["gunicorn", "-b", "0.0.0.0:7860", "app:app"]
app.py ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import zipfile
3
+ import io
4
+ from flask import Flask, render_template, request, send_file, after_this_request
5
+ from PIL import Image, ImageDraw, ImageFont, ImageOps
6
+ import tempfile
7
+ import shutil
8
+ import time
9
+
10
+ app = Flask(__name__)
11
+ app.config['MAX_CONTENT_LENGTH'] = 50 * 1024 * 1024 # 50MB limit
12
+
13
+ def process_image(img, options):
14
+ # 1. Strip EXIF (by creating a new image)
15
+ if options.get('strip_exif'):
16
+ data = list(img.getdata())
17
+ image_without_exif = Image.new(img.mode, img.size)
18
+ image_without_exif.putdata(data)
19
+ img = image_without_exif
20
+
21
+ # 2. Resize
22
+ resize_mode = options.get('resize_mode')
23
+ if resize_mode == 'percentage':
24
+ scale = int(options.get('resize_value', 100)) / 100.0
25
+ if scale != 1.0:
26
+ new_size = (int(img.width * scale), int(img.height * scale))
27
+ img = img.resize(new_size, Image.Resampling.LANCZOS)
28
+ elif resize_mode == 'width':
29
+ target_width = int(options.get('resize_value', img.width))
30
+ if target_width != img.width:
31
+ ratio = target_width / img.width
32
+ new_size = (target_width, int(img.height * ratio))
33
+ img = img.resize(new_size, Image.Resampling.LANCZOS)
34
+
35
+ # 3. Watermark
36
+ watermark_text = options.get('watermark_text')
37
+ if watermark_text:
38
+ # Create a transparent layer
39
+ txt_layer = Image.new('RGBA', img.size, (255, 255, 255, 0))
40
+ draw = ImageDraw.Draw(txt_layer)
41
+
42
+ # Calculate font size (relative to image width)
43
+ fontsize = int(img.width / 20)
44
+ if fontsize < 10: fontsize = 10
45
+
46
+ try:
47
+ # Try to load a default font, otherwise load default
48
+ # On Linux/Docker, paths might differ.
49
+ # We'll use default bitmap font if TTF not found or just try-except
50
+ font = ImageFont.truetype("DejaVuSans.ttf", fontsize)
51
+ except IOError:
52
+ font = ImageFont.load_default()
53
+
54
+ # Position: Bottom Right with padding
55
+ # Get text size
56
+ bbox = draw.textbbox((0, 0), watermark_text, font=font)
57
+ textwidth = bbox[2] - bbox[0]
58
+ textheight = bbox[3] - bbox[1]
59
+
60
+ padding = 10
61
+ x = img.width - textwidth - padding
62
+ y = img.height - textheight - padding
63
+
64
+ # Draw semi-transparent text
65
+ draw.text((x, y), watermark_text, font=font, fill=(255, 255, 255, 128))
66
+
67
+ if img.mode != 'RGBA':
68
+ img = img.convert('RGBA')
69
+
70
+ img = Image.alpha_composite(img, txt_layer)
71
+
72
+ # 4. Format Conversion handled during save
73
+ return img
74
+
75
+ @app.route('/', methods=['GET'])
76
+ def index():
77
+ return render_template('index.html')
78
+
79
+ @app.route('/process', methods=['POST'])
80
+ def process():
81
+ if 'files' not in request.files:
82
+ return "No files uploaded", 400
83
+
84
+ files = request.files.getlist('files')
85
+ if not files or files[0].filename == '':
86
+ return "No files selected", 400
87
+
88
+ # Get options
89
+ target_format = request.form.get('target_format', 'original')
90
+ resize_mode = request.form.get('resize_mode', 'none') # none, percentage, width
91
+ resize_value = request.form.get('resize_value', 0)
92
+ watermark_text = request.form.get('watermark_text', '').strip()
93
+ strip_exif = 'strip_exif' in request.form
94
+
95
+ options = {
96
+ 'resize_mode': resize_mode,
97
+ 'resize_value': resize_value,
98
+ 'watermark_text': watermark_text,
99
+ 'strip_exif': strip_exif
100
+ }
101
+
102
+ # Create a temporary directory for processing
103
+ temp_dir = tempfile.mkdtemp()
104
+
105
+ try:
106
+ zip_buffer = io.BytesIO()
107
+ with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
108
+ for file in files:
109
+ if not file.filename:
110
+ continue
111
+
112
+ try:
113
+ img = Image.open(file.stream)
114
+
115
+ # Process
116
+ img = process_image(img, options)
117
+
118
+ # Determine output filename and format
119
+ original_name = os.path.splitext(file.filename)[0]
120
+ ext = os.path.splitext(file.filename)[1].lower()
121
+
122
+ if target_format != 'original':
123
+ ext = '.' + target_format.lower()
124
+ if target_format.lower() == 'jpeg':
125
+ img = img.convert('RGB')
126
+ elif target_format.lower() == 'png':
127
+ # Keep RGBA if possible, or convert if needed
128
+ pass
129
+
130
+ # Save to temp buffer
131
+ img_byte_arr = io.BytesIO()
132
+ save_format = ext.strip('.').upper()
133
+ if save_format == 'JPG': save_format = 'JPEG'
134
+
135
+ # Handle WEBP/JPEG quality if needed (using default for now)
136
+ img.save(img_byte_arr, format=save_format)
137
+
138
+ # Add to ZIP
139
+ zip_file.writestr(f"processed/{original_name}{ext}", img_byte_arr.getvalue())
140
+
141
+ except Exception as e:
142
+ print(f"Error processing {file.filename}: {e}")
143
+ # Optionally add an error log to the zip
144
+ zip_file.writestr(f"processed/errors/{file.filename}.txt", str(e))
145
+
146
+ zip_buffer.seek(0)
147
+
148
+ # Cleanup temp dir (we didn't actually write files to disk, just memory,
149
+ # but if we did, we'd clean here. The `img_byte_arr` approach avoids disk IO)
150
+
151
+ return send_file(
152
+ zip_buffer,
153
+ mimetype='application/zip',
154
+ as_attachment=True,
155
+ download_name=f'processed_images_{int(time.time())}.zip'
156
+ )
157
+
158
+ finally:
159
+ shutil.rmtree(temp_dir)
160
+
161
+ if __name__ == '__main__':
162
+ port = int(os.environ.get('PORT', 7860))
163
+ app.run(host='0.0.0.0', port=port)
requirements.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ Flask>=2.0.0
2
+ Pillow>=9.0.0
3
+ gunicorn>=20.1.0
templates/index.html ADDED
@@ -0,0 +1,203 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>批量图片处理工坊 - Batch Image Workshop</title>
7
+ <script src="https://cdn.tailwindcss.com"></script>
8
+ <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
9
+ <style>
10
+ .drop-zone {
11
+ border: 2px dashed #cbd5e1;
12
+ transition: all 0.3s ease;
13
+ }
14
+ .drop-zone.dragover {
15
+ border-color: #3b82f6;
16
+ background-color: #eff6ff;
17
+ }
18
+ </style>
19
+ </head>
20
+ <body class="bg-gray-50 min-h-screen text-gray-800 font-sans">
21
+ <div class="container mx-auto px-4 py-8 max-w-4xl">
22
+ <!-- Header -->
23
+ <header class="text-center mb-10">
24
+ <h1 class="text-4xl font-bold text-gray-900 mb-2">
25
+ <i class="fas fa-images text-blue-500 mr-2"></i>批量图片处理工坊
26
+ </h1>
27
+ <p class="text-gray-600">
28
+ 完全本地运行(CPU),隐私安全。支持批量缩放、格式转换、水印添加。
29
+ </p>
30
+ </header>
31
+
32
+ <form action="/process" method="POST" enctype="multipart/form-data" id="processForm" class="bg-white rounded-xl shadow-lg p-6 md:p-8">
33
+
34
+ <!-- Upload Section -->
35
+ <div class="mb-8">
36
+ <h2 class="text-xl font-semibold mb-4 flex items-center">
37
+ <span class="bg-blue-100 text-blue-600 rounded-full w-8 h-8 flex items-center justify-center mr-3 text-sm">1</span>
38
+ 上传图片
39
+ </h2>
40
+ <div id="dropZone" class="drop-zone rounded-lg p-10 text-center cursor-pointer hover:bg-gray-50">
41
+ <input type="file" name="files" id="fileInput" multiple accept="image/*" class="hidden">
42
+ <div class="space-y-3">
43
+ <i class="fas fa-cloud-upload-alt text-4xl text-gray-400"></i>
44
+ <div class="text-lg text-gray-600">点击或拖拽图片到这里</div>
45
+ <p class="text-sm text-gray-400" id="fileCount">支持 JPG, PNG, WEBP 等常见格式</p>
46
+ </div>
47
+ </div>
48
+ <div id="fileList" class="mt-4 flex flex-wrap gap-2 text-sm text-gray-600"></div>
49
+ </div>
50
+
51
+ <!-- Settings Section -->
52
+ <div class="mb-8">
53
+ <h2 class="text-xl font-semibold mb-4 flex items-center">
54
+ <span class="bg-blue-100 text-blue-600 rounded-full w-8 h-8 flex items-center justify-center mr-3 text-sm">2</span>
55
+ 处理选项
56
+ </h2>
57
+
58
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
59
+ <!-- Format -->
60
+ <div class="space-y-2">
61
+ <label class="block font-medium text-gray-700">目标格式</label>
62
+ <select name="target_format" class="w-full border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 p-2 border">
63
+ <option value="original">保持原格式</option>
64
+ <option value="jpeg">JPG / JPEG</option>
65
+ <option value="png">PNG</option>
66
+ <option value="webp">WEBP</option>
67
+ </select>
68
+ </div>
69
+
70
+ <!-- Resize -->
71
+ <div class="space-y-2">
72
+ <label class="block font-medium text-gray-700">调整大小</label>
73
+ <div class="flex gap-2">
74
+ <select name="resize_mode" id="resizeMode" class="w-1/2 border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 p-2 border">
75
+ <option value="none">不调整</option>
76
+ <option value="percentage">按百分比缩放</option>
77
+ <option value="width">固定宽度 (保持比例)</option>
78
+ </select>
79
+ <input type="number" name="resize_value" id="resizeValue" placeholder="100" class="w-1/2 border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 p-2 border hidden">
80
+ </div>
81
+ </div>
82
+
83
+ <!-- Watermark -->
84
+ <div class="space-y-2">
85
+ <label class="block font-medium text-gray-700">文字水印 (可选)</label>
86
+ <input type="text" name="watermark_text" placeholder="例如:@我的版权" class="w-full border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 p-2 border">
87
+ </div>
88
+
89
+ <!-- Extras -->
90
+ <div class="space-y-2 flex items-center pt-6">
91
+ <label class="inline-flex items-center cursor-pointer">
92
+ <input type="checkbox" name="strip_exif" class="form-checkbox h-5 w-5 text-blue-600" checked>
93
+ <span class="ml-2 text-gray-700">清除 EXIF 信息 (隐私保护)</span>
94
+ </label>
95
+ </div>
96
+ </div>
97
+ </div>
98
+
99
+ <!-- Submit -->
100
+ <div class="text-center">
101
+ <button type="submit" id="submitBtn" class="bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 px-10 rounded-full shadow-lg transition duration-200 transform hover:scale-105 disabled:opacity-50 disabled:cursor-not-allowed">
102
+ <i class="fas fa-magic mr-2"></i>开始处理并下载
103
+ </button>
104
+ </div>
105
+ </form>
106
+
107
+ <footer class="mt-12 text-center text-gray-400 text-sm">
108
+ <p>&copy; 2026 Batch Image Workshop. Open Source Project.</p>
109
+ </footer>
110
+ </div>
111
+
112
+ <script>
113
+ const dropZone = document.getElementById('dropZone');
114
+ const fileInput = document.getElementById('fileInput');
115
+ const fileList = document.getElementById('fileList');
116
+ const fileCount = document.getElementById('fileCount');
117
+ const resizeMode = document.getElementById('resizeMode');
118
+ const resizeValue = document.getElementById('resizeValue');
119
+ const processForm = document.getElementById('processForm');
120
+ const submitBtn = document.getElementById('submitBtn');
121
+
122
+ // Drag & Drop interactions
123
+ dropZone.addEventListener('dragover', (e) => {
124
+ e.preventDefault();
125
+ dropZone.classList.add('dragover');
126
+ });
127
+
128
+ dropZone.addEventListener('dragleave', () => {
129
+ dropZone.classList.remove('dragover');
130
+ });
131
+
132
+ dropZone.addEventListener('drop', (e) => {
133
+ e.preventDefault();
134
+ dropZone.classList.remove('dragover');
135
+ fileInput.files = e.dataTransfer.files;
136
+ updateFileList();
137
+ });
138
+
139
+ dropZone.addEventListener('click', () => {
140
+ fileInput.click();
141
+ });
142
+
143
+ fileInput.addEventListener('change', updateFileList);
144
+
145
+ function updateFileList() {
146
+ const files = fileInput.files;
147
+ if (files.length > 0) {
148
+ fileCount.innerText = `已选择 ${files.length} 个文件`;
149
+ fileList.innerHTML = '';
150
+ // Only show first 5 names
151
+ Array.from(files).slice(0, 5).forEach(file => {
152
+ const span = document.createElement('span');
153
+ span.className = 'bg-gray-100 px-2 py-1 rounded border';
154
+ span.innerText = file.name;
155
+ fileList.appendChild(span);
156
+ });
157
+ if (files.length > 5) {
158
+ const more = document.createElement('span');
159
+ more.innerText = `... 等共 ${files.length} 个文件`;
160
+ fileList.appendChild(more);
161
+ }
162
+ } else {
163
+ fileCount.innerText = '支持 JPG, PNG, WEBP 等常见格式';
164
+ fileList.innerHTML = '';
165
+ }
166
+ }
167
+
168
+ // Resize inputs logic
169
+ resizeMode.addEventListener('change', () => {
170
+ if (resizeMode.value === 'none') {
171
+ resizeValue.classList.add('hidden');
172
+ } else {
173
+ resizeValue.classList.remove('hidden');
174
+ if (resizeMode.value === 'percentage') {
175
+ resizeValue.placeholder = '缩放比例 % (例如 50)';
176
+ resizeValue.value = 50;
177
+ } else {
178
+ resizeValue.placeholder = '目标宽度 px (例如 800)';
179
+ resizeValue.value = 800;
180
+ }
181
+ }
182
+ });
183
+
184
+ // Form Submit
185
+ processForm.addEventListener('submit', () => {
186
+ if (fileInput.files.length === 0) {
187
+ alert('请先上传图片!');
188
+ event.preventDefault();
189
+ return;
190
+ }
191
+ // Show loading state
192
+ submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>处理中...';
193
+ submitBtn.disabled = true;
194
+
195
+ // Re-enable after a few seconds (since download doesn't trigger page reload)
196
+ setTimeout(() => {
197
+ submitBtn.innerHTML = '<i class="fas fa-magic mr-2"></i>开始处理并下载';
198
+ submitBtn.disabled = false;
199
+ }, 3000);
200
+ });
201
+ </script>
202
+ </body>
203
+ </html>