Initial commit - Mobile Wallpaper Service

This commit is contained in:
arch_agent
2026-04-19 15:39:23 +02:00
commit 8c932b13b0
15 changed files with 905 additions and 0 deletions

215
app.py Normal file
View 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
View 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
View 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
View 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;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 937 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 837 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 726 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 874 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 533 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 746 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 597 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 918 KiB

222
templates/index.html Normal file
View 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 &amp; 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 &amp; 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>