Initial commit - Mobile Wallpaper Service
215
app.py
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Mobile Wallpaper Processor - Flask Web Service
|
||||||
|
Skaliert, Schneidet zu und entfernt Text aus Bildern für 1440x3120 Displays
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
import uuid
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from flask import Flask, render_template, request, send_file, jsonify, url_for
|
||||||
|
from werkzeug.utils import secure_filename
|
||||||
|
from PIL import Image, ImageEnhance
|
||||||
|
import easyocr
|
||||||
|
import threading
|
||||||
|
|
||||||
|
# Konfiguration
|
||||||
|
TARGET_WIDTH = 1440
|
||||||
|
TARGET_HEIGHT = 3120
|
||||||
|
TARGET_ASPECT = TARGET_WIDTH / TARGET_HEIGHT
|
||||||
|
UPLOAD_FOLDER = 'static/uploads'
|
||||||
|
DOWNLOAD_FOLDER = 'static/downloads'
|
||||||
|
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'tiff'}
|
||||||
|
MAX_FILE_SIZE = 50 * 1024 * 1024 # 50MB
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
|
||||||
|
app.config['DOWNLOAD_FOLDER'] = DOWNLOAD_FOLDER
|
||||||
|
app.config['MAX_CONTENT_LENGTH'] = MAX_FILE_SIZE
|
||||||
|
|
||||||
|
# Globaler OCR Reader (wird einmal geladen)
|
||||||
|
ocr_reader = None
|
||||||
|
def get_ocr_reader():
|
||||||
|
global ocr_reader
|
||||||
|
if ocr_reader is None:
|
||||||
|
print("Lade OCR Model...")
|
||||||
|
ocr_reader = easyocr.Reader(['de', 'en'], gpu=False)
|
||||||
|
return ocr_reader
|
||||||
|
|
||||||
|
def allowed_file(filename):
|
||||||
|
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
|
||||||
|
|
||||||
|
def cleanup_old_files():
|
||||||
|
"""Löscht Dateien älter als 1 Stunde"""
|
||||||
|
cutoff = datetime.now() - timedelta(hours=1)
|
||||||
|
for folder in [UPLOAD_FOLDER, DOWNLOAD_FOLDER]:
|
||||||
|
for filename in os.listdir(folder):
|
||||||
|
filepath = os.path.join(folder, filename)
|
||||||
|
try:
|
||||||
|
if os.path.isfile(filepath):
|
||||||
|
file_time = datetime.fromtimestamp(os.path.getmtime(filepath))
|
||||||
|
if file_time < cutoff:
|
||||||
|
os.remove(filepath)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Cleanup error: {e}")
|
||||||
|
|
||||||
|
def smart_crop(img_array, target_width, target_height):
|
||||||
|
"""Intelligentes Zuschneiden mit Fokus auf Bildzentrum"""
|
||||||
|
img = Image.fromarray(cv2.cvtColor(img_array, cv2.COLOR_BGR2RGB))
|
||||||
|
original_width, original_height = img.size
|
||||||
|
target_aspect = target_width / target_height
|
||||||
|
original_aspect = original_width / original_height
|
||||||
|
|
||||||
|
if original_aspect > target_aspect:
|
||||||
|
# Bild ist breiter als Ziel: Horizontal zuschneiden
|
||||||
|
new_width = int(original_height * target_aspect)
|
||||||
|
left = (original_width - new_width) // 2
|
||||||
|
img_cropped = img.crop((left, 0, left + new_width, original_height))
|
||||||
|
else:
|
||||||
|
# Bild ist höher als Ziel: Vertikal zuschneiden
|
||||||
|
new_height = int(original_width / target_aspect)
|
||||||
|
top = (original_height - new_height) // 2
|
||||||
|
img_cropped = img.crop((0, top, original_width, top + new_height))
|
||||||
|
|
||||||
|
# Auf Zielgröße skalieren
|
||||||
|
img_resized = img_cropped.resize((target_width, target_height), Image.Resampling.LANCZOS)
|
||||||
|
return cv2.cvtColor(np.array(img_resized), cv2.COLOR_RGB2BGR)
|
||||||
|
|
||||||
|
def detect_text_regions(img):
|
||||||
|
"""Erkennt Textbereiche im Bild mit OCR"""
|
||||||
|
reader = get_ocr_reader()
|
||||||
|
results = reader.readtext(img)
|
||||||
|
|
||||||
|
mask = np.zeros(img.shape[:2], dtype=np.uint8)
|
||||||
|
detected_boxes = []
|
||||||
|
|
||||||
|
for (bbox, text, conf) in results:
|
||||||
|
if conf > 0.3: # Nur Text mit ausreichender Konfidenz
|
||||||
|
# bbox sind 4 Punkte: [[x1,y1], [x2,y2], [x3,y3], [x4,y4]]
|
||||||
|
pts = np.array(bbox, np.int32)
|
||||||
|
# Erweitere Box um Padding
|
||||||
|
x_coords = [p[0] for p in bbox]
|
||||||
|
y_coords = [p[1] for p in bbox]
|
||||||
|
x_min, x_max = max(0, min(x_coords) - 10), min(img.shape[1], max(x_coords) + 10)
|
||||||
|
y_min, y_max = max(0, min(y_coords) - 10), min(img.shape[0], max(y_coords) + 10)
|
||||||
|
|
||||||
|
cv2.rectangle(mask, (x_min, y_min), (x_max, y_max), 255, -1)
|
||||||
|
detected_boxes.append({'text': text, 'confidence': conf, 'box': [x_min, y_min, x_max, y_max]})
|
||||||
|
|
||||||
|
return mask, detected_boxes
|
||||||
|
|
||||||
|
def remove_text_inpaint(img, mask):
|
||||||
|
"""Entfernt Text durch OpenCV Inpainting"""
|
||||||
|
if np.sum(mask) == 0:
|
||||||
|
return img
|
||||||
|
|
||||||
|
# Telea Inpainting - gut für kleine strukturierte Bereiche wie Text
|
||||||
|
result = cv2.inpaint(img, mask, 3, cv2.INPAINT_TELEA)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def auto_enhance(img):
|
||||||
|
"""Automatische Bildverbesserung"""
|
||||||
|
pil_img = Image.fromarray(cv2.cvtColor(img, cv2.COLOR_BGR2RGB))
|
||||||
|
|
||||||
|
# Kontrast leicht erhöhen
|
||||||
|
enhancer = ImageEnhance.Contrast(pil_img)
|
||||||
|
pil_img = enhancer.enhance(1.1)
|
||||||
|
|
||||||
|
# Sättigung leicht erhöhen
|
||||||
|
enhancer = ImageEnhance.Color(pil_img)
|
||||||
|
pil_img = enhancer.enhance(1.1)
|
||||||
|
|
||||||
|
# Schärfen
|
||||||
|
enhancer = ImageEnhance.Sharpness(pil_img)
|
||||||
|
pil_img = enhancer.enhance(1.2)
|
||||||
|
|
||||||
|
return cv2.cvtColor(np.array(pil_img), cv2.COLOR_RGB2BGR)
|
||||||
|
|
||||||
|
def process_image(input_path, remove_text=True):
|
||||||
|
"""Hauptverarbeitungs-Pipeline"""
|
||||||
|
|
||||||
|
# Bild laden
|
||||||
|
img = cv2.imread(input_path)
|
||||||
|
if img is None:
|
||||||
|
raise ValueError("Bild konnte nicht geladen werden")
|
||||||
|
|
||||||
|
# Optional: Text entfernen
|
||||||
|
if remove_text:
|
||||||
|
mask, detected_text = detect_text_regions(img)
|
||||||
|
if len(detected_text) > 0:
|
||||||
|
img = remove_text_inpaint(img, mask)
|
||||||
|
|
||||||
|
# Smart Crop auf Zielauflösung
|
||||||
|
img_processed = smart_crop(img, TARGET_WIDTH, TARGET_HEIGHT)
|
||||||
|
|
||||||
|
# Auto-Enhance
|
||||||
|
img_processed = auto_enhance(img_processed)
|
||||||
|
|
||||||
|
return img_processed
|
||||||
|
|
||||||
|
@app.route('/')
|
||||||
|
def index():
|
||||||
|
return render_template('index.html')
|
||||||
|
|
||||||
|
@app.route('/upload', methods=['POST'])
|
||||||
|
def upload_file():
|
||||||
|
if 'file' not in request.files:
|
||||||
|
return jsonify({'error': 'Keine Datei hochgeladen'}), 400
|
||||||
|
|
||||||
|
file = request.files['file']
|
||||||
|
if file.filename == '':
|
||||||
|
return jsonify({'error': 'Keine Datei ausgewählt'}), 400
|
||||||
|
|
||||||
|
if not allowed_file(file.filename):
|
||||||
|
return jsonify({'error': 'Dateiformat nicht erlaubt. Erlaubt: ' + str(ALLOWED_EXTENSIONS)}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Cleanup alte Dateien
|
||||||
|
cleanup_old_files()
|
||||||
|
|
||||||
|
# Eindeutigen Dateinamen generieren
|
||||||
|
filename = str(uuid.uuid4())[:8] + '_' + secure_filename(file.filename)
|
||||||
|
input_path = os.path.join(app.config['UPLOAD_FOLDER'], filename)
|
||||||
|
file.save(input_path)
|
||||||
|
|
||||||
|
# Optionen
|
||||||
|
remove_text = request.form.get('remove_text', 'true').lower() == 'true'
|
||||||
|
|
||||||
|
# Bild verarbeiten
|
||||||
|
processed = process_image(input_path, remove_text=remove_text)
|
||||||
|
|
||||||
|
# Ausgabedatei
|
||||||
|
output_filename = 'processed_' + os.path.splitext(filename)[0] + '.jpg'
|
||||||
|
output_path = os.path.join(app.config['DOWNLOAD_FOLDER'], output_filename)
|
||||||
|
cv2.imwrite(output_path, processed, [cv2.IMWRITE_JPEG_QUALITY, 95])
|
||||||
|
|
||||||
|
# Original löschen
|
||||||
|
os.remove(input_path)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'download_url': url_for('download_file', filename=output_filename),
|
||||||
|
'preview_url': '/' + output_path,
|
||||||
|
'message': f'Bild verarbeitet: {TARGET_WIDTH}x{TARGET_HEIGHT}px'
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
@app.route('/download/<filename>')
|
||||||
|
def download_file(filename):
|
||||||
|
return send_file(os.path.join(app.config['DOWNLOAD_FOLDER'], filename),
|
||||||
|
as_attachment=True, download_name='wallpaper_1440x3120.jpg')
|
||||||
|
|
||||||
|
@app.route('/cleanup', methods=['POST'])
|
||||||
|
def manual_cleanup():
|
||||||
|
cleanup_old_files()
|
||||||
|
return jsonify({'success': True, 'message': 'Aufgeräumt'})
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
print(f"Starting Mobile Wallpaper Service...")
|
||||||
|
print(f"Zielauflösung: {TARGET_WIDTH}x{TARGET_HEIGHT} ({TARGET_ASPECT:.3f})")
|
||||||
|
app.run(host='0.0.0.0', port=5000, debug=True)
|
||||||
6
requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
flask>=2.3.3
|
||||||
|
opencv-python-headless>=4.9.0
|
||||||
|
pillow>=10.2.0
|
||||||
|
numpy>=1.26.0
|
||||||
|
werkzeug>=2.3.7
|
||||||
|
easyocr>=1.7.0
|
||||||
41
start.sh
Executable file
@@ -0,0 +1,41 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Installation und Start Script für Mobile Wallpaper Processor
|
||||||
|
|
||||||
|
echo "🚀 Mobile Wallpaper Processor Setup"
|
||||||
|
echo "===================================="
|
||||||
|
|
||||||
|
# Prüfe Python
|
||||||
|
if ! command -v python3 &> /dev/null; then
|
||||||
|
echo "❌ Python3 ist nicht installiert"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
# Virtuelle Umgebung erstellen
|
||||||
|
if [ ! -d "venv" ]; then
|
||||||
|
echo "📦 Erstelle virtuelle Umgebung..."
|
||||||
|
python3 -m venv venv
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Aktivieren
|
||||||
|
source venv/bin/activate
|
||||||
|
|
||||||
|
# Abhängigkeiten installieren
|
||||||
|
echo "📥 Installiere Abhängigkeiten..."
|
||||||
|
pip install --upgrade pip
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# Download OCR Model beim ersten Start
|
||||||
|
echo "🤖 Lade OCR Model herunter (einmalig)..."
|
||||||
|
python3 -c "import easyocr; easyocr.Reader(['de','en'], gpu=False)" 2>/dev/null || true
|
||||||
|
|
||||||
|
# Ordner erstellen
|
||||||
|
mkdir -p static/uploads static/downloads
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✅ Installation abgeschlossen!"
|
||||||
|
echo ""
|
||||||
|
echo "Starte Server auf http://localhost:5000"
|
||||||
|
echo ""
|
||||||
|
python3 app.py
|
||||||
421
static/css/style.css
Normal file
@@ -0,0 +1,421 @@
|
|||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--primary: #6366f1;
|
||||||
|
--primary-dark: #4f46e5;
|
||||||
|
--bg-dark: #0f0f1a;
|
||||||
|
--bg-card: #1a1a2e;
|
||||||
|
--bg-hover: #252542;
|
||||||
|
--text: #e2e8f0;
|
||||||
|
--text-muted: #94a3b8;
|
||||||
|
--border: #2d2d4a;
|
||||||
|
--success: #22c55e;
|
||||||
|
--error: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
|
background: var(--bg-dark);
|
||||||
|
color: var(--text);
|
||||||
|
min-height: 100vh;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 40px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
background: linear-gradient(135deg, var(--primary), #a855f7);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Upload Zone */
|
||||||
|
.upload-zone {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 2px dashed var(--border);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 60px 40px;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-zone:hover, .upload-zone.dragover {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-icon {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
color: var(--primary);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-text {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-hint {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Options */
|
||||||
|
.options {
|
||||||
|
margin-top: 24px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label:hover {
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkmark {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
min-width: 22px;
|
||||||
|
border: 2px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
position: relative;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label input:checked + .checkmark {
|
||||||
|
background: var(--primary);
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label input:checked + .checkmark::after {
|
||||||
|
content: '✓';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
color: white;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-text strong {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-text small {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Preview */
|
||||||
|
.preview {
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview h3 {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-container {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-item {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-item img {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 200px;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-label {
|
||||||
|
display: block;
|
||||||
|
margin-top: 8px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Phone Frame */
|
||||||
|
.phone-frame {
|
||||||
|
position: relative;
|
||||||
|
width: 120px;
|
||||||
|
height: 260px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: #000;
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 8px;
|
||||||
|
box-shadow: 0 20px 50px rgba(0,0,0,0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.phone-screen {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.phone-screen img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phone-notch {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 60px;
|
||||||
|
height: 18px;
|
||||||
|
background: #000;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Progress */
|
||||||
|
.progress {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 30px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
width: 100%;
|
||||||
|
height: 6px;
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
width: 0%;
|
||||||
|
background: linear-gradient(90deg, var(--primary), #a855f7);
|
||||||
|
border-radius: 3px;
|
||||||
|
animation: progress 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes progress {
|
||||||
|
0% { width: 0%; opacity: 1; }
|
||||||
|
50% { width: 100%; opacity: 1; }
|
||||||
|
100% { width: 100%; opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-text {
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.processing-steps {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 20px;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step.active {
|
||||||
|
color: var(--primary);
|
||||||
|
background: rgba(99, 102, 241, 0.1);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step::before {
|
||||||
|
content: '○ ';
|
||||||
|
}
|
||||||
|
|
||||||
|
.step.active::before {
|
||||||
|
content: '● ';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Result */
|
||||||
|
.result {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-phone {
|
||||||
|
width: 200px;
|
||||||
|
height: 430px;
|
||||||
|
margin: 0 auto 30px;
|
||||||
|
background: #000;
|
||||||
|
border-radius: 36px;
|
||||||
|
padding: 12px;
|
||||||
|
box-shadow: 0 20px 80px rgba(0,0,0,0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-phone img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 14px 28px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--primary);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: var(--primary-dark);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 8px 25px rgba(99, 102, 241, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: var(--bg-card);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-info {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-info strong {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error */
|
||||||
|
.error {
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
border: 1px solid var(--error);
|
||||||
|
color: #fca5a5;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
footer {
|
||||||
|
margin-top: 60px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Utilities */
|
||||||
|
.hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile */
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.container {
|
||||||
|
padding: 20px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-zone {
|
||||||
|
padding: 40px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-container {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phone-frame {
|
||||||
|
transform: scale(0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 935 KiB |
|
After Width: | Height: | Size: 937 KiB |
|
After Width: | Height: | Size: 837 KiB |
|
After Width: | Height: | Size: 726 KiB |
|
After Width: | Height: | Size: 874 KiB |
|
After Width: | Height: | Size: 533 KiB |
|
After Width: | Height: | Size: 746 KiB |
|
After Width: | Height: | Size: 597 KiB |
|
After Width: | Height: | Size: 737 KiB |
|
After Width: | Height: | Size: 918 KiB |
222
templates/index.html
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Mobile Wallpaper Processor</title>
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<header>
|
||||||
|
<h1>📱 Mobile Wallpaper Processor</h1>
|
||||||
|
<p class="subtitle">Optimiere Bilder für 1440×3120 Displays (Pixel 6 Pro, Nothing Phone 3)</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="upload-zone" id="dropZone">
|
||||||
|
<input type="file" id="fileInput" accept=".jpg,.jpeg,.png,.gif,.bmp,.webp,.tiff" hidden>
|
||||||
|
<div class="upload-content">
|
||||||
|
<svg class="upload-icon" viewbox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||||
|
<polyline points="17 8 12 3 7 8"></polyline>
|
||||||
|
<line x1="12" y1="3" x2="12" y2="15"></line>
|
||||||
|
</svg>
|
||||||
|
<p class="upload-text">Bild hier ziehen oder klicken zum Auswählen</p>
|
||||||
|
<p class="upload-hint">JPG, PNG, WEBP, GIF | max. 50MB</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="options">
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" id="removeText" checked>
|
||||||
|
<span class="checkmark"></span>
|
||||||
|
<span class="option-text">
|
||||||
|
<strong>Text erkennen & entfernen</strong>
|
||||||
|
<small>Verwendet AI-Inpainting um Text im Bild zu entfernen</small>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="preview" class="preview hidden">
|
||||||
|
<h3>Vorschau</h3>
|
||||||
|
<div class="preview-container">
|
||||||
|
<div class="preview-item">
|
||||||
|
<img id="originalPreview" alt="Original">
|
||||||
|
<span class="preview-label">Original</span>
|
||||||
|
</div>
|
||||||
|
<div class="preview-item">
|
||||||
|
<div class="phone-frame">
|
||||||
|
<div class="phone-screen">
|
||||||
|
<img id="croppedPreview" alt="Zugeschnitten">
|
||||||
|
</div>
|
||||||
|
<div class="phone-notch"></div>
|
||||||
|
</div>
|
||||||
|
<span class="preview-label">1440×3120 Crop</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="progress" class="progress hidden">
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div class="progress-fill"></div>
|
||||||
|
</div>
|
||||||
|
<p class="progress-text">Verarbeite Bild...</p>
|
||||||
|
<div class="processing-steps">
|
||||||
|
<span id="step1" class="step">Text erkennen</span>
|
||||||
|
<span id="step2" class="step">Text entfernen</span>
|
||||||
|
<span id="step3" class="step">Smart Crop</span>
|
||||||
|
<span id="step4" class="step">Optimieren</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="result" class="result hidden">
|
||||||
|
<div class="result-phone">
|
||||||
|
<img id="resultImage" alt="Ergebnis">
|
||||||
|
</div>
|
||||||
|
<div class="result-actions">
|
||||||
|
<a id="downloadBtn" class="btn btn-primary" href="#">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||||
|
<polyline points="7 10 12 15 17 10"></polyline>
|
||||||
|
<line x1="12" y1="15" x2="12" y2="3"></line>
|
||||||
|
</svg>
|
||||||
|
Wallpaper herunterladen (1440×3120)
|
||||||
|
</a>
|
||||||
|
<button class="btn btn-secondary" onclick="resetApp()">Neues Bild</button>
|
||||||
|
</div>
|
||||||
|
<div class="result-info">
|
||||||
|
<p>Das Bild wurde auf <strong>1440×3120 Pixel</strong> optimiert.</p>
|
||||||
|
<p>Format: <strong>9.7:19.5 Hochkant</strong></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="error" class="error hidden"></div>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<p>Automatische Optimierung: Smart Crop, AI Text-Entfernung, Kontrast & Schärfe</p>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const dropZone = document.getElementById('dropZone');
|
||||||
|
const fileInput = document.getElementById('fileInput');
|
||||||
|
const originalPreview = document.getElementById('originalPreview');
|
||||||
|
const croppedPreview = document.getElementById('croppedPreview');
|
||||||
|
const preview = document.getElementById('preview');
|
||||||
|
const progress = document.getElementById('progress');
|
||||||
|
const result = document.getElementById('result');
|
||||||
|
const resultImage = document.getElementById('resultImage');
|
||||||
|
const downloadBtn = document.getElementById('downloadBtn');
|
||||||
|
const error = document.getElementById('error');
|
||||||
|
|
||||||
|
// Event Listeners
|
||||||
|
dropZone.addEventListener('click', () => fileInput.click());
|
||||||
|
dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.classList.add('dragover'); });
|
||||||
|
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('dragover'));
|
||||||
|
dropZone.addEventListener('drop', handleDrop);
|
||||||
|
fileInput.addEventListener('change', handleFileSelect);
|
||||||
|
|
||||||
|
function handleDrop(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
dropZone.classList.remove('dragover');
|
||||||
|
const files = e.dataTransfer.files;
|
||||||
|
if (files.length) processFile(files[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFileSelect(e) {
|
||||||
|
const files = e.target.files;
|
||||||
|
if (files.length) processFile(files[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function processFile(file) {
|
||||||
|
// Validierung
|
||||||
|
const validTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/bmp', 'image/tiff'];
|
||||||
|
if (!validTypes.includes(file.type)) {
|
||||||
|
showError('Bitte ein gültiges Bildformat hochladen (JPG, PNG, WEBP, GIF)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (file.size > 50 * 1024 * 1024) {
|
||||||
|
showError('Datei zu groß (max. 50MB)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vorschau anzeigen
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
originalPreview.src = e.target.result;
|
||||||
|
croppedPreview.src = e.target.result;
|
||||||
|
preview.classList.remove('hidden');
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
|
||||||
|
// Upload
|
||||||
|
uploadFile(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
function uploadFile(file) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
formData.append('remove_text', document.getElementById('removeText').checked);
|
||||||
|
|
||||||
|
// UI Update
|
||||||
|
dropZone.style.display = 'none';
|
||||||
|
preview.classList.add('hidden');
|
||||||
|
progress.classList.remove('hidden');
|
||||||
|
result.classList.add('hidden');
|
||||||
|
error.classList.add('hidden');
|
||||||
|
|
||||||
|
// Progress Animation
|
||||||
|
const steps = ['step1', 'step2', 'step3', 'step4'];
|
||||||
|
let currentStep = 0;
|
||||||
|
const stepInterval = setInterval(() => {
|
||||||
|
if (currentStep < steps.length) {
|
||||||
|
document.getElementById(steps[currentStep]).classList.add('active');
|
||||||
|
currentStep++;
|
||||||
|
}
|
||||||
|
}, 800);
|
||||||
|
|
||||||
|
fetch('/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
clearInterval(stepInterval);
|
||||||
|
progress.classList.add('hidden');
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
resultImage.src = data.preview_url;
|
||||||
|
downloadBtn.href = data.download_url;
|
||||||
|
result.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
showError(data.error || 'Unbekannter Fehler');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
clearInterval(stepInterval);
|
||||||
|
progress.classList.add('hidden');
|
||||||
|
showError('Netzwerkfehler: ' + err.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(msg) {
|
||||||
|
error.textContent = msg;
|
||||||
|
error.classList.remove('hidden');
|
||||||
|
dropZone.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetApp() {
|
||||||
|
dropZone.style.display = 'block';
|
||||||
|
preview.classList.add('hidden');
|
||||||
|
progress.classList.add('hidden');
|
||||||
|
result.classList.add('hidden');
|
||||||
|
error.classList.add('hidden');
|
||||||
|
fileInput.value = '';
|
||||||
|
|
||||||
|
// Reset steps
|
||||||
|
document.querySelectorAll('.step').forEach(s => s.classList.remove('active', 'done'));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||