Esmaill1 commited on
Commit
e64ee47
ยท
1 Parent(s): 1178450

Initialize Hugging Face Space with project files

Browse files
.gitignore ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.py[cod]
3
+ *$py.class
4
+ .env
5
+ .venv
6
+ env/
7
+ venv/
8
+ ENV/
9
+ storage/uploads/*
10
+ storage/processed/*
11
+ storage/results/*
12
+ !storage/uploads/.gitkeep
13
+ !storage/processed/.gitkeep
14
+ !storage/results/.gitkeep
15
+ .DS_Store
16
+ .vscode/
17
+ .idea/
18
+ *.log
DOCUMENTATION.md ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ๐Ÿ“œ ID Maker Studio: Technical Master Documentation
2
+
3
+ This document serves as the comprehensive technical map for the **EL HELAL Studio Photo Pipeline**.
4
+
5
+ ---
6
+
7
+ ## ๐Ÿ— High-Level Architecture
8
+
9
+ The system is a modular Python-based suite designed to automate the conversion of raw student portraits into professional, print-ready ID sheets. It bridges the gap between complex AI models and a production studio environment.
10
+
11
+ ### ๐Ÿงฉ Component Breakdown
12
+ - **`/core` (The Brain):** Pure logic and AI processing. It is UI-agnostic and handles image math, landmark detection, and layout composition.
13
+ - **`/web` (The Primary Interface):** A modern FastAPI backend coupled with a localized Arabic (RTL) frontend for batch processing.
14
+ - **`/storage` (The Data):** Centralized storage for uploads, processed images, and final results.
15
+ - **`/config` (The Settings):** Stores `settings.json` for global configuration.
16
+ - **`/tools` (The Utilities):** Dev scripts, troubleshooting guides, and verification tools.
17
+ - **`/assets` (The Identity):** Centralized storage for branding assets (logo), typography (Arabic fonts), and color grading LUTs.
18
+ - **`/gui` (Legacy):** A Tkinter desktop wrapper for offline/workstation usage.
19
+
20
+ ---
21
+
22
+ ## ๐Ÿš€ The 5-Step AI Pipeline
23
+
24
+ Every photo processed by the studio follows a strictly sequenced pipeline:
25
+
26
+ ### 1. Auto-Crop & Face Detection (`crop.py`)
27
+ - **Technology:** OpenCV Haar Cascades.
28
+ - **Logic:** Detects the largest face, centers it, and calculates a 5:7 (4x6cm) aspect ratio crop.
29
+ - **Fallback:** Centers the crop if no face is detected to ensure the pipeline never breaks.
30
+
31
+ ### 2. AI Background Removal (`process_images.py`)
32
+ - **Model:** **BiRefNet (RMBG-2.0)**.
33
+ - **Optimization:** Automatically detects and utilizes CUDA/GPU. In CPU environments (like HF Spaces), it uses dynamic quantization for speed.
34
+ - **Resilience:** Includes critical monkeypatches for `transformers 4.50+` to handle tied weights and meta-tensor materialization bugs.
35
+
36
+ ### 3. Color Grading Style Transfer (`color_steal.py`)
37
+ - **Mechanism:** Analyzes "Before" and "After" pairs to learn R, G, and B curves.
38
+ - **Smoothing:** Uses **Savitzky-Golay filters** to prevent color banding.
39
+ - **Application:** Applies learned styles via vectorized NumPy operations for near-instant processing.
40
+
41
+ ### 4. Surgical Retouching (`retouch.py`)
42
+ - **Landmarking:** Uses **MediaPipe Face Mesh** (468 points) to generate a precise skin mask, excluding eyes, lips, and hair.
43
+ - **Frequency Separation:** Splits the image into **High Frequency** (texture/pores) and **Low Frequency** (tone/color).
44
+ - **Blemish Removal:** Detects anomalies on the High-Freq layer and inpaints them using surrounding texture.
45
+ - **Result:** Pores and skin texture are 100% preserved; only defects are removed.
46
+
47
+ ### 5. Layout Composition (`layout_engine.py`)
48
+ - **Rendering:** Composes a 300 DPI canvas for printing.
49
+ - **Localization:** Uses `arabic_reshaper` and `python-bidi` for correct Arabic script rendering.
50
+ - **Dynamic Assets:** Overlays IDs with specific offsets and studio branding (logos).
51
+
52
+ ---
53
+
54
+ ## โš™๏ธ Configuration & Real-Time Tuning
55
+
56
+ The system is controlled by `core/settings.json`.
57
+ - **Hot Reloading:** The layout engine reloads this file on **every request**. You can adjust `id_font_size`, `grid_gap`, or `retouch_sensitivity` and see the changes in the next processed photo without restarting the server.
58
+
59
+ ---
60
+
61
+ ## ๐Ÿณ Deployment & Cloud Readiness
62
+
63
+ The project is optimized for high-availability environments.
64
+
65
+ ### Docker Environment
66
+ - **Base:** `python:3.10-slim`.
67
+ - **System Deps:** Requires `libgl1` (OpenCV), `libraqm0` (Font rendering), and `libharfbuzz0b` (Arabic shaping).
68
+
69
+ ### Hugging Face Spaces
70
+ - **Transformers Fix:** Patches `PretrainedConfig` to allow custom model loading without attribute errors.
71
+ - **LFS Support:** Binary files (`.ttf`, `.cube`, `.png`) are managed via Git LFS to ensure integrity.
72
+
73
+ ---
74
+
75
+ ## ๐Ÿ›  Troubleshooting (Common Pitfalls)
76
+
77
+ | Issue | Root Cause | Solution |
78
+ |-------|------------|----------|
79
+ | **"Tofu" Boxes in Text** | Missing or corrupted fonts. | Ensure `assets/arialbd.ttf` is not a Git LFS pointer (size > 300KB). |
80
+ | **Meta-Tensor Error** | Transformers 4.50+ CPU bug. | Handled by `torch.linspace` monkeypatch in `process_images.py`. |
81
+ | **Slow Processing** | CPU bottleneck. | Ensure `torch` is using multiple threads or enable CUDA. |
82
+
83
+ ---
84
+
85
+ *Last Updated: February 2026 โ€” EL HELAL Studio Engineering*
Dockerfile ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Use an official Python runtime as a parent image
2
+ # We use the full image to ensure all AI/OpenCV dependencies are compatible
3
+ FROM python:3.10
4
+
5
+ # Set environment variables
6
+ ENV PYTHONDONTWRITEBYTECODE 1
7
+ ENV PYTHONUNBUFFERED 1
8
+
9
+ # Install system dependencies for OpenCV, AI models, and Font Rendering
10
+ RUN apt-get update && apt-get install -y \
11
+ wget \
12
+ fontconfig \
13
+ libfontconfig1 \
14
+ libgl1 \
15
+ libglib2.0-0 \
16
+ libsm6 \
17
+ libxext6 \
18
+ libxrender1 \
19
+ libasound2 \
20
+ fonts-dejavu-core \
21
+ fonts-liberation \
22
+ fonts-noto-core \
23
+ fonts-noto-extra \
24
+ fonts-noto-color-emoji \
25
+ libraqm0 \
26
+ libfreetype6 \
27
+ libfribidi0 \
28
+ libharfbuzz0b \
29
+ libprotobuf-dev \
30
+ protobuf-compiler \
31
+ && rm -rf /var/lib/apt/lists/*
32
+
33
+ # Set the working directory in the container
34
+ WORKDIR /app
35
+
36
+ # Download high-quality fonts to bypass Git LFS issues
37
+ RUN mkdir -p assets && \
38
+ wget -O assets/tahomabd.ttf "https://raw.githubusercontent.com/Esmaill1/color-stealer/main/tahomabd.ttf" && \
39
+ wget -O assets/TYBAH.TTF "https://raw.githubusercontent.com/Esmaill1/color-stealer/main/TYBAH.TTF" && \
40
+ wget -O assets/arialbd.ttf "https://raw.githubusercontent.com/Esmaill1/color-stealer/main/arialbd.ttf"
41
+
42
+ # Copy the requirements file into the container
43
+ COPY requirements.txt .
44
+
45
+ # Install any needed packages specified in requirements.txt
46
+ # We use --no-cache-dir to keep the image small
47
+ RUN pip install --no-cache-dir -r requirements.txt
48
+
49
+ # Copy the rest of the application code
50
+ COPY . .
51
+
52
+ # Expose port 7860 for Hugging Face Spaces
53
+ EXPOSE 7860
54
+
55
+ # Run the server
56
+ CMD ["python", "web/server.py"]
assets/My_Style.cube ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:66f671e50af9d77272208ed587a7bb50e26a9007573487852064e09b04a6779f
3
+ size 7077935
assets/TYBAH.TTF ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:8d7d030e8d87bf1811cab4d350a632bd330a99b2c24746d89e823a2ab20c6474
3
+ size 83612
assets/arialbd.ttf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:df70597f0bdf49da3af270138f8a34396e4f5618c671a1db3480e626f38aaece
3
+ size 352224
assets/logo.png ADDED

Git LFS Details

  • SHA256: 329e34ca5da5fe1cc77b87538b9cefe2e7e8e4cc05579402f39168628e29e149
  • Pointer size: 131 Bytes
  • Size of remote file: 827 kB
bucket.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import boto3
3
+ import urllib3
4
+
5
+ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
6
+
7
+ # Hardcoded
8
+ ENDPOINT_URL = 'https://ba1add5d3df1b47fa4893182b8f5761e.eu.r2.cloudflarestorage.comhttps://ba1add5d3df1b47fa4893182b8f5761e.r2.cloudflarestorage.com'
9
+ BUCKET_NAME = 'fonts'
10
+ ACCESS_KEY_ID = '915b4668f3b146f10a869c753f0d6a12'
11
+ SECRET_ACCESS_KEY = 'bcb6246a36b9732a9e1accf3fb7e3004d2cb130f46dd4c393408a3ef4433b869'
12
+ LOCAL_DOWNLOAD_DIR = 'downloaded_fonts'
13
+
14
+ print(f"Connecting to: {ENDPOINT_URL}")
15
+ print(f"Bucket: {BUCKET_NAME}")
16
+
17
+ s3 = boto3.client(
18
+ 's3',
19
+ endpoint_url=ENDPOINT_URL,
20
+ aws_access_key_id=ACCESS_KEY_ID,
21
+ aws_secret_access_key=SECRET_ACCESS_KEY,
22
+ verify=False
23
+ )
24
+
25
+ if not os.path.exists(LOCAL_DOWNLOAD_DIR):
26
+ os.makedirs(LOCAL_DOWNLOAD_DIR)
27
+ print(f"Created: {LOCAL_DOWNLOAD_DIR}")
28
+
29
+ print("Downloading files...")
30
+ paginator = s3.get_paginator('list_objects_v2')
31
+ file_count = 0
32
+
33
+ try:
34
+ for page in paginator.paginate(Bucket=BUCKET_NAME):
35
+ if 'Contents' in page:
36
+ for obj in page['Contents']:
37
+ key = obj['Key']
38
+ local_path = os.path.join(LOCAL_DOWNLOAD_DIR, key)
39
+
40
+ local_dir = os.path.dirname(local_path)
41
+ if local_dir and not os.path.exists(local_dir):
42
+ os.makedirs(local_dir)
43
+
44
+ print(f" Downloading: {key}")
45
+ s3.download_file(BUCKET_NAME, key, local_path)
46
+ file_count += 1
47
+
48
+ print(f"\nโœ“ Done! Downloaded {file_count} files to {LOCAL_DOWNLOAD_DIR}")
49
+ except Exception as e:
50
+ print(f"โœ— Error: {e}")
config/settings.json ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "layout": {
3
+ "dpi": 300,
4
+ "output_w_cm": 25.7,
5
+ "output_h_cm": 12.7,
6
+ "grid_rows": 2,
7
+ "grid_cols": 4,
8
+ "grid_gap": 10,
9
+ "grid_margin": 15,
10
+ "photo_bottom_pad_cm": 0.7,
11
+ "brand_border": 50,
12
+ "section_gap": 5
13
+ },
14
+ "overlays": {
15
+ "logo_size_small": 70,
16
+ "logo_size_large": 95,
17
+ "logo_margin": 8,
18
+ "id_font_size": 63,
19
+ "name_font_size": 42,
20
+ "date_font_size": 19,
21
+ "large_date_font_size": 24,
22
+ "id_lift_offset": 45,
23
+ "id_char_spacing": -8
24
+ },
25
+ "retouch": {
26
+ "enabled": true,
27
+ "sensitivity": 3,
28
+ "tone_smoothing": 0.6
29
+ },
30
+ "colors": {
31
+ "maroon": [
32
+ 139,
33
+ 69,
34
+ 19
35
+ ],
36
+ "dark_red": [
37
+ 180,
38
+ 0,
39
+ 0
40
+ ],
41
+ "gold": [
42
+ 200,
43
+ 150,
44
+ 12
45
+ ],
46
+ "white": [
47
+ 255,
48
+ 255,
49
+ 255
50
+ ],
51
+ "text_dark": [
52
+ 60,
53
+ 60,
54
+ 60
55
+ ]
56
+ }
57
+ }
context.md ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ๐Ÿง  Project Context: ID Maker Studio (EL HELAL Pipeline)
2
+
3
+ This document provides a high-level overview of the project's intent, architecture, and critical technical state for developers and AI agents.
4
+
5
+ ---
6
+
7
+ ## ๐ŸŽฏ Project Intent
8
+ **ID Maker Studio** is a professional-grade image processing pipeline designed for **EL HELAL Studio**. Its primary goal is to automate the conversion of raw student portraits into high-resolution (300 DPI), print-ready ID sheets.
9
+
10
+ The system handles everything from blemish removal and color grading to complex Arabic typography and branding.
11
+
12
+ ---
13
+
14
+ ## ๐Ÿ— High-Level Architecture
15
+
16
+ The project is strictly modular, separating core business logic from user interfaces.
17
+
18
+ ### 1. The Brain (`/core`)
19
+ - **Pipeline Orchestration:** Sequentially runs 5 critical steps.
20
+ - **AI Logic:**
21
+ - **Crop:** OpenCV-based face detection (5:7 ratio).
22
+ - **Background Removal:** BiRefNet (RMBG-2.0) running on CPU (quantized).
23
+ - **Retouching:** MediaPipe Face Mesh + Frequency Separation (preserves 100% skin texture).
24
+ - **Color Steal:** Custom algorithm to learn and apply professional color curves.
25
+ - **Layout Engine:** Composite engine using Pillow. Handles complex Arabic script via manual shaping and reordering.
26
+
27
+ ### 2. The Interfaces
28
+ - **Web Interface (`/web`):** The primary production tool. Built with FastAPI and a localized Arabic (RTL) frontend.
29
+ - **3-Column Layout:** Queue (Right) โ€” Preview (Center) โ€” Options & Settings (Left).
30
+ - **Dark/Light Theme:** Toggleable via header button, persisted in localStorage.
31
+ - **Batch Processing Counter:** Shows `1/5`, `2/5`... overlay with dim background during batch processing.
32
+ - **Per-Image Delete:** Hover delete button on each queue item.
33
+ - **Settings API:** Real-time slider-based tuning of retouch sensitivity, skin smoothing, font sizes โ€” saved to `config/settings.json`.
34
+ - **Keyboard Shortcuts:** Arrow navigation, Delete, Enter (save & next), Ctrl+S (process), Escape.
35
+ - **Before/After Toggle:** Quick comparison between original and processed result.
36
+ - **Zoom Modal:** Scroll-wheel zoom for detailed inspection.
37
+ - **Mobile Drawer:** Responsive drawer for queue on mobile devices.
38
+ - **Desktop GUI (`/gui`):** Legacy Tkinter application for offline machine usage.
39
+
40
+ ### 3. Data & Config
41
+ - **`/storage`:** Managed directory for uploads, processing, and results.
42
+ - **`/config`:** Centralized `settings.json` for real-time layout tuning (DPI, margins, font sizes).
43
+ - **`/assets`:** Branding marks and font files.
44
+
45
+ ---
46
+
47
+ ## ๐Ÿš€ Deployment & Environment
48
+
49
+ - **Infrastructure:** Optimized for **Docker** and **Hugging Face Spaces**.
50
+ - **Hardware Context:** Targeted for **2 CPUs / 16GB RAM**.
51
+ - RAM allows for large image buffers.
52
+ - CPU is the bottleneck; inference is sequential and quantized.
53
+ - **Binary Management:** Uses a hybrid approach. Smaller assets in Git; critical fonts are **automatically downloaded from GitHub** during Docker build to bypass Git LFS issues.
54
+
55
+ ---
56
+
57
+ ## โš ๏ธ Critical Technical "Gotchas"
58
+
59
+ - **Arabic Rendering:** In the container environment (standard LTR), Arabic is rendered by:
60
+ 1. Reshaping characters (connecting letters).
61
+ 2. **Manually reversing** the string characters to ensure correct visual flow without requiring `libraqm`.
62
+ - **MediaPipe Stability:** Explicitly imports submodules and includes safety checks to skip retouching if binary dependencies fail to initialize.
63
+ - **Transformers Monkeypatch:** Contains deep patches in `process_images.py` to handle `transformers 4.50+` tied-weight and meta-tensor bugs on CPU.
64
+ - **Dynamic Config:** `layout_engine.py` reloads `settings.json` on every request to allow "live" tuning of the output.
65
+
66
+ ---
67
+
68
+ ## ๐Ÿ“‚ Quick File Map
69
+ - `web/server.py`: FastAPI entry point. Includes Settings API (`GET/POST /settings`).
70
+ - `web/web_storage/index.html`: 3-column RTL frontend (Queue | Preview | Options).
71
+ - `core/layout_engine.py`: Final sheet composition logic.
72
+ - `core/retouch.py`: Advanced skin processing.
73
+ - `config/settings.json`: Live-reloadable settings (retouch sensitivity, font sizes, etc.).
74
+ - `Dockerfile`: Production environment definition.
75
+ - `tools/problems.md`: Historical log of technical hurdles and fixes.
76
+
77
+ ---
78
+
79
+ *Last Updated: February 2026 โ€” Web UI v2 (3-Column Layout, Batch Counter, Theme Toggle)*
core/My_Style.cube ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:66f671e50af9d77272208ed587a7bb50e26a9007573487852064e09b04a6779f
3
+ size 7077935
core/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # Core logic package
core/color_steal.py ADDED
@@ -0,0 +1,377 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+ import os
3
+ from PIL import Image
4
+ from scipy.interpolate import interp1d
5
+ from scipy.signal import savgol_filter
6
+
7
+ # This file stores the learned color curves so we don't need to re-train every time
8
+ _DIR = os.path.dirname(os.path.abspath(__file__))
9
+ MODEL_CACHE = os.path.join(_DIR, "trained_curves.npz")
10
+
11
+
12
+ def get_pairs_from_folders(before_folder, after_folder):
13
+ """
14
+ Scans 'before' and 'after' folders to find matching image pairs based on filenames.
15
+ Assumes files are named like '12_d.jpg' and '12_l.jpg' where '12' is the common prefix.
16
+ """
17
+ before_files = {}
18
+ after_files = {}
19
+ valid_ext = (".jpg", ".jpeg", ".png", ".tif", ".tiff")
20
+
21
+ # 1. Index 'before' files by their numeric prefix
22
+ for f in os.listdir(before_folder):
23
+ if f.lower().endswith(valid_ext):
24
+ prefix = f.split("_")[0] # e.g. "12" from "12_d.jpg"
25
+ before_files[prefix] = os.path.join(before_folder, f)
26
+
27
+ # 2. Index 'after' files by their numeric prefix
28
+ for f in os.listdir(after_folder):
29
+ if f.lower().endswith(valid_ext):
30
+ prefix = f.split("_")[0]
31
+ after_files[prefix] = os.path.join(after_folder, f)
32
+
33
+ # 3. Match them up
34
+ pairs = []
35
+ matched = sorted(set(before_files.keys()) & set(after_files.keys()))
36
+ for prefix in matched:
37
+ pairs.append((before_files[prefix], after_files[prefix]))
38
+
39
+ # log unmatched files for debugging
40
+ unmatched_before = set(before_files.keys()) - set(after_files.keys())
41
+ unmatched_after = set(after_files.keys()) - set(before_files.keys())
42
+ if unmatched_before:
43
+ print(
44
+ f" Warning: No match in after/ for: {[before_files[p] for p in unmatched_before]}"
45
+ )
46
+ if unmatched_after:
47
+ print(
48
+ f" Warning: No match in before/ for: {[after_files[p] for p in unmatched_after]}"
49
+ )
50
+
51
+ print(f" Found {len(pairs)} matched pairs")
52
+ return pairs
53
+
54
+
55
+ def extract_curves_from_pairs(pairs):
56
+ """
57
+ The CORE function. It analyzes pixel differences between raw (before) and edited (after) photos
58
+ to "learn" the color grading style.
59
+
60
+ Returns:
61
+ luts: A list of 3 arrays [Red_Curve, Green_Curve, Blue_Curve] representing the color mapping.
62
+ """
63
+ # We'll store every single pixel value from all images here
64
+ channel_src = [[], [], []] # R, G, B source pixels (Before)
65
+ channel_tgt = [[], [], []] # R, G, B target pixels (After)
66
+
67
+ for i, (raw_path, edited_path) in enumerate(pairs, 1):
68
+ print(f" Loading pair {i}/{len(pairs)}: {raw_path} -> {edited_path}")
69
+
70
+ raw_ref = np.array(Image.open(raw_path).convert("RGB"))
71
+ edited_ref = np.array(Image.open(edited_path).convert("RGB"))
72
+
73
+ # Safety check: if edited image was cropped/resized, scale it to match the raw source
74
+ if raw_ref.shape != edited_ref.shape:
75
+ edited_ref = np.array(
76
+ Image.open(edited_path)
77
+ .convert("RGB")
78
+ .resize((raw_ref.shape[1], raw_ref.shape[0]), Image.Resampling.LANCZOS)
79
+ )
80
+
81
+ # Flatten image into a long list of pixels and add to our big collection
82
+ for ch in range(3):
83
+ channel_src[ch].append(raw_ref[:, :, ch].flatten())
84
+ channel_tgt[ch].append(edited_ref[:, :, ch].flatten())
85
+
86
+ # Combine data from all images into one massive array per channel
87
+ for ch in range(3):
88
+ channel_src[ch] = np.concatenate(channel_src[ch])
89
+ channel_tgt[ch] = np.concatenate(channel_tgt[ch])
90
+
91
+ total_pixels = len(channel_src[0])
92
+ print(f" Total training pixels: {total_pixels:,} (from {len(pairs)} pairs)")
93
+
94
+ # Build the LUT (Look Up Table) for each channel (R, G, B)
95
+ luts = []
96
+ channel_names = ["Red", "Green", "Blue"]
97
+ x_bins = np.arange(256) # Input pixel values 0-255
98
+
99
+ for ch in range(3):
100
+ src_flat = channel_src[ch]
101
+ tgt_flat = channel_tgt[ch]
102
+
103
+ # Calculate average target value for every possible input value (0-255)
104
+ # e.g., "When input Red is 100, what is the average output Red?"
105
+ y_means = []
106
+ y_counts = []
107
+ for val in x_bins:
108
+ mask = src_flat == val
109
+ count = np.sum(mask)
110
+ if count > 0:
111
+ y_means.append(np.mean(tgt_flat[mask]))
112
+ y_counts.append(count)
113
+ else:
114
+ y_means.append(np.nan) # No data for this specific pixel value
115
+ y_counts.append(0)
116
+
117
+ y_means = np.array(y_means)
118
+ y_counts = np.array(y_counts)
119
+
120
+ # Report how much of the color range we actually saw
121
+ coverage = np.sum(y_counts > 0)
122
+ print(f" {channel_names[ch]}: {coverage}/256 values covered")
123
+
124
+ valid_mask = ~np.isnan(y_means)
125
+ if not valid_mask.any():
126
+ print(f" ERROR: {channel_names[ch]} channel has no data!")
127
+ return None
128
+
129
+ # --- Weighting Logic ---
130
+ # We trust values that appeared frequently in the images more than rare pixel values.
131
+ max_count = np.max(y_counts[y_counts > 0])
132
+
133
+ # Weighted interpolation: if we have low confidence in a value (low count),
134
+ # allow it to be smoothed by its neighbors.
135
+ y_means_weighted = y_means.copy()
136
+ for i in np.where(valid_mask)[0]:
137
+ if y_counts[i] < 100: # If we saw this pixel value fewer than 100 times...
138
+ # Look at neighbors to sanity check
139
+ neighbors = y_means[max(0, i - 2) : min(256, i + 3)]
140
+ valid_neighbors = neighbors[~np.isnan(neighbors)]
141
+ if len(valid_neighbors) > 0:
142
+ y_means_weighted[i] = np.mean(valid_neighbors)
143
+
144
+ # Fill in missing values using linear interpolation
145
+ interpolator = interp1d(
146
+ x_bins[valid_mask],
147
+ y_means_weighted[valid_mask],
148
+ kind="linear",
149
+ fill_value="extrapolate",
150
+ bounds_error=False,
151
+ )
152
+
153
+ full_lut = interpolator(np.arange(256))
154
+
155
+ # --- Smoothing ---
156
+ # Apply Savitzky-Golay filter to make the curve smooth and "organic"
157
+ # This prevents color banding in the final image.
158
+ try:
159
+ full_lut = savgol_filter(full_lut, window_length=11, polyorder=2)
160
+ except ValueError:
161
+ # Fallback for small datasets
162
+ full_lut = savgol_filter(full_lut, window_length=5, polyorder=1)
163
+
164
+ # Ensure values stay valid (0-255) and integer type
165
+ full_lut = full_lut.clip(0, 255).astype(np.uint8)
166
+ luts.append(full_lut)
167
+
168
+ return luts
169
+
170
+
171
+ def save_trained_curves(luts, filename=None):
172
+ """Saves trained LUT curves to disk (as a .npz file)."""
173
+ filename = filename or MODEL_CACHE
174
+ np.savez(filename, r=luts[0], g=luts[1], b=luts[2])
175
+ print(f" Curves saved to {filename}")
176
+
177
+
178
+ def load_trained_curves(filename=None):
179
+ """
180
+ Checks for a saved model file.
181
+ Returns the curves (array) if found, logic None otherwise.
182
+ """
183
+ filename = filename or MODEL_CACHE
184
+ if not os.path.exists(filename):
185
+ return None
186
+
187
+ data = np.load(filename)
188
+ print(f" Loaded cached curves from {filename} (skipping training)")
189
+ return [data["r"], data["g"], data["b"]]
190
+
191
+
192
+ def save_cube_file(luts, filename, lut_size=64):
193
+ """
194
+ Exports the style as a standard .cube file.
195
+ Use this file in Photoshop, Premiere, DaVinci Resolve, OBS, etc.
196
+ """
197
+ print(f" Saving {lut_size}x{lut_size}x{lut_size} .cube file: {filename}")
198
+
199
+ with open(filename, "w") as f:
200
+ f.write(f'TITLE "Multi_Pair_Style_Match"\n')
201
+ f.write(f"LUT_3D_SIZE {lut_size}\n\n")
202
+
203
+ # Generate the 3D grid and apply our learned curves to it
204
+ domain = np.linspace(0, 255, lut_size).astype(int)
205
+ for b_val in domain:
206
+ for g_val in domain:
207
+ for r_val in domain:
208
+ # Apply R, G, B curves independently
209
+ new_r = luts[0][r_val] / 255.0
210
+ new_g = luts[1][g_val] / 255.0
211
+ new_b = luts[2][b_val] / 255.0
212
+ f.write(f"{new_r:.6f} {new_g:.6f} {new_b:.6f}\n")
213
+
214
+ print(f" .cube file saved!")
215
+
216
+
217
+ def apply_to_folder(luts, target_folder, output_folder):
218
+ """
219
+ Takes the learned curves and applies them to every image in 'target_folder'.
220
+ Saves result to 'output_folder'.
221
+ """
222
+ if not os.path.exists(output_folder):
223
+ os.makedirs(output_folder)
224
+
225
+ files = [
226
+ f
227
+ for f in os.listdir(target_folder)
228
+ if f.lower().endswith((".jpg", ".jpeg", ".png", ".tif", ".tiff"))
229
+ ]
230
+
231
+ if not files:
232
+ print(f" No images found in {target_folder}")
233
+ return
234
+
235
+ print(f" Processing {len(files)} images...")
236
+ for filename in files:
237
+ img_path = os.path.join(target_folder, filename)
238
+ img = Image.open(img_path)
239
+ original_format = img.format # Preserve original format info
240
+
241
+ # Handle Transparency (Alpha Channel):
242
+ # We must separate the alpha channel, grade the RGB channels, then put alpha back.
243
+ has_alpha = img.mode in ("RGBA", "LA", "PA")
244
+ alpha_channel = None
245
+
246
+ if has_alpha:
247
+ img_rgba = img.convert("RGBA")
248
+ alpha_channel = np.array(img_rgba)[:, :, 3] # Save the transparency layer
249
+ img_rgb = img_rgba.convert("RGB")
250
+ print(f" (Transparent PNG detected โ€” alpha will be preserved)")
251
+ else:
252
+ img_rgb = img.convert("RGB")
253
+
254
+ img_array = np.array(img_rgb)
255
+
256
+ # Apply LUT per channel (Vectorized operation = very fast)
257
+ result_array = img_array.copy()
258
+ for ch in range(3):
259
+ # Map every pixel value using our lookup table
260
+ result_array[:, :, ch] = luts[ch][img_array[:, :, ch]]
261
+
262
+ # RESTORE Alpha Channel
263
+ if has_alpha and alpha_channel is not None:
264
+ result_rgba = np.dstack((result_array, alpha_channel))
265
+ result_img = Image.fromarray(result_rgba, "RGBA")
266
+ else:
267
+ result_img = Image.fromarray(result_array)
268
+
269
+ # Preserve EXIF metadata (camera settings, date, etc.)
270
+ exif_data = img.info.get("exif", None)
271
+
272
+ # Save options based on file type
273
+ name, ext = os.path.splitext(filename)
274
+ save_path = os.path.join(output_folder, f"Graded_{filename}")
275
+
276
+ save_kwargs = {}
277
+ if exif_data:
278
+ save_kwargs["exif"] = exif_data
279
+
280
+ if ext.lower() in (".jpg", ".jpeg"):
281
+ if has_alpha: # JPG can't store alpha, must convert to RGB
282
+ result_img = result_img.convert("RGB")
283
+ save_kwargs["quality"] = 100
284
+ save_kwargs["subsampling"] = 0 # Highest color quality (4:4:4)
285
+ elif ext.lower() == ".png":
286
+ save_kwargs["compress_level"] = 1 # Low compression = faster
287
+ elif ext.lower() in (".tif", ".tiff"):
288
+ save_kwargs["compression"] = "tiff_lzw"
289
+
290
+ result_img.save(save_path, **save_kwargs)
291
+ print(f" Done: {filename} ({img_array.shape[1]}x{img_array.shape[0]})")
292
+ print(f" All images saved at original resolution & maximum quality.")
293
+
294
+
295
+ def apply_to_image(luts, img: Image.Image) -> Image.Image:
296
+ """
297
+ Applies the learned curves to a single PIL Image and returns the result.
298
+ Handles transparency (alpha channel) correctly.
299
+ """
300
+ # Handle Transparency (Alpha Channel)
301
+ has_alpha = img.mode in ("RGBA", "LA", "PA")
302
+ alpha_channel = None
303
+
304
+ if has_alpha:
305
+ img_rgba = img.convert("RGBA")
306
+ alpha_channel = np.array(img_rgba)[:, :, 3] # Save the transparency layer
307
+ img_rgb = img_rgba.convert("RGB")
308
+ else:
309
+ img_rgb = img.convert("RGB")
310
+
311
+ img_array = np.array(img_rgb)
312
+
313
+ # Apply LUT per channel
314
+ result_array = img_array.copy()
315
+ for ch in range(3):
316
+ result_array[:, :, ch] = luts[ch][img_array[:, :, ch]]
317
+
318
+ # Restore Alpha Channel
319
+ if has_alpha and alpha_channel is not None:
320
+ result_rgba = np.dstack((result_array, alpha_channel))
321
+ return Image.fromarray(result_rgba, "RGBA")
322
+ else:
323
+ return Image.fromarray(result_array)
324
+
325
+
326
+ if __name__ == "__main__":
327
+ # ==========================================
328
+ # EXECUTION SECTION
329
+ # ==========================================
330
+
331
+ # Folder setup
332
+ BEFORE_FOLDER = "./before"
333
+ AFTER_FOLDER = "./after"
334
+
335
+ # 1. Attempt to load existing model first to save time
336
+ luts = load_trained_curves()
337
+
338
+ # 2. If NO model helps, we attempt to learn from images
339
+ if luts is None:
340
+ # Check if folders actually exist before crashing
341
+ if os.path.exists(BEFORE_FOLDER) and os.path.exists(AFTER_FOLDER):
342
+ pairs = get_pairs_from_folders(BEFORE_FOLDER, AFTER_FOLDER)
343
+
344
+ if not pairs:
345
+ # Error only if we have NO model AND NO training data
346
+ print(
347
+ "ERROR: No matching pairs found. Make sure before/ and after/ folders"
348
+ )
349
+ print(
350
+ " have images with matching prefixes (e.g. 12_d.jpg <-> 12_l.jpg)"
351
+ )
352
+ else:
353
+ print("=" * 50)
354
+ print("PHASE 1: Learning Color Grade")
355
+ print("=" * 50)
356
+ luts = extract_curves_from_pairs(pairs)
357
+ if luts:
358
+ save_trained_curves(luts) # Cache for next time
359
+ else:
360
+ print("Model not found and training folders are missing. Cannot proceed.")
361
+
362
+ # 3. If we successfully loaded or learned the style, apply it
363
+ if luts:
364
+ # Export .cube file (LUT) for external use
365
+ if not os.path.exists("My_Style.cube"):
366
+ print("\n" + "=" * 50)
367
+ print("PHASE 2: Exporting .cube LUT")
368
+ print("=" * 50)
369
+ save_cube_file(luts, "My_Style.cube", lut_size=64)
370
+ else:
371
+ print("\n My_Style.cube already exists, skipping export.")
372
+
373
+ # Batch-apply to all photos in the 'crop' folder
374
+ print("\n" + "=" * 50)
375
+ print("PHASE 3: Batch Processing")
376
+ print("=" * 50)
377
+ apply_to_folder(luts, "./trans", "./colored")
core/crop.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import cv2
2
+ import os
3
+ from PIL import Image
4
+ import sys
5
+ import cv2.data
6
+ import numpy as np
7
+
8
+ # Locate the standard frontal face XML classifier provided by OpenCV
9
+ cascade_path = os.path.join(
10
+ cv2.data.haarcascades, "haarcascade_frontalface_default.xml"
11
+ )
12
+ face_cascade = cv2.CascadeClassifier(cascade_path)
13
+
14
+
15
+ def _load_image_exif_safe(image_path):
16
+ """Loads an image using PIL, handles EXIF orientation, and converts to OpenCV BGR."""
17
+ try:
18
+ from PIL import ImageOps
19
+ pil_img = Image.open(image_path)
20
+ pil_img = ImageOps.exif_transpose(pil_img)
21
+ # Convert to BGR for OpenCV
22
+ return cv2.cvtColor(np.array(pil_img.convert("RGB")), cv2.COLOR_RGB2BGR)
23
+ except Exception as e:
24
+ print(f"Error loading image safe: {e}")
25
+ return None
26
+
27
+
28
+ def get_auto_crop_rect(image_path):
29
+ """
30
+ Detects a face and calculates the 5:7 crop rectangle.
31
+ Returns (x1, y1, x2, y2) in original image coordinates or None.
32
+ """
33
+ image = _load_image_exif_safe(image_path)
34
+ if image is None:
35
+ return None
36
+ h, w, _ = image.shape
37
+ gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
38
+ faces = face_cascade.detectMultiScale(gray, scaleFactor=1.1, minNeighbors=5)
39
+
40
+ if len(faces) == 0:
41
+ # Fallback: Center crop if no face found
42
+ aspect_ratio = 5 / 7
43
+ crop_h = int(h * 0.8)
44
+ crop_w = int(crop_h * aspect_ratio)
45
+ x1 = (w - crop_w) // 2
46
+ y1 = (h - crop_h) // 2
47
+ return (x1, y1, x1 + crop_w, y1 + crop_h)
48
+
49
+ faces = sorted(faces, key=lambda x: x[2] * x[3], reverse=True)
50
+ (x, y, fw, fh) = faces[0]
51
+ cx, cy = x + fw // 2, y + fh // 2
52
+ aspect_ratio = 5 / 7
53
+
54
+ crop_height = int(min(h, w / aspect_ratio) * 0.7)
55
+ crop_width = int(crop_height * aspect_ratio)
56
+
57
+ head_top = y - int(fh * 0.35)
58
+ HEAD_SPACE_RATIO = 0.10
59
+ y1 = max(0, head_top - int(crop_height * HEAD_SPACE_RATIO))
60
+ x1 = max(0, cx - crop_width // 2)
61
+
62
+ x2 = min(w, x1 + crop_width)
63
+ y2 = min(h, y1 + crop_height)
64
+
65
+ # Adjust to maintain size
66
+ if x2 - x1 < crop_width:
67
+ x1 = max(0, x2 - crop_width)
68
+ if y2 - y1 < crop_height:
69
+ y1 = max(0, y2 - crop_height)
70
+
71
+ return (int(x1), int(y1), int(x1 + crop_width), int(y1 + crop_height))
72
+
73
+
74
+ def apply_custom_crop(image_path, output_path, rect):
75
+ """
76
+ Applies a specific (x1, y1, x2, y2) crop and resizes to 10x14cm @ 300DPI.
77
+ """
78
+ x1, y1, x2, y2 = rect
79
+ try:
80
+ image = _load_image_exif_safe(image_path)
81
+ if image is None: return False
82
+
83
+ cropped = image[y1:y2, x1:x2]
84
+ final = cv2.resize(cropped, (1181, 1654))
85
+ final_rgb = cv2.cvtColor(final, cv2.COLOR_BGR2RGB)
86
+ pil_img = Image.fromarray(final_rgb)
87
+ pil_img.save(output_path, dpi=(300, 300), quality=95)
88
+ return True
89
+ except Exception as e:
90
+ print(f"Error applying custom crop: {e}")
91
+ return False
92
+
93
+
94
+ def crop_to_4x6_opencv(image_path, output_path):
95
+ """Standard AI auto-crop."""
96
+ rect = get_auto_crop_rect(image_path)
97
+ if rect:
98
+ return apply_custom_crop(image_path, output_path, rect)
99
+ return False
100
+
101
+
102
+ def batch_process(input_folder, output_folder):
103
+ if not os.path.exists(output_folder):
104
+ os.makedirs(output_folder)
105
+ files = [
106
+ f
107
+ for f in os.listdir(input_folder)
108
+ if f.lower().endswith((".jpg", ".jpeg", ".png"))
109
+ ]
110
+ for filename in files:
111
+ crop_to_4x6_opencv(
112
+ os.path.join(input_folder, filename), os.path.join(output_folder, filename)
113
+ )
core/layout_engine.py ADDED
@@ -0,0 +1,318 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ EL HELAL Studio โ€“ Photo Layout Engine
3
+ Dynamically loads settings from settings.json
4
+ """
5
+
6
+ from PIL import Image, ImageDraw, ImageFont, features
7
+ import arabic_reshaper
8
+ from bidi.algorithm import get_display
9
+ import os
10
+ import json
11
+ from datetime import date
12
+
13
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
14
+ # Paths & Config Loading
15
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
16
+ # Use the directory of this script as a base
17
+ _CORE_DIR = os.path.dirname(os.path.abspath(__file__))
18
+ # The project root is one level up from 'core'
19
+ _ROOT_DIR = os.path.abspath(os.path.join(_CORE_DIR, ".."))
20
+
21
+ # Search for assets in both 'assets' folder and locally
22
+ def find_asset(filename):
23
+ # 1. Try assets/ folder in root
24
+ p1 = os.path.join(_ROOT_DIR, "assets", filename)
25
+ if os.path.exists(p1): return p1
26
+ # 2. Try locally in core/
27
+ p2 = os.path.join(_CORE_DIR, filename)
28
+ if os.path.exists(p2): return p2
29
+ # 3. Try root directly
30
+ p3 = os.path.join(_ROOT_DIR, filename)
31
+ if os.path.exists(p3): return p3
32
+ return p1 # Fallback to default path
33
+
34
+ LOGO_PATH = find_asset("logo.png")
35
+ ARABIC_FONT_PATH = find_asset("TYBAH.TTF")
36
+ SETTINGS_PATH = os.path.join(_ROOT_DIR, "config", "settings.json")
37
+
38
+ def load_settings():
39
+ defaults = {
40
+ "layout": {
41
+ "dpi": 300, "output_w_cm": 25.7, "output_h_cm": 12.7,
42
+ "grid_rows": 2, "grid_cols": 4, "grid_gap": 10, "grid_margin": 15,
43
+ "photo_bottom_pad_cm": 0.7, "brand_border": 50, "section_gap": 5
44
+ },
45
+ "overlays": {
46
+ "logo_size_small": 77, "logo_size_large": 95, "logo_margin": 8,
47
+ "id_font_size": 50, "name_font_size": 30, "date_font_size": 19,
48
+ "large_date_font_size": 24, "id_lift_offset": 45, "id_char_spacing": -3
49
+ },
50
+ "colors": {
51
+ "maroon": [60, 0, 0], "dark_red": [180, 0, 0], "gold": [200, 150, 12],
52
+ "white": [255, 255, 255], "text_dark": [60, 60, 60]
53
+ },
54
+ "retouch": {
55
+ "enabled": True, "sensitivity": 3.0, "tone_smoothing": 0.6
56
+ }
57
+ }
58
+ if os.path.exists(SETTINGS_PATH):
59
+ try:
60
+ with open(SETTINGS_PATH, "r") as f:
61
+ user_settings = json.load(f)
62
+ # Merge user settings into defaults
63
+ for key, val in user_settings.items():
64
+ if key in defaults and isinstance(val, dict):
65
+ defaults[key].update(val)
66
+ else:
67
+ defaults[key] = val
68
+ except Exception as e:
69
+ print(f"Error loading settings.json: {e}")
70
+ return defaults
71
+
72
+ S = load_settings()
73
+
74
+ # Derived Constants
75
+ DPI = S["layout"]["dpi"]
76
+ OUTPUT_WIDTH = round(S["layout"]["output_w_cm"] / 2.54 * DPI)
77
+ OUTPUT_HEIGHT = round(S["layout"]["output_h_cm"] / 2.54 * DPI)
78
+ PHOTO_BOTTOM_PAD = round(S["layout"]["photo_bottom_pad_cm"] / 2.54 * DPI)
79
+
80
+ def c(key): return tuple(S["colors"][key])
81
+ WHITE = c("white")
82
+ MAROON = c("maroon")
83
+ DARK_RED = c("dark_red")
84
+ TEXT_DARK = c("text_dark")
85
+
86
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
87
+ # Helpers
88
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
89
+
90
+ def _load_logo() -> Image.Image | None:
91
+ if os.path.exists(LOGO_PATH):
92
+ try:
93
+ return Image.open(LOGO_PATH).convert("RGBA")
94
+ except Exception as e:
95
+ print(f"Error loading logo from {LOGO_PATH}: {e}")
96
+ else:
97
+ print(f"Logo not found at: {LOGO_PATH}")
98
+ return None
99
+
100
+ def _load_font_with_fallback(size: int, is_arabic: bool = False) -> ImageFont.FreeTypeFont:
101
+ """Aggressive font loader with deep system search."""
102
+ # 1. Assets (Downloaded via Dockerfile - Guaranteed binary files if links work)
103
+ candidates = [
104
+ os.path.join(_ROOT_DIR, "assets", "arialbd.ttf"),
105
+ os.path.join(_ROOT_DIR, "assets", "tahomabd.ttf"),
106
+ os.path.join(_ROOT_DIR, "assets", "TYBAH.TTF")
107
+ ]
108
+
109
+ # 2. Add System Fonts based on priority
110
+ if os.name == "nt": # Windows
111
+ candidates += ["C:/Windows/Fonts/arialbd.ttf", "C:/Windows/Fonts/tahomabd.ttf"]
112
+ else: # Linux / Docker - SCAN SYSTEM
113
+ # We look for Noto (Arabic) and DejaVu (English/Fallback)
114
+ search_dirs = ["/usr/share/fonts", "/usr/local/share/fonts"]
115
+ found_system_fonts = []
116
+ for d in search_dirs:
117
+ if os.path.exists(d):
118
+ for root, _, files in os.walk(d):
119
+ for f in files:
120
+ if "NotoSansArabic-Bold" in f or "DejaVuSans-Bold" in f or "FreeSansBold" in f:
121
+ found_system_fonts.append(os.path.join(root, f))
122
+
123
+ # Prioritize Noto for Arabic, DejaVu for English
124
+ if is_arabic:
125
+ found_system_fonts.sort(key=lambda x: "Noto" in x, reverse=True)
126
+ else:
127
+ found_system_fonts.sort(key=lambda x: "DejaVu" in x, reverse=True)
128
+
129
+ candidates += found_system_fonts
130
+
131
+ # 3. Final Search and Load
132
+ for path in candidates:
133
+ if path and os.path.exists(path):
134
+ try:
135
+ f_size = os.path.getsize(path)
136
+ if f_size < 2000: continue # Skip pointers and empty files
137
+
138
+ font = ImageFont.truetype(path, size)
139
+ print(f"DEBUG: Using {os.path.basename(path)} for {'ARABIC' if is_arabic else 'ENGLISH'} (Size: {f_size})")
140
+ return font
141
+ except:
142
+ continue
143
+
144
+ print("CRITICAL: All font loads failed. Falling back to default.")
145
+ return ImageFont.load_default()
146
+
147
+ def _find_font(size: int) -> ImageFont.FreeTypeFont:
148
+ return _load_font_with_fallback(size, is_arabic=False)
149
+
150
+ def _arabic_font(size: int) -> ImageFont.FreeTypeFont:
151
+ return _load_font_with_fallback(size, is_arabic=True)
152
+
153
+ def _reshape_arabic(text: str) -> str:
154
+ if not text: return ""
155
+ try:
156
+ # 1. Reshape the text to get connected glyphs
157
+ reshaped_text = arabic_reshaper.reshape(text)
158
+
159
+ # 2. Manually reverse the string characters.
160
+ # In a pure LTR engine like Pillow (without Raqm), the reshaped glyphs
161
+ # must be provided in reverse order so that the first logical character
162
+ # ends up on the right side of the word visually.
163
+ return "".join(reversed(reshaped_text))
164
+ except Exception as e:
165
+ print(f"DEBUG: Arabic Shaping Error: {e}")
166
+ return text
167
+
168
+ def _resize_to_fit(img: Image.Image, max_w: int, max_h: int) -> Image.Image:
169
+ w, h = img.size
170
+ scale = min(max_w / w, max_h / h)
171
+ return img.resize((int(w * scale), int(h * scale)), Image.LANCZOS)
172
+
173
+ def _paste_logo_with_stroke(target: Image.Image, logo: Image.Image, x: int, y: int, stroke_width: int = 2):
174
+ mask = logo.split()[-1]
175
+ white_img = Image.new("RGBA", logo.size, (255, 255, 255, 255))
176
+ for dx in range(-stroke_width, stroke_width + 1):
177
+ for dy in range(-stroke_width, stroke_width + 1):
178
+ if dx*dx + dy*dy <= stroke_width*stroke_width:
179
+ target.paste(white_img, (x + dx, y + dy), mask)
180
+ target.paste(logo, (x, y), logo)
181
+
182
+ def _to_arabic_digits(text: str) -> str:
183
+ latin_to_arabic = str.maketrans("0123456789", "ู ูกูขูฃูคูฅูฆูงูจูฉ")
184
+ return text.translate(latin_to_arabic)
185
+
186
+ def _draw_text_with_spacing(draw: ImageDraw.ImageDraw, x: int, y: int, text: str, font: ImageFont.FreeTypeFont, fill: tuple, spacing: int = 0):
187
+ # Use standard draw. Complex script shaping is handled by reshaper/bidi before this call.
188
+ if spacing == 0:
189
+ draw.text((x, y), text, fill=fill, font=font)
190
+ return
191
+
192
+ curr_x = x
193
+ for char in text:
194
+ draw.text((curr_x, y), char, fill=fill, font=font)
195
+ curr_x += font.getlength(char) + spacing
196
+
197
+ def _today_str() -> str:
198
+ d = date.today()
199
+ return f"{d.day}.{d.month}.{d.year}"
200
+
201
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
202
+ # Main API
203
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
204
+
205
+ def generate_layout(input_image: Image.Image, person_name: str = "", id_number: str = "",
206
+ add_studio_name: bool = True, add_logo: bool = True, add_date: bool = True) -> Image.Image:
207
+ # Reload settings to ensure any changes to settings.json are applied immediately
208
+ global S, DPI, OUTPUT_WIDTH, OUTPUT_HEIGHT, PHOTO_BOTTOM_PAD, WHITE, MAROON, DARK_RED, TEXT_DARK
209
+ S = load_settings()
210
+ DPI = S["layout"]["dpi"]
211
+ OUTPUT_WIDTH = round(S["layout"]["output_w_cm"] / 2.54 * DPI)
212
+ OUTPUT_HEIGHT = round(S["layout"]["output_h_cm"] / 2.54 * DPI)
213
+ PHOTO_BOTTOM_PAD = round(S["layout"]["photo_bottom_pad_cm"] / 2.54 * DPI)
214
+ WHITE = c("white")
215
+ MAROON = c("maroon")
216
+ DARK_RED = c("dark_red")
217
+ TEXT_DARK = c("text_dark")
218
+
219
+ print(f"LAYOUT: Starting generation | Name: '{person_name}' | ID: '{id_number}'")
220
+ print(f"LAYOUT: Options | Logo: {add_logo} | Studio: {add_studio_name} | Date: {add_date}")
221
+ print(f"LAYOUT: Font Sizes | ID: {S['overlays']['id_font_size']} | Name: {S['overlays']['name_font_size']}")
222
+
223
+ if input_image.mode in ("RGBA", "LA") or (input_image.mode == "P" and "transparency" in input_image.info):
224
+ img = Image.new("RGB", input_image.size, WHITE)
225
+ img.paste(input_image, (0, 0), input_image.convert("RGBA"))
226
+ else:
227
+ img = input_image.convert("RGB")
228
+
229
+ logo = _load_logo() if add_logo else None
230
+ today = _today_str()
231
+ studio_date_text = f"EL HELAL {today}" if add_studio_name and add_date else \
232
+ "EL HELAL" if add_studio_name else \
233
+ today if add_date else ""
234
+
235
+ f_date = _find_font(S["overlays"]["date_font_size"])
236
+ f_id = _find_font(S["overlays"]["id_font_size"])
237
+ f_name = _arabic_font(S["overlays"]["name_font_size"])
238
+ f_date_l = _find_font(S["overlays"]["large_date_font_size"])
239
+ f_brand = _find_font(52)
240
+
241
+ display_name = _reshape_arabic(person_name)
242
+ id_display = _to_arabic_digits(id_number)
243
+
244
+ canvas = Image.new("RGB", (OUTPUT_WIDTH, OUTPUT_HEIGHT), WHITE)
245
+ draw = ImageDraw.Draw(canvas)
246
+
247
+ brand_w = round(9.2 / 2.54 * DPI)
248
+ grid_w = OUTPUT_WIDTH - brand_w - S["layout"]["section_gap"]
249
+
250
+ avail_w = grid_w - 2*S["layout"]["grid_margin"] - (S["layout"]["grid_cols"]-1)*S["layout"]["grid_gap"]
251
+ cell_w = avail_w // S["layout"]["grid_cols"]
252
+ avail_h = OUTPUT_HEIGHT - 2*S["layout"]["grid_margin"] - (S["layout"]["grid_rows"]-1)*S["layout"]["grid_gap"]
253
+ cell_h = avail_h // S["layout"]["grid_rows"]
254
+
255
+ photo_h = cell_h - PHOTO_BOTTOM_PAD
256
+ small = _resize_to_fit(img, cell_w, photo_h)
257
+ sw, sh = small.size
258
+
259
+ small_dec = Image.new("RGBA", (sw, sh), (255, 255, 255, 0))
260
+ small_dec.paste(small, (0, 0))
261
+
262
+ if id_display:
263
+ id_draw = ImageDraw.Draw(small_dec)
264
+ sp = S["overlays"]["id_char_spacing"]
265
+ tw = sum(f_id.getlength(c) for c in id_display) + (len(id_display)-1)*sp
266
+ tx, ty = (sw-tw)//2, sh - S["overlays"]["id_font_size"] - S["overlays"]["id_lift_offset"]
267
+ for off in [(-2,-2), (2,-2), (-2,2), (2,2), (0,-2), (0,2), (-2,0), (2,0)]:
268
+ _draw_text_with_spacing(id_draw, tx+off[0], ty+off[1], id_display, f_id, WHITE, sp)
269
+ _draw_text_with_spacing(id_draw, tx, ty, id_display, f_id, TEXT_DARK, sp)
270
+
271
+ if logo:
272
+ ls = S["overlays"]["logo_size_small"]
273
+ l_img = _resize_to_fit(logo, ls, ls)
274
+ _paste_logo_with_stroke(small_dec, l_img, S["overlays"]["logo_margin"], sh - l_img.size[1] - S["overlays"]["logo_margin"])
275
+
276
+ small_final = Image.new("RGB", small_dec.size, WHITE)
277
+ small_final.paste(small_dec, (0, 0), small_dec)
278
+
279
+ for r in range(S["layout"]["grid_rows"]):
280
+ for col in range(S["layout"]["grid_cols"]):
281
+ x = S["layout"]["grid_margin"] + col*(cell_w + S["layout"]["grid_gap"]) + (cell_w - sw)//2
282
+ y = S["layout"]["grid_margin"] + r*(cell_h + S["layout"]["grid_gap"])
283
+ canvas.paste(small_final, (x, y))
284
+ if studio_date_text:
285
+ draw.text((x + 5, y + sh + 1), studio_date_text, fill=DARK_RED, font=f_date)
286
+ if display_name:
287
+ nb = f_name.getbbox(display_name)
288
+ nx = x + (sw - (nb[2]-nb[0]))//2
289
+ # Draw reshaped/bidi text normally
290
+ draw.text((nx, y + sh + 23), display_name, fill=(0,0,0), font=f_name)
291
+
292
+ bx = grid_w + S["layout"]["section_gap"]
293
+ draw.rectangle([bx, 0, OUTPUT_WIDTH, OUTPUT_HEIGHT], fill=MAROON)
294
+
295
+ lav_w = brand_w - 2*S["layout"]["brand_border"]
296
+ lav_h = OUTPUT_HEIGHT - 2*S["layout"]["brand_border"] - 100
297
+ large = _resize_to_fit(img, lav_w, lav_h)
298
+ lw, lh = large.size
299
+ px = bx + (brand_w - lw)//2
300
+ py = S["layout"]["brand_border"] + (lav_h - lh)//2
301
+
302
+ draw.rectangle([px-6, py-6, px+lw+6, py+lh+6], fill=WHITE)
303
+ canvas.paste(large, (px, py))
304
+
305
+ if logo:
306
+ ls = S["overlays"]["logo_size_large"]
307
+ l_l = _resize_to_fit(logo, ls, ls)
308
+ _paste_logo_with_stroke(canvas, l_l, px + 15, py + lh - l_l.size[1] - 15)
309
+
310
+ if add_date:
311
+ draw.text((px + lw//2, py + lh - 40), studio_date_text, fill=DARK_RED, font=f_date_l, anchor="ms")
312
+
313
+ if add_studio_name:
314
+ btb = f_brand.getbbox("EL HELAL Studio")
315
+ draw.text((bx + (brand_w - (btb[2]-btb[0]))//2, OUTPUT_HEIGHT - 110), "EL HELAL Studio", fill=WHITE, font=f_brand)
316
+
317
+ canvas.info["dpi"] = (DPI, DPI)
318
+ return canvas
core/pipeline.py ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import time
3
+ import argparse
4
+ import sys
5
+ import torch
6
+ from pathlib import Path
7
+ from PIL import Image
8
+
9
+ # Import functions from existing scripts
10
+ # We might need to handle the monkeypatch for transformers in process_images
11
+ import crop
12
+ import process_images
13
+ import color_steal
14
+ import white_bg
15
+
16
+ def run_pipeline(raw_dir, crop_dir, trans_dir, colored_dir, white_dir, curves_file):
17
+ start_total = time.time()
18
+
19
+ # Step 1: Crop
20
+ print("\n" + "="*50)
21
+ print("STEP 1: Cropping and Face Detection")
22
+ print("="*50)
23
+ crop.batch_process(raw_dir, crop_dir)
24
+
25
+ # Step 2: Background Removal
26
+ print("\n" + "="*50)
27
+ print("STEP 2: Background Removal (AI)")
28
+ print("="*50)
29
+
30
+ # Setup model (this is the heavy part)
31
+ model, device = process_images.setup_model()
32
+ transform = process_images.get_transform()
33
+
34
+ input_path = Path(crop_dir)
35
+ output_path = Path(trans_dir)
36
+ output_path.mkdir(parents=True, exist_ok=True)
37
+
38
+ files = [f for f in input_path.iterdir() if f.suffix.lower() in process_images.ALLOWED_EXTENSIONS]
39
+ if not files:
40
+ print(f"No images found in {crop_dir} for background removal.")
41
+ else:
42
+ for idx, file_path in enumerate(files, 1):
43
+ try:
44
+ print(f"[{idx}/{len(files)}] Removing background: {file_path.name}...", end='', flush=True)
45
+ img = Image.open(file_path)
46
+ from PIL import ImageOps
47
+ img = ImageOps.exif_transpose(img)
48
+ img = img.convert('RGB')
49
+ result = process_images.remove_background(model, img, transform)
50
+ out_name = file_path.stem + "_rmbg.png"
51
+ result.save(output_path / out_name, "PNG")
52
+ print(" Done.")
53
+ except Exception as e:
54
+ print(f" Failed! {e}")
55
+
56
+ # Step 3: Color Grading
57
+ print("\n" + "="*50)
58
+ print("STEP 3: Color Grading")
59
+ print("="*50)
60
+ luts = color_steal.load_trained_curves(curves_file)
61
+ if not luts:
62
+ print(f"Warning: No trained curves found at {curves_file}. Skipping color grading.")
63
+ # If no grading, we might want to copy trans to colored or just skip to step 4 using trans_dir
64
+ # For simplicity, let's assume we need curves or we skip this step and use trans_dir for step 4
65
+ current_input_for_white = trans_dir
66
+ else:
67
+ color_steal.apply_to_folder(luts, trans_dir, colored_dir)
68
+ current_input_for_white = colored_dir
69
+
70
+ # Step 4: White Background
71
+ print("\n" + "="*50)
72
+ print("STEP 4: Adding White Background & Finalizing")
73
+ print("="*50)
74
+ white_bg.add_white_background(current_input_for_white, white_dir)
75
+
76
+ end_total = time.time()
77
+ print("\n" + "="*50)
78
+ print(f"PIPELINE COMPLETE in {end_total - start_total:.2f} seconds")
79
+ print(f"Final results are in: {os.path.abspath(white_dir)}")
80
+ print("="*50)
81
+
82
+ if __name__ == "__main__":
83
+ parser = argparse.ArgumentParser(description="Full Image Processing Pipeline")
84
+ parser.add_argument("--raw", default="raw", help="Folder with raw images")
85
+ parser.add_argument("--crop", default="crop", help="Folder for cropped images")
86
+ parser.add_argument("--trans", default="trans", help="Folder for transparent images")
87
+ parser.add_argument("--colored", default="colored", help="Folder for color-graded images")
88
+ parser.add_argument("--white", default="white", help="Folder for final results")
89
+ parser.add_argument("--curves", default="trained_curves.npz", help="Pre-trained curves file")
90
+
91
+ args = parser.parse_args()
92
+
93
+ # Ensure all directories exist
94
+ for d in [args.raw, args.crop, args.trans, args.colored, args.white]:
95
+ if not os.path.exists(d):
96
+ os.makedirs(d)
97
+ print(f"Created directory: {d}")
98
+
99
+ run_pipeline(args.raw, args.crop, args.trans, args.colored, args.white, args.curves)
core/process_images.py ADDED
@@ -0,0 +1,214 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sys
3
+ import argparse
4
+ import time
5
+ import traceback
6
+ from pathlib import Path
7
+ from PIL import Image, ImageOps
8
+ import torch
9
+ from torchvision import transforms
10
+
11
+ # ---- Monkeypatch for transformers 4.50+ compatibility with custom Config classes ----
12
+ from transformers import configuration_utils
13
+ _original_get_text_config = configuration_utils.PretrainedConfig.get_text_config
14
+
15
+ def _patched_get_text_config(self, *args, **kwargs):
16
+ if not hasattr(self, 'is_encoder_decoder'):
17
+ self.is_encoder_decoder = False
18
+ return _original_get_text_config(self, *args, **kwargs)
19
+
20
+ configuration_utils.PretrainedConfig.get_text_config = _patched_get_text_config
21
+ # ---- End Monkeypatch ----
22
+
23
+ # ---- Monkeypatch for BiRefNet/RMBG-2.0 meta-tensor bug during initialization ----
24
+ _orig_linspace = torch.linspace
25
+ def _patched_linspace(*args, **kwargs):
26
+ t = _orig_linspace(*args, **kwargs)
27
+ if t.is_meta:
28
+ return _orig_linspace(*args, **{**kwargs, "device": "cpu"})
29
+ return t
30
+ torch.linspace = _patched_linspace
31
+ # ---- End Monkeypatch ----
32
+
33
+ # ---- Monkeypatch for BiRefNet tied weights compatibility with transformers 4.50+ ----
34
+ def patch_birefnet_tied_weights():
35
+ try:
36
+ from transformers import PreTrainedModel
37
+
38
+ # Force the property to always return a dict, even if _tied_weights_keys is None
39
+ def _get_all_tied_weights_keys(self):
40
+ return getattr(self, "_tied_weights_keys", {}) or {}
41
+
42
+ PreTrainedModel.all_tied_weights_keys = property(_get_all_tied_weights_keys)
43
+ print("Applied robust BiRefNet tied weights patch")
44
+
45
+ except Exception as e:
46
+ print(f"Failed to apply BiRefNet tied weights patch: {e}")
47
+
48
+ patch_birefnet_tied_weights()
49
+ # ---- End Monkeypatch ----
50
+
51
+ from transformers import AutoModelForImageSegmentation, AutoConfig
52
+ import retouch
53
+
54
+ # Try to import devicetorch (from your project dependencies)
55
+ try:
56
+ import devicetorch
57
+ except ImportError:
58
+ print("Error: 'devicetorch' not found. Please run this script from the project root or install requirements.")
59
+ sys.exit(1)
60
+
61
+ # Configure allowed extensions
62
+ ALLOWED_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp'}
63
+
64
+ def setup_model():
65
+ """Load and configure the RMBG-2.0 model"""
66
+ print("Loading BRIA-RMBG-2.0 model...")
67
+
68
+ # 1. Device Selection
69
+ device = devicetorch.get(torch)
70
+ print(f"Device: {device}")
71
+
72
+ if device == 'cpu':
73
+ torch.set_num_threads(max(1, os.cpu_count() or 1))
74
+
75
+ # 2. Load Model
76
+ try:
77
+ print("Loading model config...")
78
+ config = AutoConfig.from_pretrained("cocktailpeanut/rm", trust_remote_code=True)
79
+
80
+ # Explicitly set low_cpu_mem_usage=False to avoid meta-tensor issues
81
+ model = AutoModelForImageSegmentation.from_pretrained(
82
+ "cocktailpeanut/rm",
83
+ config=config,
84
+ trust_remote_code=True,
85
+ low_cpu_mem_usage=False
86
+ )
87
+ model = devicetorch.to(torch, model)
88
+ model.eval()
89
+ except Exception as e:
90
+ print(f"Error loading model: {e}")
91
+ traceback.print_exc()
92
+ sys.exit(1)
93
+
94
+ # 3. CPU Optimization (Optional)
95
+ if device == 'cpu':
96
+ print("Applying Dynamic Quantization for CPU speedup...")
97
+ try:
98
+ model = torch.quantization.quantize_dynamic(
99
+ model, {torch.nn.Linear}, dtype=torch.qint8
100
+ )
101
+ except Exception:
102
+ pass
103
+
104
+ return model, device
105
+
106
+ def get_transform():
107
+ """Get the specific image transformation required by the model"""
108
+ return transforms.Compose([
109
+ transforms.Resize((1024, 1024)),
110
+ transforms.ToTensor(),
111
+ transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]),
112
+ ])
113
+
114
+ def remove_background(model, image, transform):
115
+ """Process a single image"""
116
+ # Keep original size for later resizing
117
+ orig_size = image.size
118
+
119
+ # Preprocess
120
+ input_tensor = transform(image).unsqueeze(0)
121
+ input_tensor = devicetorch.to(torch, input_tensor)
122
+
123
+ # Inference
124
+ with torch.inference_mode():
125
+ outputs = model(input_tensor)
126
+ if isinstance(outputs, (list, tuple)):
127
+ preds = outputs[-1].sigmoid().cpu()
128
+ else:
129
+ preds = outputs.sigmoid().cpu()
130
+
131
+ # Post-process mask
132
+ pred = preds[0].squeeze()
133
+ pred_pil = transforms.ToPILImage()(pred)
134
+ mask = pred_pil.resize(orig_size)
135
+
136
+ # Apply mask
137
+ result = image.copy()
138
+ result.putalpha(mask)
139
+
140
+ # Cleanup VRAM if needed
141
+ devicetorch.empty_cache(torch)
142
+
143
+ return result
144
+
145
+ def retouch_face(image, sensitivity=3.0, tone_smoothing=0.6):
146
+ """Wrapper for the surgical retouch logic with detailed logging"""
147
+ start_time = time.time()
148
+ try:
149
+ retouched_img, count = retouch.retouch_image_pil(image, sensitivity, tone_smoothing)
150
+ duration = (time.time() - start_time) * 1000
151
+ print(f"RETOUCH: Success | Blemishes: {count} | Time: {duration:.1f}ms")
152
+ return retouched_img
153
+ except Exception as e:
154
+ print(f"RETOUCH: Failed | Error: {e}")
155
+ return image
156
+
157
+ def main():
158
+ parser = argparse.ArgumentParser(description="Batch Background Removal Tool")
159
+ parser.add_argument('--input', '-i', required=True, help="Input folder containing images")
160
+ parser.add_argument('--output', '-o', required=True, help="Output folder for processed images")
161
+ args = parser.parse_args()
162
+
163
+ input_path = Path(args.input)
164
+ output_path = Path(args.output)
165
+
166
+ if not input_path.exists():
167
+ print(f"Error: Input folder '{input_path}' does not exist.")
168
+ sys.exit(1)
169
+
170
+ # Create output folder if it doesn't exist
171
+ output_path.mkdir(parents=True, exist_ok=True)
172
+
173
+ # Setup
174
+ model, device = setup_model()
175
+ transform = get_transform()
176
+
177
+ # Process files
178
+ files = [f for f in input_path.iterdir() if f.suffix.lower() in ALLOWED_EXTENSIONS]
179
+ total = len(files)
180
+
181
+ print(f"\nFound {total} images. Starting processing...")
182
+ print("-" * 50)
183
+
184
+ start_time = time.time()
185
+ for idx, file_path in enumerate(files, 1):
186
+ try:
187
+ filename = file_path.name
188
+ print(f"[{idx}/{total}] Processing {filename}...", end='', flush=True)
189
+
190
+ # Load image and handle orientation
191
+ img = Image.open(file_path)
192
+ img = ImageOps.exif_transpose(img)
193
+ img = img.convert('RGB')
194
+
195
+ # Process
196
+ result = remove_background(model, img, transform)
197
+
198
+ # Save (force PNG for transparency)
199
+ out_name = file_path.stem + "_rmbg.png"
200
+ out_file = output_path / out_name
201
+ result.save(out_file, "PNG")
202
+
203
+ print(" Done.")
204
+
205
+ except Exception as e:
206
+ print(f" Failed! Error: {e}")
207
+
208
+ duration = time.time() - start_time
209
+ print("-" * 50)
210
+ print(f"Finished! Processed {total} images in {duration:.2f} seconds.")
211
+ print(f"Output saved to: {output_path.absolute()}")
212
+
213
+ if __name__ == "__main__":
214
+ main()
core/retouch.py ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ """
3
+ Surgical Skin Retouching Script - Refactored for PIL Integration
4
+ """
5
+ import cv2
6
+ import numpy as np
7
+ from PIL import Image
8
+
9
+ # Robust Mediapipe Loading
10
+ try:
11
+ import mediapipe as mp
12
+ import mediapipe.solutions.face_mesh as mp_face_mesh
13
+ import mediapipe.solutions.drawing_utils as mp_drawing
14
+ except (ImportError, ModuleNotFoundError):
15
+ try:
16
+ import mediapipe as mp
17
+ mp_face_mesh = mp.solutions.face_mesh
18
+ mp_drawing = mp.solutions.drawing_utils
19
+ except (AttributeError, ImportError, ModuleNotFoundError):
20
+ mp_face_mesh = None
21
+ mp_drawing = None
22
+
23
+ # Landmarks constants
24
+ FACE_OVAL = [10, 338, 297, 332, 284, 251, 389, 356, 454, 323, 361, 288,
25
+ 397, 365, 379, 378, 400, 377, 152, 148, 176, 149, 150, 136,
26
+ 172, 58, 132, 93, 234, 127, 162, 21, 54, 103, 67, 109]
27
+
28
+ LEFT_EYE = [362, 382, 381, 380, 374, 373, 390, 249, 263, 466, 388, 387, 386, 385, 384, 398]
29
+ RIGHT_EYE = [33, 7, 163, 144, 145, 153, 154, 155, 133, 173, 157, 158, 159, 160, 161, 246]
30
+ LEFT_EYEBROW = [336, 296, 334, 293, 300, 276, 283, 282, 295, 285]
31
+ RIGHT_EYEBROW = [70, 63, 105, 66, 107, 55, 65, 52, 53, 46]
32
+ LIPS_OUTER = [61, 146, 91, 181, 84, 17, 314, 405, 321, 375, 291, 409, 270, 269, 267, 0, 37, 39, 40, 185]
33
+ LIPS_INNER = [78, 95, 88, 178, 87, 14, 317, 402, 318, 324, 308, 415, 310, 311, 312, 13, 82, 81, 80, 191]
34
+ NOSE_TIP = [1, 2, 98, 327]
35
+
36
+ def get_landmark_points(landmarks, indices, width, height):
37
+ pts = []
38
+ for i in indices:
39
+ lm = landmarks[i]
40
+ pts.append((int(lm.x * width), int(lm.y * height)))
41
+ return np.array(pts)
42
+
43
+ def create_skin_mask(image, landmarks, width, height, feature_guard=5):
44
+ mask = np.zeros((height, width), dtype=np.uint8)
45
+ face_pts = get_landmark_points(landmarks, FACE_OVAL, width, height)
46
+ cv2.fillConvexPoly(mask, cv2.convexHull(face_pts), 255)
47
+
48
+ exclusions = [LEFT_EYE, RIGHT_EYE, LEFT_EYEBROW, RIGHT_EYEBROW,
49
+ LIPS_OUTER, LIPS_INNER, NOSE_TIP]
50
+ for feature_indices in exclusions:
51
+ pts = get_landmark_points(landmarks, feature_indices, width, height)
52
+ hull = cv2.convexHull(pts)
53
+ cv2.fillConvexPoly(mask, hull, 0)
54
+ cv2.polylines(mask, [hull], True, 0, feature_guard)
55
+
56
+ img_ycrcb = cv2.cvtColor(image, cv2.COLOR_BGR2YCrCb)
57
+ lower = np.array([0, 133, 77], dtype=np.uint8)
58
+ upper = np.array([255, 173, 127], dtype=np.uint8)
59
+ color_mask = cv2.inRange(img_ycrcb, lower, upper)
60
+ mask = cv2.bitwise_and(mask, color_mask)
61
+
62
+ kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (7, 7))
63
+ mask = cv2.erode(mask, kernel, iterations=1)
64
+ return mask
65
+
66
+ def frequency_separation(image, blur_radius=15):
67
+ img_float = image.astype(np.float64)
68
+ if blur_radius % 2 == 0: blur_radius += 1
69
+ low_freq = cv2.GaussianBlur(img_float, (blur_radius, blur_radius), 0)
70
+ high_freq = img_float - low_freq
71
+ return low_freq, high_freq
72
+
73
+ def detect_blemishes(high_freq, skin_mask, sensitivity=3.0, min_size=3, max_size=50):
74
+ hf_gray = cv2.cvtColor(np.clip(high_freq + 128, 0, 255).astype(np.uint8), cv2.COLOR_BGR2GRAY)
75
+ dog_fine = cv2.GaussianBlur(hf_gray.astype(np.float64), (3, 3), 1)
76
+ dog_coarse = cv2.GaussianBlur(hf_gray.astype(np.float64), (15, 15), 5)
77
+ dog = dog_fine - dog_coarse
78
+
79
+ skin_pixels = dog[skin_mask > 0]
80
+ if len(skin_pixels) == 0: return np.zeros_like(skin_mask)
81
+
82
+ mean_val, std_val = np.mean(skin_pixels), np.std(skin_pixels)
83
+ threshold, threshold_bright = mean_val - sensitivity * std_val, mean_val + sensitivity * std_val
84
+
85
+ blemish_mask = np.zeros_like(skin_mask)
86
+ blemish_mask[(dog < threshold) & (skin_mask > 0)] = 255
87
+ blemish_mask[(dog > threshold_bright) & (skin_mask > 0)] = 255
88
+
89
+ contours, _ = cv2.findContours(blemish_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
90
+ filtered_mask = np.zeros_like(blemish_mask)
91
+ for cnt in contours:
92
+ area = cv2.contourArea(cnt)
93
+ if min_size * min_size <= area <= max_size * max_size:
94
+ cv2.drawContours(filtered_mask, [cnt], -1, 255, -1)
95
+
96
+ kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
97
+ return cv2.dilate(filtered_mask, kernel, iterations=1)
98
+
99
+ def even_skin_tone(low_freq, skin_mask, strength=0.4):
100
+ mask_float = cv2.GaussianBlur(skin_mask.astype(np.float64)/255.0, (31, 31), 10)
101
+ mask_3ch = np.stack([mask_float] * 3, axis=-1)
102
+ smoothed = cv2.GaussianBlur(low_freq, (31, 31), 10)
103
+ return low_freq + (smoothed - low_freq) * mask_3ch * strength
104
+
105
+ def retouch_image_pil(pil_image, sensitivity=3.0, tone_smoothing=0.6):
106
+ # Handle Transparency (Alpha Channel)
107
+ has_alpha = pil_image.mode in ("RGBA", "LA", "PA")
108
+ alpha_channel = None
109
+ if has_alpha:
110
+ alpha_channel = pil_image.getchannel('A')
111
+
112
+ # PIL to BGR (always process RGB part)
113
+ image = cv2.cvtColor(np.array(pil_image.convert("RGB")), cv2.COLOR_RGB2BGR)
114
+ height, width = image.shape[:2]
115
+
116
+ # Face Detection
117
+ if mp_face_mesh is None:
118
+ print("RETOUCH: Skipped (Mediapipe FaceMesh not loaded)")
119
+ return pil_image, 0
120
+
121
+ with mp_face_mesh.FaceMesh(static_image_mode=True, max_num_faces=1, refine_landmarks=True) as face_mesh:
122
+ results = face_mesh.process(cv2.cvtColor(image, cv2.COLOR_BGR2RGB))
123
+ if not results.multi_face_landmarks:
124
+ return pil_image, 0 # No face found, return original with 0 count
125
+
126
+ landmarks = results.multi_face_landmarks[0].landmark
127
+
128
+ # Processing
129
+ skin_mask = create_skin_mask(image, landmarks, width, height)
130
+ low_freq, high_freq = frequency_separation(image)
131
+ blemish_mask = detect_blemishes(high_freq, skin_mask, sensitivity)
132
+
133
+ hf_uint8 = np.clip(high_freq + 128, 0, 255).astype(np.uint8)
134
+ hf_inpainted = cv2.inpaint(hf_uint8, blemish_mask, inpaintRadius=5, flags=cv2.INPAINT_TELEA)
135
+ high_freq_clean = hf_inpainted.astype(np.float64) - 128.0
136
+
137
+ if tone_smoothing > 0:
138
+ low_freq = even_skin_tone(low_freq, skin_mask, tone_smoothing)
139
+
140
+ result = np.clip(low_freq + high_freq_clean, 0, 255).astype(np.uint8)
141
+
142
+ # Seamless Blend
143
+ mask_f = cv2.GaussianBlur(skin_mask.astype(np.float64)/255.0, (21, 21), 7)
144
+ mask_3ch = np.stack([mask_f] * 3, axis=-1)
145
+ final_bgr = (result * mask_3ch + image * (1.0 - mask_3ch)).astype(np.uint8)
146
+
147
+ # Count blemishes for logging
148
+ blemish_count = len(cv2.findContours(blemish_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)[0])
149
+
150
+ # BGR to PIL
151
+ final_pil = Image.fromarray(cv2.cvtColor(final_bgr, cv2.COLOR_BGR2RGB))
152
+
153
+ # Restore Alpha Channel
154
+ if has_alpha and alpha_channel is not None:
155
+ final_pil.putalpha(alpha_channel)
156
+ return final_pil, blemish_count
157
+ else:
158
+ return final_pil, blemish_count
core/trained_curves.npz ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f9686847cc033368e66bddaf4ebb214cc0442876e2d3451d55209b45a1632b85
3
+ size 1492
core/white_bg.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import argparse
3
+ from PIL import Image
4
+
5
+ # Constants for 10cm x 14cm at 300 DPI
6
+ # Calculation: (10 / 2.54 * 300) = 1181 px width
7
+ # (14 / 2.54 * 300) = 1654 px height
8
+ TARGET_WIDTH = 1181
9
+ TARGET_HEIGHT = 1654
10
+
11
+ def add_white_background(input_folder, output_folder):
12
+ if not os.path.exists(output_folder):
13
+ os.makedirs(output_folder)
14
+ print(f"Created output folder: {output_folder}")
15
+
16
+ valid_exts = ('.png', '.tiff', '.tif')
17
+ files = [f for f in os.listdir(input_folder) if f.lower().endswith(valid_exts)]
18
+
19
+ if not files:
20
+ print(f"No transparent images (PNG/TIFF) found in {input_folder}")
21
+ return
22
+
23
+ print(f"Found {len(files)} images. Adding white background...")
24
+
25
+ for filename in files:
26
+ img_path = os.path.join(input_folder, filename)
27
+ out_path = os.path.join(output_folder, os.path.splitext(filename)[0] + ".jpg")
28
+
29
+ try:
30
+ print(f"Processing: {filename}...")
31
+
32
+ # 1. Load Image (Ensure RGBA to handle transparency)
33
+ foreground = Image.open(img_path).convert("RGBA")
34
+
35
+ # 2. Smart Resize to Target (10x14cm)
36
+ # Create a white canvas of the exact target size
37
+ final_image = Image.new("RGB", (TARGET_WIDTH, TARGET_HEIGHT), (255, 255, 255))
38
+
39
+ # Resize the foreground to fit/fill
40
+ # Using LANCZOS for high quality downscaling/upscaling
41
+ fg_resized = foreground.resize((TARGET_WIDTH, TARGET_HEIGHT), Image.Resampling.LANCZOS)
42
+
43
+ # 3. Composite Foreground onto White Canvas
44
+ # We use the alpha channel of the resized foreground as the mask
45
+ final_image.paste(fg_resized, (0, 0), fg_resized)
46
+
47
+ # 4. Save with 300 DPI metadata
48
+ final_image.save(out_path, quality=95, dpi=(300, 300))
49
+ print(f" -> Saved to {out_path} (10x14cm @ 300DPI)")
50
+
51
+ except Exception as e:
52
+ print(f" ERROR processing {filename}: {e}")
53
+
54
+ if __name__ == "__main__":
55
+ parser = argparse.ArgumentParser(description="Add white background to transparent images and resize to 10x14cm @ 300DPI")
56
+ parser.add_argument("input", help="Input folder containing transparent images (PNG)")
57
+ parser.add_argument("output", help="Output folder")
58
+
59
+ args = parser.parse_args()
60
+
61
+ add_white_background(args.input, args.output)
docker-compose.yml ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ version: '3.8'
2
+
3
+ services:
4
+ studio-web:
5
+ build: .
6
+ ports:
7
+ - "8000:8000"
8
+ volumes:
9
+ - studio_storage:/app/storage
10
+ - ./assets:/app/assets
11
+ - ./core:/app/core
12
+ environment:
13
+ - PORT=8000
14
+ restart: unless-stopped
15
+
16
+ volumes:
17
+ studio_storage:
gui/gui.py ADDED
@@ -0,0 +1,415 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ EL HELAL Studio โ€“ Professional Photo Workflow
3
+ Integrated Pipeline with Memory Optimization and Manual Crop
4
+ """
5
+
6
+ import tkinter as tk
7
+ from tkinter import ttk, filedialog, messagebox
8
+ from PIL import Image, ImageTk, ImageOps
9
+ import os
10
+ import threading
11
+ from pathlib import Path
12
+ import time
13
+ import sys
14
+
15
+ # Add core directory to python path
16
+ sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'core')))
17
+
18
+ # Import our processing tools
19
+ import crop
20
+ import process_images
21
+ import color_steal
22
+ from layout_engine import generate_layout
23
+
24
+
25
+ class CropDialog(tk.Toplevel):
26
+ """Interactive window to adjust crop manually."""
27
+ def __init__(self, parent, image_path, current_rect, callback):
28
+ super().__init__(parent)
29
+ self.title("Adjust Crop (5:7 Aspect Ratio)")
30
+ self.image_path = image_path
31
+ self.callback = callback
32
+
33
+ # Load original image for reference
34
+ self.orig_pil = Image.open(image_path)
35
+ self.w, self.h = self.orig_pil.size
36
+
37
+ # Scale for display
38
+ screen_h = self.winfo_screenheight()
39
+ target_h = int(screen_h * 0.8)
40
+ self.display_scale = min(target_h / self.h, 1.0)
41
+
42
+ self.display_w = int(self.w * self.display_scale)
43
+ self.display_h = int(self.h * self.display_scale)
44
+
45
+ self.display_img = self.orig_pil.resize((self.display_w, self.display_h), Image.LANCZOS)
46
+ self.tk_img = ImageTk.PhotoImage(self.display_img)
47
+
48
+ # Rect in original coordinates (x1, y1, x2, y2)
49
+ self.rect = list(current_rect) if current_rect else [0, 0, 100, 140]
50
+
51
+ # UI Layout
52
+ self.canvas = tk.Canvas(self, width=self.display_w, height=self.display_h, bg="black", highlightthickness=0)
53
+ self.canvas.pack(pady=10, padx=10)
54
+ self.canvas.create_image(0, 0, image=self.tk_img, anchor=tk.NW)
55
+
56
+ self.rect_id = self.canvas.create_rectangle(0, 0, 0, 0, outline="yellow", width=3)
57
+ self._update_canvas_rect()
58
+
59
+ ctrl = tk.Frame(self)
60
+ ctrl.pack(fill=tk.X, padx=20, pady=5)
61
+ tk.Label(ctrl, text="Drag the yellow box to move the crop. The size is fixed to 5:7.", font=("Arial", 10, "italic")).pack()
62
+
63
+ btn_frame = tk.Frame(self)
64
+ btn_frame.pack(pady=15)
65
+ tk.Button(btn_frame, text=" Cancel ", command=self.destroy, width=10).pack(side=tk.LEFT, padx=10)
66
+ tk.Button(btn_frame, text=" Apply & Reprocess ", bg="#27ae60", fg="white",
67
+ command=self._apply, width=20, font=("Arial", 10, "bold")).pack(side=tk.LEFT, padx=10)
68
+
69
+ # Mouse Events
70
+ self.canvas.bind("<B1-Motion>", self._on_drag)
71
+ self.canvas.bind("<Button-1>", self._on_click)
72
+
73
+ # Center the window
74
+ self.update_idletasks()
75
+ wx = (self.winfo_screenwidth() - self.winfo_width()) // 2
76
+ wy = (self.winfo_screenheight() - self.winfo_height()) // 2
77
+ self.geometry(f"+{wx}+{wy}")
78
+
79
+ self.grab_set() # Modal
80
+
81
+ def _update_canvas_rect(self):
82
+ x1, y1, x2, y2 = self.rect
83
+ self.canvas.coords(self.rect_id,
84
+ x1 * self.display_scale, y1 * self.display_scale,
85
+ x2 * self.display_scale, y2 * self.display_scale)
86
+
87
+ def _on_click(self, event):
88
+ self.start_x = event.x
89
+ self.start_y = event.y
90
+
91
+ def _on_drag(self, event):
92
+ dx = (event.x - self.start_x) / self.display_scale
93
+ dy = (event.y - self.start_y) / self.display_scale
94
+
95
+ rw = self.rect[2] - self.rect[0]
96
+ rh = self.rect[3] - self.rect[1]
97
+
98
+ nx1 = self.rect[0] + dx
99
+ ny1 = self.rect[1] + dy
100
+
101
+ if nx1 < 0: nx1 = 0
102
+ if ny1 < 0: ny1 = 0
103
+ if nx1 + rw > self.w: nx1 = self.w - rw
104
+ if ny1 + rh > self.h: ny1 = self.h - rh
105
+
106
+ self.rect[0] = nx1
107
+ self.rect[1] = ny1
108
+ self.rect[2] = nx1 + rw
109
+ self.rect[3] = ny1 + rh
110
+
111
+ self.start_x = event.x
112
+ self.start_y = event.y
113
+ self._update_canvas_rect()
114
+
115
+ def _apply(self):
116
+ self.callback(tuple(map(int, self.rect)))
117
+ self.destroy()
118
+
119
+
120
+ class StudioApp:
121
+ """Main application with memory-efficient batch handling."""
122
+ def __init__(self, root: tk.Tk):
123
+ self.root = root
124
+ self.root.title("EL HELAL Studio โ€” Professional Workflow")
125
+ self.root.minsize(1000, 900)
126
+ self.root.configure(bg="#f0f0f0")
127
+
128
+ self._image_data: list[dict] = []
129
+ self._current_index = 0
130
+ self._phase = "empty" # "empty" | "input" | "preview"
131
+
132
+ self.model = None
133
+ self.transform = None
134
+ self.luts = color_steal.load_trained_curves()
135
+ self.is_model_ready = False
136
+
137
+ self._build_ui()
138
+ self.root.bind("<Configure>", self._on_resize)
139
+
140
+ threading.Thread(target=self._warm_up_model, daemon=True).start()
141
+
142
+ def _warm_up_model(self):
143
+ try:
144
+ self._set_status("Initializing AI Engine...")
145
+ self.model, _ = process_images.setup_model()
146
+ self.transform = process_images.get_transform()
147
+ self.is_model_ready = True
148
+ self._set_status("AI Engine Ready.")
149
+ except Exception as e:
150
+ self._set_status(f"Critical Error: AI Model failed to load ({e})")
151
+
152
+ def _build_ui(self):
153
+ # Header
154
+ header = tk.Frame(self.root, bg="#1a2634", pady=15)
155
+ header.pack(fill=tk.X)
156
+ tk.Label(header, text="EL HELAL Studio", font=("Arial", 26, "bold"), fg="#e8b923", bg="#1a2634").pack()
157
+ tk.Label(header, text="Memory Optimized Pipeline: Auto-Crop | AI Background | Color Grade | Layout",
158
+ font=("Arial", 10), fg="white", bg="#1a2634").pack()
159
+
160
+ # Input Area
161
+ input_frame = tk.Frame(self.root, bg="#f0f0f0", pady=10)
162
+ input_frame.pack(fill=tk.X, padx=20)
163
+ tk.Label(input_frame, text="Name (ุงู„ุงุณู…):", font=("Arial", 11, "bold"), bg="#f0f0f0").pack(side=tk.LEFT, padx=(0, 5))
164
+ self.entry_name = tk.Entry(input_frame, font=("Arial", 14), width=30, justify=tk.RIGHT)
165
+ self.entry_name.pack(side=tk.LEFT, padx=(0, 25))
166
+ tk.Label(input_frame, text="ID (ุงู„ุฑู‚ู…):", font=("Arial", 11, "bold"), bg="#f0f0f0").pack(side=tk.LEFT, padx=(0, 5))
167
+ self.entry_id = tk.Entry(input_frame, font=("Arial", 14), width=20)
168
+ self.entry_id.pack(side=tk.LEFT)
169
+
170
+ # Toolbar
171
+ toolbar = tk.Frame(self.root, bg="#f0f0f0", pady=10)
172
+ toolbar.pack(fill=tk.X, padx=20)
173
+ self.btn_open = tk.Button(toolbar, text=" ๐Ÿ“‚ Select Photos ", command=self._open_files,
174
+ bg="#3498db", fg="white", relief=tk.FLAT, padx=15, pady=8, font=("Arial", 10, "bold"))
175
+ self.btn_open.pack(side=tk.LEFT, padx=5)
176
+ self.btn_process = tk.Button(toolbar, text=" โšก Start All ", command=self._process_all,
177
+ bg="#e67e22", fg="white", relief=tk.FLAT, padx=15, pady=8,
178
+ font=("Arial", 10, "bold"), state=tk.DISABLED)
179
+ self.btn_process.pack(side=tk.LEFT, padx=5)
180
+ self.btn_save = tk.Button(toolbar, text=" ๐Ÿ’พ Save All ", command=self._save_all,
181
+ bg="#27ae60", fg="white", relief=tk.FLAT, padx=15, pady=8,
182
+ font=("Arial", 10, "bold"), state=tk.DISABLED)
183
+ self.btn_save.pack(side=tk.LEFT, padx=5)
184
+ self.btn_edit_crop = tk.Button(toolbar, text=" โœ‚ Edit Crop ", command=self._edit_crop,
185
+ bg="#95a5a6", fg="white", relief=tk.FLAT, padx=15, pady=8,
186
+ font=("Arial", 10, "bold"), state=tk.DISABLED)
187
+ self.btn_edit_crop.pack(side=tk.LEFT, padx=(30, 0))
188
+
189
+ # Navigation
190
+ nav_frame = tk.Frame(toolbar, bg="#f0f0f0"); nav_frame.pack(side=tk.RIGHT)
191
+ self.btn_prev = tk.Button(nav_frame, text=" โ—€ ", command=self._prev_image, state=tk.DISABLED, width=4)
192
+ self.btn_prev.pack(side=tk.LEFT, padx=2)
193
+ self.lbl_counter = tk.Label(nav_frame, text="", font=("Arial", 11, "bold"), bg="#f0f0f0", width=8)
194
+ self.lbl_counter.pack(side=tk.LEFT)
195
+ self.btn_next = tk.Button(nav_frame, text=" โ–ถ ", command=self._next_image, state=tk.DISABLED, width=4)
196
+ self.btn_next.pack(side=tk.LEFT, padx=2)
197
+
198
+ # Progress
199
+ self.progress = ttk.Progressbar(self.root, orient=tk.HORIZONTAL, mode='determinate')
200
+ self.progress.pack(fill=tk.X, padx=20, pady=(0, 10))
201
+
202
+ # Canvas
203
+ canvas_frame = tk.Frame(self.root, bg="#ddd", bd=1, relief=tk.SUNKEN)
204
+ canvas_frame.pack(fill=tk.BOTH, expand=True, padx=20, pady=5)
205
+ self.canvas = tk.Canvas(canvas_frame, bg="white", highlightthickness=0)
206
+ self.canvas.pack(fill=tk.BOTH, expand=True)
207
+
208
+ self.status = tk.Label(self.root, text="Ready", font=("Arial", 10), bg="#1a2634",
209
+ fg="white", anchor=tk.W, padx=15, pady=8)
210
+ self.status.pack(fill=tk.X, side=tk.BOTTOM)
211
+
212
+ # โ”€โ”€ Operations โ”€โ”€
213
+
214
+ def _open_files(self):
215
+ paths = filedialog.askopenfilenames(
216
+ title="Select Student Photos",
217
+ filetypes=[("Image files", "*.jpg *.jpeg *.png *.bmp *.tiff")]
218
+ )
219
+ if not paths: return
220
+
221
+ self._image_data = []
222
+ for path in paths:
223
+ # Generate thumbnail for preview to save memory
224
+ try:
225
+ with Image.open(path) as img:
226
+ img.thumbnail((800, 800), Image.LANCZOS)
227
+ thumb = img.copy()
228
+
229
+ self._image_data.append({
230
+ "path": path,
231
+ "name": "",
232
+ "id": "",
233
+ "thumb": thumb, # Store small version only
234
+ "result": None,
235
+ "crop_rect": None
236
+ })
237
+ except Exception as e:
238
+ print(f"Error loading {path}: {e}")
239
+
240
+ self._current_index = 0
241
+ self._phase = "input"
242
+ self._load_current_fields()
243
+ self._update_nav()
244
+ self._update_ui_state()
245
+ self._show_current()
246
+ self._set_status(f"Loaded {len(self._image_data)} photos. Memory usage optimized.")
247
+
248
+ def _process_all(self):
249
+ self._save_current_fields()
250
+ if not self.is_model_ready:
251
+ messagebox.showwarning("Wait", "AI Model is still loading.")
252
+ return
253
+
254
+ self.progress['value'] = 0
255
+ self.progress['maximum'] = len(self._image_data)
256
+ self.btn_open.config(state=tk.DISABLED)
257
+ self.btn_process.config(state=tk.DISABLED)
258
+
259
+ threading.Thread(target=self._run_pipeline_batch, daemon=True).start()
260
+
261
+ def _run_pipeline_batch(self):
262
+ total = len(self._image_data)
263
+ for i in range(total):
264
+ self.root.after(0, self._set_status, f"Processing {i+1}/{total}...")
265
+ self._process_single_image(i)
266
+ self.root.after(0, lambda v=i+1: self.progress.configure(value=v))
267
+ self.root.after(0, self._on_batch_done)
268
+
269
+ def _process_single_image(self, idx):
270
+ data = self._image_data[idx]
271
+ try:
272
+ # 1. CROP (Face Detection) - Loads full resolution from disk
273
+ temp_crop = f"temp_{idx}_{int(time.time())}.jpg"
274
+ if not data["crop_rect"]:
275
+ data["crop_rect"] = crop.get_auto_crop_rect(data["path"])
276
+
277
+ if not crop.apply_custom_crop(data["path"], temp_crop, data["crop_rect"]):
278
+ raise Exception("Crop failed")
279
+
280
+ cropped_img = Image.open(temp_crop)
281
+
282
+ # 2. BACKGROUND REMOVAL (AI)
283
+ trans = process_images.remove_background(self.model, cropped_img, self.transform)
284
+
285
+ # 3. COLOR GRADING
286
+ graded = color_steal.apply_to_image(self.luts, trans) if self.luts else trans
287
+
288
+ # 4. FINAL LAYOUT
289
+ data["result"] = generate_layout(graded, data["name"], data["id"])
290
+
291
+ if os.path.exists(temp_crop): os.remove(temp_crop)
292
+
293
+ except Exception as e:
294
+ print(f"Error processing index {idx}: {e}")
295
+ data["result"] = None
296
+
297
+ def _on_batch_done(self):
298
+ self._phase = "preview"
299
+ self.btn_open.config(state=tk.NORMAL)
300
+ self.btn_process.config(state=tk.NORMAL)
301
+ for i, d in enumerate(self._image_data):
302
+ if d["result"]:
303
+ self._current_index = i
304
+ break
305
+ self._update_ui_state()
306
+ self._load_current_fields()
307
+ self._update_nav()
308
+ self._show_current()
309
+ self._set_status("Processing complete.")
310
+
311
+ def _edit_crop(self):
312
+ data = self._image_data[self._current_index]
313
+ if not data["crop_rect"]:
314
+ data["crop_rect"] = crop.get_auto_crop_rect(data["path"])
315
+
316
+ def on_apply(new_rect):
317
+ data["crop_rect"] = new_rect
318
+ self._set_status("Reprocessing image...")
319
+ threading.Thread(target=self._reprocess_current, daemon=True).start()
320
+
321
+ CropDialog(self.root, data["path"], data["crop_rect"], on_apply)
322
+
323
+ def _reprocess_current(self):
324
+ self._process_single_image(self._current_index)
325
+ self.root.after(0, self._show_current)
326
+ self.root.after(0, self._set_status, "Reprocess Done.")
327
+
328
+ # โ”€โ”€ UI Sync โ”€โ”€
329
+
330
+ def _update_ui_state(self):
331
+ if not self._image_data:
332
+ self.btn_process.config(state=tk.DISABLED); self.btn_save.config(state=tk.DISABLED); self.btn_edit_crop.config(state=tk.DISABLED)
333
+ return
334
+ self.btn_process.config(state=tk.NORMAL)
335
+ self.btn_save.config(state=tk.NORMAL if self._phase == "preview" else tk.DISABLED)
336
+ self.btn_edit_crop.config(state=tk.NORMAL)
337
+
338
+ def _show_current(self):
339
+ if not self._image_data: return
340
+ data = self._image_data[self._current_index]
341
+
342
+ if self._phase == "preview" and data["result"]:
343
+ img = data["result"]
344
+ else:
345
+ # Use thumbnail for navigation preview to stay memory-efficient
346
+ img = data["thumb"]
347
+
348
+ self._show_image_on_canvas(img)
349
+
350
+ def _show_image_on_canvas(self, img):
351
+ self.canvas.update_idletasks()
352
+ cw, ch = self.canvas.winfo_width(), self.canvas.winfo_height()
353
+ if cw < 10 or ch < 10: return
354
+
355
+ iw, ih = img.size
356
+ scale = min(cw/iw, ch/ih, 1.0)
357
+ preview = img.resize((int(iw*scale), int(ih*scale)), Image.LANCZOS)
358
+ self.tk_preview = ImageTk.PhotoImage(preview)
359
+ self.canvas.delete("all")
360
+ self.canvas.create_image(cw//2, ch//2, image=self.tk_preview, anchor=tk.CENTER)
361
+
362
+ def _save_current_fields(self):
363
+ if not self._image_data: return
364
+ data = self._image_data[self._current_index]
365
+ data["name"] = self.entry_name.get().strip()
366
+ data["id"] = self.entry_id.get().strip()
367
+
368
+ def _load_current_fields(self):
369
+ if not self._image_data: return
370
+ data = self._image_data[self._current_index]
371
+ self.entry_name.delete(0, tk.END); self.entry_name.insert(0, data["name"])
372
+ self.entry_id.delete(0, tk.END); self.entry_id.insert(0, data["id"])
373
+
374
+ def _prev_image(self):
375
+ if self._current_index > 0:
376
+ self._save_current_fields(); self._current_index -= 1
377
+ self._load_current_fields(); self._update_nav(); self._show_current()
378
+
379
+ def _next_image(self):
380
+ if self._current_index < len(self._image_data) - 1:
381
+ self._save_current_fields(); self._current_index += 1
382
+ self._load_current_fields(); self._update_nav(); self._show_current()
383
+
384
+ def _update_nav(self):
385
+ n = len(self._image_data)
386
+ self.btn_prev.config(state=tk.NORMAL if self._current_index > 0 else tk.DISABLED)
387
+ self.btn_next.config(state=tk.NORMAL if self._current_index < n-1 else tk.DISABLED)
388
+ self.lbl_counter.config(text=f"{self._current_index+1} / {n}" if n else "")
389
+
390
+ def _save_all(self):
391
+ results = [d for d in self._image_data if d["result"]]
392
+ if not results: return
393
+ folder = filedialog.askdirectory(title="Select Save Folder")
394
+ if not folder: return
395
+ count = 0
396
+ for d in results:
397
+ try:
398
+ name = Path(d["path"]).stem + "_layout.jpg"
399
+ d["result"].save(os.path.join(folder, name), "JPEG", quality=95, dpi=(300, 300))
400
+ count += 1
401
+ except: pass
402
+ messagebox.showinfo("Success", f"Saved {count} layouts.")
403
+
404
+ def _on_resize(self, event):
405
+ if self._image_data: self._show_current()
406
+
407
+ def _set_status(self, msg):
408
+ self.status.config(text=msg); self.root.update_idletasks()
409
+
410
+
411
+ if __name__ == "__main__":
412
+ root = tk.Tk()
413
+ w, h = 1100, 950
414
+ root.geometry(f"{w}x{h}+{(root.winfo_screenwidth()-w)//2}+{(root.winfo_screenheight()-h)//2}")
415
+ StudioApp(root); root.mainloop()
gui/main.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ EL HELAL Studio โ€” Photo Layout Generator
3
+ =========================================
4
+ Run this file to launch the desktop application.
5
+
6
+ Usage:
7
+ python main.py
8
+ """
9
+
10
+ import tkinter as tk
11
+ from gui import StudioApp
12
+
13
+
14
+ def main():
15
+ root = tk.Tk()
16
+
17
+ # Centre window on screen
18
+ root.update_idletasks()
19
+ sw = root.winfo_screenwidth()
20
+ sh = root.winfo_screenheight()
21
+ w, h = 900, 1000
22
+ x = (sw - w) // 2
23
+ y = max((sh - h) // 2 - 30, 0)
24
+ root.geometry(f"{w}x{h}+{x}+{y}")
25
+
26
+ app = StudioApp(root) # noqa: F841
27
+ root.mainloop()
28
+
29
+
30
+ if __name__ == "__main__":
31
+ main()
requirements.txt ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Pillow>=10.0.0
2
+ arabic-reshaper
3
+ python-bidi
4
+ opencv-python
5
+ numpy
6
+ torch
7
+ torchvision
8
+ transformers==4.48.2
9
+ scipy
10
+ mediapipe==0.10.9
11
+ devicetorch
12
+ timm
13
+ kornia
14
+ accelerate
15
+ fastapi
16
+ uvicorn
17
+ python-multipart
18
+ jinja2
tools/problems.md ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Resolved Technical Issues & Deployment Guide
2
+
3
+ This document tracks critical problems encountered during development and deployment (especially for Docker and Hugging Face Spaces) and their corresponding solutions.
4
+
5
+ ## 1. Font Rendering & Layout Consistency
6
+
7
+ ### Problem: "Boxes" instead of Arabic Text
8
+ - **Symptom**: Arabic names appeared as empty boxes (tofu) or incorrectly rendered characters.
9
+ - **Cause**: The system was attempting to load Windows-specific font paths (e.g., `C:/Windows/Fonts/...`) which do not exist in Linux/Docker environments.
10
+ - **Solution**:
11
+ - Implemented platform-agnostic font discovery in `core/layout_engine.py`.
12
+ - Added automatic detection of bundled fonts in the `assets/` directory.
13
+ - Prioritized `arialbd.ttf` for Arabic support over legacy fonts.
14
+
15
+ ### Problem: Settings Changes Not Reflecting
16
+ - **Symptom**: Changing `id_font_size` in `settings.json` had no effect until the app was restarted.
17
+ - **Cause**: Settings were loaded once at module import time and cached as global constants.
18
+ - **Solution**: Modified `generate_layout` to call `load_settings()` at the start of every execution, ensuring real-time updates from the JSON file.
19
+
20
+ ---
21
+
22
+ ## 2. Hugging Face Spaces (Docker) Deployment
23
+
24
+ ### Problem: Obsolete `libgl1-mesa-glx`
25
+ - **Symptom**: Docker build failed with `E: Package 'libgl1-mesa-glx' has no installation candidate`.
26
+ - **Cause**: The base Debian image (Trixie/Sid) used by HF has obsoleted this package.
27
+ - **Solution**: Updated `Dockerfile` to use `libgl1` which provides the necessary OpenGL libraries for OpenCV.
28
+
29
+ ### Problem: Transformers 4.50+ Compatibility (BiRefNet/RMBG-2.0)
30
+ - **Symptom**: `AttributeError: 'BiRefNet' object has no attribute 'all_tied_weights_keys'` or `'NoneType' object has no attribute 'keys'`.
31
+ - **Cause**: The custom model code for BiRefNet/RMBG-2.0 is incompatible with internal changes in recent `transformers` versions regarding tied weight tracking.
32
+ - **Solution**:
33
+ - Applied a robust monkeypatch in `core/process_images.py` to the `PreTrainedModel` class.
34
+ - Forced `all_tied_weights_keys` to always return an empty dictionary `{}` instead of `None`.
35
+ - Pinned `transformers==4.48.2` in `requirements.txt` for a stable environment.
36
+
37
+ ### Problem: Meta-Tensor Initialization Bug
38
+ - **Symptom**: Model failed to load on CPU due to "meta tensors" not being correctly materialized.
39
+ - **Cause**: A bug in how `torch.linspace` interacts with transformers' `low_cpu_mem_usage` flag for custom models.
40
+ - **Solution**: Monkeypatched `torch.linspace` in `core/process_images.py` to force materialization on CPU when a meta-tensor is detected.
41
+
42
+ ---
43
+
44
+ ## 3. Git & LFS Management
45
+
46
+ ### Problem: Binary File Rejection
47
+ - **Symptom**: `remote: Your push was rejected because it contains binary files.`
48
+ - **Cause**: Pushing large `.jpg`, `.png`, or `.cube` files directly to Hugging Face without Git LFS, or having them in the Git history from previous commits.
49
+ - **Solution**:
50
+ - Configured `.gitattributes` to track `*.png`, `*.TTF`, `*.npz`, and `*.cube` with LFS.
51
+ - Used `git filter-branch` to purge large binaries (`raw/`, `white/`, and root `.jpg` files) from the entire Git history to reduce repo size and satisfy HF hooks.
52
+
53
+ ---
54
+
55
+ ## 5. Image Processing Pipeline & Dependencies
56
+
57
+ ### Problem: `KeyError: 'setting text direction... not supported without libraqm'`
58
+ - **Symptom**: Application crashes on Windows when attempting to render Arabic text in the layout.
59
+ - **Cause**: Pillow's `direction` and `features` parameters require the `libraqm` library, which is difficult to install on Windows.
60
+ - **Solution**:
61
+ - Added a safety check using `PIL.features.check("raqm")`.
62
+ - Implemented a fallback that relies on `arabic-reshaper` and `python-bidi` for manual shaping/reordering when `raqm` is missing.
63
+
64
+ ### Problem: Background Removal failing when Retouching is enabled
65
+ - **Symptom**: Background removal appeared "ignored" or reverted to original background after processing.
66
+ - **Cause**: The `retouch_image_pil` function in `core/retouch.py` was converting the image to RGB for OpenCV processing, stripping the Alpha channel (transparency) created by the BG removal step.
67
+ - **Solution**:
68
+ - Updated `retouch_image_pil` to detect and save the Alpha channel before processing.
69
+ - Modified the logic to restore the Alpha channel to the final retouched PIL image before returning it to the pipeline.
70
+
71
+ ### Problem: BiRefNet Model Inference Error
72
+ - **Symptom**: `TypeError` or indexing errors during background removal inference.
73
+ - **Cause**: Inconsistent model output formats (list of tensors vs. a single tensor) depending on the environment or `transformers` version.
74
+ - **Solution**: Updated `remove_background` in `core/process_images.py` to check if output is a list/tuple and handle both cases robustly.
75
+
76
+ ### Problem: `AttributeError: module 'mediapipe' has no attribute 'solutions'`
77
+ - **Symptom**: Skin retouching fails in the Docker container with this error.
78
+ - **Cause**: Inconsistent behavior of the `mediapipe` package initialization in some Linux environments.
79
+ - **Solution**:
80
+ - Explicitly imported submodules like `mediapipe.solutions.face_mesh` at the top of the file.
81
+ - Switched from `python:3.10-slim` to the full `python:3.10` image to ensure a complete build environment.
82
+ - Added `libprotobuf-dev` and `protobuf-compiler` to the `Dockerfile`.
83
+
84
+ ### Problem: Arabic/English Text appearing as "Boxes" (Tofu)
85
+ - **Symptom**: All text on the print layout appears as empty squares in the Hugging Face Space.
86
+ - **Cause**: The container lacked fonts with proper character support, and binary `.ttf` files were often corrupted as 130-byte Git LFS pointers.
87
+ - **Solution**:
88
+ - **Automated Downloads**: Updated `Dockerfile` to use `wget` to pull real binary fonts directly from GitHub during the build.
89
+ - **Deep Search**: Implemented a recursive font discovery system in `core/layout_engine.py` that scans `/usr/share/fonts`.
90
+ - **System Fallbacks**: Installed `fonts-noto-extra` and `fonts-dejavu-core` as guaranteed system-level backups.
91
+
92
+ ### Problem: Arabic Text appearing "Connected but Reversed"
93
+ - **Symptom**: Arabic letters connect correctly but flow from Left-to-Right (e.g., "ู… ุญ ู… ุฏ" instead of "ู…ุญู…ุฏ").
94
+ - **Cause**: The BiDi reordering algorithm was inconsistent in the LTR drawing environment of the container.
95
+ - **Solution**:
96
+ - **Manual Reversal**: After using `arabic_reshaper` to handle ligatures, the code now manually reverses the string characters (`"".join(reversed(text))`).
97
+ - This bypasses the need for the `libraqm` library and ensures perfect visual flow on standard LTR drawing canvases.
98
+
99
+ ### Problem: Manual Cropping ignored or shifted
100
+ - **Symptom**: After manually adjusting the crop in the web interface, the result still looked like the AI auto-crop or was completely wrong.
101
+ - **Cause**: The backend was using `cv2.imdecode` which ignores EXIF orientation tags. Since the frontend cropper works on correctly oriented thumbnails, the coordinates sent to the backend didn't match the raw image orientation on disk.
102
+ - **Solution**: Updated `core/crop.py` to use a `_load_image_exif_safe` helper that uses PIL to transpose the image before converting it to OpenCV format. This ensures coordinates from the web UI always match the backend image state.
tools/scan_fonts.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import os
3
+ import sys
4
+ from PIL import Image, ImageFont, ImageDraw
5
+
6
+ def list_fonts():
7
+ print("--- FONT SCANNER ---")
8
+ paths = [
9
+ "/usr/share/fonts",
10
+ "/usr/share/fonts/truetype",
11
+ "/usr/share/fonts/truetype/noto",
12
+ "/usr/share/fonts/truetype/dejavu",
13
+ "/usr/share/fonts/truetype/liberation",
14
+ "assets"
15
+ ]
16
+
17
+ for p in paths:
18
+ if os.path.exists(p):
19
+ print(f"
20
+ Directory: {p}")
21
+ files = [f for f in os.listdir(p) if f.lower().endswith(('.ttf', '.otf'))]
22
+ for f in sorted(files):
23
+ full_path = os.path.join(p, f)
24
+ size = os.path.getsize(full_path)
25
+ print(f" - {f} ({size} bytes)")
26
+ else:
27
+ print(f"
28
+ Directory NOT FOUND: {p}")
29
+
30
+ if __name__ == "__main__":
31
+ list_fonts()
tools/verify_layout.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import os
3
+ import sys
4
+ from PIL import Image
5
+
6
+ # Add root directory to python path
7
+ sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
8
+
9
+ from core.layout_engine import generate_layout
10
+
11
+ def test_layout():
12
+ print("Starting layout verification...")
13
+
14
+ # Create a dummy input image
15
+ dummy_input = Image.new("RGB", (600, 800), (200, 200, 200))
16
+
17
+ try:
18
+ # Generate the layout with dummy data
19
+ print("Generating layout...")
20
+ result = generate_layout(
21
+ dummy_input,
22
+ person_name="ู…ุญู…ุฏ ุฃุญู…ุฏ ุงุณู…ุงุนูŠู„ ",
23
+ id_number="1234567"
24
+ )
25
+
26
+ # Save the result
27
+ output_path = "layout_verification_result.jpg"
28
+ result.save(output_path, quality=95)
29
+
30
+ print(f"Success! Layout generated and saved to: {output_path}")
31
+ print(f"Current Settings Used: ID Font Size = {generate_layout.__globals__['S']['overlays']['id_font_size']}")
32
+
33
+ except Exception as e:
34
+ print(f"ERROR: {e}")
35
+
36
+ if __name__ == "__main__":
37
+ test_layout()
web/server.py ADDED
@@ -0,0 +1,291 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ EL HELAL Studio โ€” Web Backend (FastAPI)
3
+ Integrated with Auto-Cleanup and Custom Cropping
4
+ """
5
+
6
+ from fastapi import FastAPI, UploadFile, File, Form, BackgroundTasks
7
+ from fastapi.responses import JSONResponse, FileResponse
8
+ from fastapi.staticfiles import StaticFiles
9
+ from fastapi.middleware.cors import CORSMiddleware
10
+ from contextlib import asynccontextmanager
11
+ import uvicorn
12
+ import shutil
13
+ import os
14
+ import json
15
+ import uuid
16
+ from pathlib import Path
17
+ from PIL import Image
18
+ import threading
19
+ import sys
20
+ import asyncio
21
+ import time
22
+
23
+ # Add core directory to python path
24
+ sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'core')))
25
+
26
+ # Import existing tools
27
+ import crop
28
+ import process_images
29
+ import color_steal
30
+ import retouch
31
+ from layout_engine import generate_layout, load_settings
32
+
33
+ # Setup Directories
34
+ WEB_DIR = Path(os.path.dirname(__file__)) / "web_storage"
35
+ ROOT_DIR = Path(os.path.dirname(__file__)).parent
36
+ STORAGE_DIR = ROOT_DIR / "storage"
37
+
38
+ UPLOAD_DIR = STORAGE_DIR / "uploads"
39
+ PROCESSED_DIR = STORAGE_DIR / "processed"
40
+ RESULT_DIR = STORAGE_DIR / "results"
41
+
42
+ for d in [UPLOAD_DIR, PROCESSED_DIR, RESULT_DIR]:
43
+ d.mkdir(parents=True, exist_ok=True)
44
+
45
+ # Global Model State
46
+ models = {
47
+ "model": None,
48
+ "transform": None,
49
+ "luts": color_steal.load_trained_curves(),
50
+ "ready": False
51
+ }
52
+
53
+ def warm_up_ai():
54
+ print("AI Model: Loading in background...")
55
+ try:
56
+ models["model"], _ = process_images.setup_model()
57
+ models["transform"] = process_images.get_transform()
58
+ models["ready"] = True
59
+ print("AI Model: READY")
60
+ except Exception as e:
61
+ print(f"AI Model: FAILED to load - {e}")
62
+
63
+ async def cleanup_task():
64
+ """Background task to delete files older than 24 hours."""
65
+ while True:
66
+ print("Cleanup: Checking for old files...")
67
+ now = time.time()
68
+ count = 0
69
+ for folder in [UPLOAD_DIR, PROCESSED_DIR, RESULT_DIR]:
70
+ for path in folder.glob("*"):
71
+ if path.is_file() and (now - path.stat().st_mtime) > 86400: # 24 hours
72
+ path.unlink()
73
+ count += 1
74
+ if count > 0: print(f"Cleanup: Removed {count} old files.")
75
+ await asyncio.sleep(3600) # Run every hour
76
+
77
+ @asynccontextmanager
78
+ async def lifespan(app: FastAPI):
79
+ # Startup
80
+ threading.Thread(target=warm_up_ai, daemon=True).start()
81
+ asyncio.create_task(cleanup_task())
82
+ yield
83
+ # Shutdown
84
+ pass
85
+
86
+ app = FastAPI(title="EL HELAL Studio API", lifespan=lifespan)
87
+
88
+ app.add_middleware(
89
+ CORSMiddleware,
90
+ allow_origins=["*"],
91
+ allow_methods=["*"],
92
+ allow_headers=["*"],
93
+ )
94
+
95
+ # โ”€โ”€ API Endpoints โ”€โ”€
96
+
97
+ @app.get("/")
98
+ async def read_index():
99
+ return FileResponse(WEB_DIR / "index.html")
100
+
101
+ @app.get("/status")
102
+ async def get_status():
103
+ return {"ai_ready": models["ready"]}
104
+
105
+ @app.post("/upload")
106
+ async def upload_image(file: UploadFile = File(...)):
107
+ file_id = str(uuid.uuid4())
108
+ ext = Path(file.filename).suffix
109
+ file_path = UPLOAD_DIR / f"{file_id}{ext}"
110
+
111
+ with file_path.open("wb") as buffer:
112
+ shutil.copyfileobj(file.file, buffer)
113
+
114
+ with Image.open(file_path) as img:
115
+ from PIL import ImageOps
116
+ # FIX: Handle EXIF orientation (rotation)
117
+ img = ImageOps.exif_transpose(img)
118
+
119
+ # Get original dimensions after transposition for the web cropper
120
+ width, height = img.size
121
+
122
+ img.thumbnail((800, 800), Image.LANCZOS)
123
+ thumb_path = UPLOAD_DIR / f"{file_id}_thumb.jpg"
124
+ if img.mode in ("RGBA", "LA"):
125
+ bg = Image.new("RGB", img.size, (255, 255, 255))
126
+ bg.paste(img, mask=img.split()[-1])
127
+ bg.save(thumb_path, "JPEG")
128
+ else:
129
+ img.convert("RGB").save(thumb_path, "JPEG")
130
+
131
+ return {
132
+ "id": file_id,
133
+ "filename": file.filename,
134
+ "thumb_url": f"/static/uploads/{file_id}_thumb.jpg",
135
+ "width": width,
136
+ "height": height
137
+ }
138
+
139
+ @app.post("/process/{file_id}")
140
+ async def process_image(
141
+ file_id: str,
142
+ name: str = Form(""),
143
+ id_number: str = Form(""),
144
+ # Steps toggles
145
+ do_rmbg: bool = Form(True),
146
+ do_color: bool = Form(True),
147
+ do_retouch: bool = Form(True),
148
+ do_crop: bool = Form(True),
149
+ # Branding toggles
150
+ add_studio_name: bool = Form(True),
151
+ add_logo: bool = Form(True),
152
+ add_date: bool = Form(True),
153
+ # Optional manual crop coordinates
154
+ x1: int = Form(None),
155
+ y1: int = Form(None),
156
+ x2: int = Form(None),
157
+ y2: int = Form(None)
158
+ ):
159
+ if not models["ready"]:
160
+ return JSONResponse(status_code=503, content={"error": "AI Model not ready"})
161
+
162
+ files = list(UPLOAD_DIR.glob(f"{file_id}.*"))
163
+ if not files: return JSONResponse(status_code=404, content={"error": "File not found"})
164
+ orig_path = files[0]
165
+
166
+ try:
167
+ temp_crop = PROCESSED_DIR / f"{file_id}_processed_crop.jpg"
168
+
169
+ # 1. CROP (Manual, Auto, or Skip)
170
+ if x1 is not None and y1 is not None:
171
+ print(f"Pipeline: Applying manual crop for {file_id} | Rect: ({x1}, {y1}, {x2}, {y2})")
172
+ rect = (x1, y1, x2, y2)
173
+ crop.apply_custom_crop(str(orig_path), str(temp_crop), rect)
174
+ cropped_img = Image.open(temp_crop)
175
+ elif do_crop:
176
+ print(f"Pipeline: Applying auto crop for {file_id}...")
177
+ crop.crop_to_4x6_opencv(str(orig_path), str(temp_crop))
178
+ cropped_img = Image.open(temp_crop)
179
+ else:
180
+ print(f"Pipeline: Skipping crop for {file_id}")
181
+ cropped_img = Image.open(orig_path)
182
+
183
+ # 2. BACKGROUND REMOVAL
184
+ if do_rmbg:
185
+ print(f"Pipeline: Removing background for {file_id}...")
186
+ processed_img = process_images.remove_background(models["model"], cropped_img, models["transform"])
187
+ print(f"Pipeline: BG Removal Done. Image Mode: {processed_img.mode}")
188
+ else:
189
+ print(f"Pipeline: Skipping background removal for {file_id}")
190
+ processed_img = cropped_img
191
+
192
+ # 3. COLOR GRADING
193
+ if do_color and models["luts"]:
194
+ print(f"Pipeline: Applying color grading for {file_id}...")
195
+ graded_img = color_steal.apply_to_image(models["luts"], processed_img)
196
+ print(f"Pipeline: Color Grading Done. Image Mode: {graded_img.mode}")
197
+ else:
198
+ print(f"Pipeline: Skipping color grading for {file_id}")
199
+ graded_img = processed_img
200
+
201
+ # 4. RETOUCH
202
+ current_settings = load_settings()
203
+ # Retouch happens if BOTH the UI checkbox is checked AND it's enabled in global settings
204
+ if do_retouch and current_settings.get("retouch", {}).get("enabled", False):
205
+ retouch_cfg = current_settings["retouch"]
206
+ print(f"Pipeline: Retouching face for {file_id} (Sensitivity: {retouch_cfg.get('sensitivity', 3.0)})")
207
+ final_processed, count = retouch.retouch_image_pil(
208
+ graded_img,
209
+ sensitivity=retouch_cfg.get("sensitivity", 3.0),
210
+ tone_smoothing=retouch_cfg.get("tone_smoothing", 0.6)
211
+ )
212
+ print(f"Pipeline: Retouch Done. Blemishes: {count}. Image Mode: {final_processed.mode}")
213
+ else:
214
+ print(f"Pipeline: Retouching skipped for {file_id}")
215
+ final_processed = graded_img
216
+
217
+ print(f"Pipeline: Generating final layout for {file_id}...")
218
+ final_layout = generate_layout(
219
+ final_processed, name, id_number,
220
+ add_studio_name=add_studio_name,
221
+ add_logo=add_logo,
222
+ add_date=add_date
223
+ )
224
+
225
+ result_path = RESULT_DIR / f"{file_id}_layout.jpg"
226
+ final_layout.save(result_path, "JPEG", quality=95, dpi=(300, 300))
227
+
228
+ if temp_crop.exists(): temp_crop.unlink()
229
+
230
+ return {"id": file_id, "result_url": f"/static/results/{file_id}_layout.jpg"}
231
+
232
+ except Exception as e:
233
+ import traceback
234
+ traceback.print_exc()
235
+ return JSONResponse(status_code=500, content={"error": str(e)})
236
+
237
+ @app.post("/clear-all")
238
+ async def clear_all():
239
+ """Manually clear all uploaded and processed files."""
240
+ count = 0
241
+ try:
242
+ for folder in [UPLOAD_DIR, PROCESSED_DIR, RESULT_DIR]:
243
+ for path in folder.glob("*"):
244
+ if path.is_file() and not path.name.endswith(".gitkeep"):
245
+ path.unlink()
246
+ count += 1
247
+ return {"status": "success", "removed_count": count}
248
+ except Exception as e:
249
+ return JSONResponse(status_code=500, content={"error": f"Failed to clear storage: {str(e)}"})
250
+
251
+ # โ”€โ”€ Settings API โ”€โ”€
252
+ SETTINGS_PATH = ROOT_DIR / "config" / "settings.json"
253
+
254
+ @app.get("/settings")
255
+ async def get_settings():
256
+ """Return current settings.json contents."""
257
+ try:
258
+ if SETTINGS_PATH.exists():
259
+ with open(SETTINGS_PATH, "r") as f:
260
+ return json.load(f)
261
+ return {}
262
+ except Exception as e:
263
+ return JSONResponse(status_code=500, content={"error": str(e)})
264
+
265
+ @app.post("/settings")
266
+ async def update_settings(data: dict):
267
+ """Merge incoming settings into settings.json (partial update)."""
268
+ try:
269
+ current = {}
270
+ if SETTINGS_PATH.exists():
271
+ with open(SETTINGS_PATH, "r") as f:
272
+ current = json.load(f)
273
+ # Deep merge one level
274
+ for key, val in data.items():
275
+ if key in current and isinstance(val, dict) and isinstance(current[key], dict):
276
+ current[key].update(val)
277
+ else:
278
+ current[key] = val
279
+ SETTINGS_PATH.parent.mkdir(parents=True, exist_ok=True)
280
+ with open(SETTINGS_PATH, "w") as f:
281
+ json.dump(current, f, indent=4, ensure_ascii=False)
282
+ return {"status": "success"}
283
+ except Exception as e:
284
+ return JSONResponse(status_code=500, content={"error": str(e)})
285
+
286
+ app.mount("/static", StaticFiles(directory=str(STORAGE_DIR)), name="static")
287
+
288
+ if __name__ == "__main__":
289
+ # Hugging Face Spaces uses port 7860 by default
290
+ port = int(os.environ.get("PORT", 7860))
291
+ uvicorn.run(app, host="0.0.0.0", port=port)
web/web_storage/index.html ADDED
@@ -0,0 +1,1200 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="ar" dir="rtl" data-theme="dark">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>ุณุชูˆุฏูŠูˆ ุงู„ู‡ู„ุงู„ โ€” ู†ุธุงู… ู…ุนุงู„ุฌุฉ ุงู„ุตูˆุฑ</title>
7
+ <script src="https://cdn.tailwindcss.com"></script>
8
+ <script>
9
+ tailwind.config = {
10
+ darkMode: ['selector', '[data-theme="dark"]'],
11
+ }
12
+ </script>
13
+ <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
14
+ <!-- Cropper.js -->
15
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.5.13/cropper.min.css">
16
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.5.13/cropper.min.js"></script>
17
+ <!-- JSZip for Download All -->
18
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
19
+ <style>
20
+ @import url('https://fonts.googleapis.com/css2?family=Cairo:wght@300;400;600;700&display=swap');
21
+
22
+ /* โ”€โ”€ Theme Variables โ”€โ”€ */
23
+ :root {
24
+ --bg-body: #f1f5f9;
25
+ --bg-card: #ffffff;
26
+ --bg-card-alt: #f8fafc;
27
+ --bg-input: #f1f5f9;
28
+ --border-card: #e2e8f0;
29
+ --border-input: #cbd5e1;
30
+ --text-primary: #0f172a;
31
+ --text-secondary: #475569;
32
+ --text-muted: #94a3b8;
33
+ --scrollbar-track: #f1f5f9;
34
+ --scrollbar-thumb: #cbd5e1;
35
+ --overlay-bg: rgba(255,255,255,0.92);
36
+ }
37
+ [data-theme="dark"] {
38
+ --bg-body: #0f172a;
39
+ --bg-card: #1e293b;
40
+ --bg-card-alt: #0f172a;
41
+ --bg-input: #0f172a;
42
+ --border-card: #334155;
43
+ --border-input: #334155;
44
+ --text-primary: #f8fafc;
45
+ --text-secondary: #94a3b8;
46
+ --text-muted: #475569;
47
+ --scrollbar-track: #0f172a;
48
+ --scrollbar-thumb: #334155;
49
+ --overlay-bg: rgba(15,23,42,0.92);
50
+ }
51
+
52
+ body {
53
+ font-family: 'Cairo', sans-serif;
54
+ background-color: var(--bg-body);
55
+ color: var(--text-primary);
56
+ transition: background-color 0.3s, color 0.3s;
57
+ }
58
+ .studio-card {
59
+ background: var(--bg-card);
60
+ border: 1px solid var(--border-card);
61
+ transition: background-color 0.3s, border-color 0.3s;
62
+ }
63
+ .studio-input {
64
+ background: var(--bg-input);
65
+ border-color: var(--border-input);
66
+ color: var(--text-primary);
67
+ }
68
+ .text-theme-primary { color: var(--text-primary); }
69
+ .text-theme-secondary { color: var(--text-secondary); }
70
+ .text-theme-muted { color: var(--text-muted); }
71
+
72
+ .gradient-text { background: linear-gradient(to left, #fbbf24, #f59e0b); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
73
+ .cropper-view-box, .cropper-face { border-radius: 4px; outline: 2px solid #fbbf24; }
74
+ ::-webkit-scrollbar { width: 6px; }
75
+ ::-webkit-scrollbar-track { background: var(--scrollbar-track); }
76
+ ::-webkit-scrollbar-thumb { background: var(--scrollbar-thumb); border-radius: 10px; }
77
+
78
+ /* Indicators & Glow */
79
+ .badge { font-size: 8px; padding: 1px 4px; border-radius: 3px; font-weight: 800; text-transform: uppercase; }
80
+ .badge-rmbg { background: #10b981; color: #064e3b; }
81
+ .badge-retouch { background: #3b82f6; color: #1e3a8a; }
82
+ .badge-crop { background: #f59e0b; color: #78350f; }
83
+ .badge-color { background: #a855f7; color: #4c1d95; }
84
+ .badge-quality { font-size: 7px; padding: 1px 3px; border-radius: 2px; font-weight: 700; }
85
+ .badge-hd { background: #10b981; color: white; }
86
+ .badge-lowres { background: #ef4444; color: white; }
87
+ .badge-mid { background: #f59e0b; color: white; }
88
+
89
+ .drop-glow { border-color: #fbbf24 !important; box-shadow: 0 0 20px rgba(251, 191, 36, 0.3); }
90
+
91
+ /* Skeleton Animation */
92
+ @keyframes shimmer {
93
+ 0% { background-position: -468px 0 }
94
+ 100% { background-position: 468px 0 }
95
+ }
96
+ .skeleton {
97
+ background: var(--bg-card);
98
+ background-image: linear-gradient(to right, var(--bg-card) 0%, var(--border-card) 20%, var(--bg-card) 40%, var(--bg-card) 100%);
99
+ background-repeat: no-repeat;
100
+ background-size: 800px 104px;
101
+ animation: shimmer 1.5s infinite linear;
102
+ }
103
+
104
+ .preview-transition { transition: opacity 0.3s ease-in-out, transform 0.3s ease-in-out; }
105
+ .processing-spin { animation: spin 2s linear infinite; }
106
+ @keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
107
+
108
+ /* โ”€โ”€ Toast Notifications โ”€โ”€ */
109
+ #toast-container {
110
+ position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
111
+ z-index: 9999; display: flex; flex-direction: column-reverse; gap: 8px; align-items: center;
112
+ pointer-events: none;
113
+ }
114
+ .toast {
115
+ pointer-events: auto;
116
+ padding: 12px 24px; border-radius: 12px; font-size: 14px; font-weight: 600;
117
+ display: flex; align-items: center; gap: 10px;
118
+ box-shadow: 0 8px 30px rgba(0,0,0,0.2);
119
+ animation: toastIn 0.35s ease-out;
120
+ max-width: 420px;
121
+ }
122
+ .toast-success { background: #065f46; color: #d1fae5; }
123
+ .toast-error { background: #7f1d1d; color: #fecaca; }
124
+ .toast-info { background: #1e3a5f; color: #bfdbfe; }
125
+ .toast-warning { background: #78350f; color: #fef3c7; }
126
+ @keyframes toastIn { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } }
127
+ @keyframes toastOut { from { opacity: 1; transform: translateY(0); } to { opacity: 0; transform: translateY(20px); } }
128
+
129
+ /* โ”€โ”€ Upload Progress โ”€โ”€ */
130
+ .upload-progress-bar {
131
+ height: 4px; border-radius: 2px; background: var(--border-card); overflow: hidden; margin-top: 8px;
132
+ }
133
+ .upload-progress-fill {
134
+ height: 100%; background: linear-gradient(90deg, #fbbf24, #f59e0b);
135
+ border-radius: 2px; transition: width 0.15s linear; width: 0%;
136
+ }
137
+
138
+ /* โ”€โ”€ Zoom Modal โ”€โ”€ */
139
+ #zoom-modal {
140
+ position: fixed; inset: 0; z-index: 100; display: none;
141
+ background: rgba(0,0,0,0.9); cursor: zoom-out;
142
+ align-items: center; justify-content: center;
143
+ }
144
+ #zoom-modal.active { display: flex; }
145
+ #zoom-modal img {
146
+ max-width: 95vw; max-height: 95vh; object-fit: contain;
147
+ transform-origin: center; transition: transform 0.2s;
148
+ }
149
+
150
+ /* โ”€โ”€ Settings Panel โ”€โ”€ */
151
+ .settings-panel { max-height: 0; overflow: hidden; transition: max-height 0.35s ease-out, opacity 0.25s; opacity: 0; }
152
+ .settings-panel.open { max-height: 500px; opacity: 1; }
153
+
154
+ /* โ”€โ”€ Mobile Drawer โ”€โ”€ */
155
+ .mobile-drawer-overlay {
156
+ position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 80; display: none;
157
+ }
158
+ .mobile-drawer-overlay.active { display: block; }
159
+ .mobile-drawer {
160
+ position: fixed; top: 0; bottom: 0; width: 85%; max-width: 380px;
161
+ z-index: 81; transition: transform 0.3s ease;
162
+ overflow-y: auto; padding: 20px;
163
+ background: var(--bg-card);
164
+ }
165
+ /* RTL: drawer slides from left */
166
+ [dir="rtl"] .mobile-drawer { left: 0; right: auto; transform: translateX(-100%); }
167
+ .mobile-drawer.active { transform: translateX(0) !important; }
168
+
169
+ /* โ”€โ”€ Before/After Toggle โ”€โ”€ */
170
+ .ba-toggle {
171
+ position: absolute; top: 12px; left: 12px; z-index: 10;
172
+ backdrop-filter: blur(8px);
173
+ }
174
+
175
+ /* โ”€โ”€ Empty State Animation โ”€โ”€ */
176
+ @keyframes float { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-10px); } }
177
+ .float-anim { animation: float 3s ease-in-out infinite; }
178
+ @keyframes pointDown { 0%,100% { transform: translateY(0); } 50% { transform: translateY(8px); } }
179
+ .point-anim { animation: pointDown 1.5s ease-in-out infinite; }
180
+
181
+ /* โ”€โ”€ Keyboard Shortcuts Modal โ”€โ”€ */
182
+ #shortcuts-modal {
183
+ position: fixed; inset: 0; z-index: 90; display: none;
184
+ background: rgba(0,0,0,0.6); align-items: center; justify-content: center;
185
+ backdrop-filter: blur(4px);
186
+ }
187
+ #shortcuts-modal.active { display: flex; }
188
+
189
+ /* Horizontal queue slider */
190
+ .queue-slider {
191
+ display: flex; flex-direction: row; gap: 10px;
192
+ flex-wrap: wrap;
193
+ max-height: 340px;
194
+ overflow-y: auto; overflow-x: hidden;
195
+ padding: 8px 4px; scroll-behavior: smooth;
196
+ scrollbar-width: thin;
197
+ }
198
+ .queue-slider::-webkit-scrollbar { width: 5px; }
199
+ .queue-slider::-webkit-scrollbar-track { background: var(--scrollbar-track); border-radius: 10px; }
200
+ .queue-slider::-webkit-scrollbar-thumb { background: var(--scrollbar-thumb); border-radius: 10px; }
201
+ .queue-slider::-webkit-scrollbar-thumb:hover { background: #fbbf24; }
202
+
203
+ .queue-slide {
204
+ flex: 0 0 auto; width: 72px; cursor: pointer;
205
+ position: relative; border-radius: 10px;
206
+ transition: transform 0.15s, box-shadow 0.15s;
207
+ }
208
+ .queue-slide:hover { transform: translateY(-2px); }
209
+ .queue-slide.active {
210
+ box-shadow: 0 0 0 2px #fbbf24;
211
+ transform: translateY(-2px);
212
+ }
213
+ .queue-slide img {
214
+ width: 72px; height: 72px; object-fit: cover;
215
+ border-radius: 10px; display: block;
216
+ border: 1px solid var(--border-card);
217
+ }
218
+ .queue-slide .slide-status {
219
+ position: absolute; top: -4px; right: -4px;
220
+ width: 16px; height: 16px; border-radius: 50%;
221
+ display: flex; align-items: center; justify-content: center;
222
+ font-size: 9px; z-index: 5;
223
+ background: var(--bg-card); box-shadow: 0 1px 3px rgba(0,0,0,0.3);
224
+ }
225
+ .queue-slide .slide-delete {
226
+ position: absolute; top: -4px; left: -4px;
227
+ width: 18px; height: 18px; border-radius: 50%;
228
+ background: #ef4444; color: white;
229
+ display: flex; align-items: center; justify-content: center;
230
+ font-size: 8px; z-index: 5;
231
+ opacity: 0; transition: opacity 0.15s;
232
+ border: none; cursor: pointer;
233
+ }
234
+ .queue-slide:hover .slide-delete { opacity: 1; }
235
+ .queue-slide .slide-name {
236
+ font-size: 9px; text-align: center; margin-top: 4px;
237
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
238
+ color: var(--text-secondary); font-weight: 600;
239
+ max-width: 72px;
240
+ }
241
+ </style>
242
+ </head>
243
+ <body class="min-h-screen p-4 md:p-10">
244
+
245
+ <!-- Toast Container -->
246
+ <div id="toast-container"></div>
247
+
248
+ <!-- Zoom Modal (#14) -->
249
+ <div id="zoom-modal" onclick="closeZoom()">
250
+ <img id="zoom-img" src="" alt="Zoomed preview">
251
+ </div>
252
+
253
+ <!-- Keyboard Shortcuts Modal -->
254
+ <div id="shortcuts-modal" onclick="this.classList.remove('active')">
255
+ <div class="studio-card rounded-2xl p-6 max-w-sm w-full mx-4 shadow-2xl" onclick="event.stopPropagation()">
256
+ <h3 class="font-bold text-lg mb-4 text-right gradient-text">ุงุฎุชุตุงุฑุงุช ู„ูˆุญุฉ ุงู„ู…ูุงุชูŠุญ</h3>
257
+ <div class="space-y-3 text-sm" dir="ltr">
258
+ <div class="flex justify-between"><kbd class="bg-slate-700 text-white px-2 py-1 rounded text-xs">&larr; &rarr;</kbd><span class="text-theme-secondary">Navigate images</span></div>
259
+ <div class="flex justify-between"><kbd class="bg-slate-700 text-white px-2 py-1 rounded text-xs">Delete</kbd><span class="text-theme-secondary">Delete selected</span></div>
260
+ <div class="flex justify-between"><kbd class="bg-slate-700 text-white px-2 py-1 rounded text-xs">Enter</kbd><span class="text-theme-secondary">Save &amp; Next</span></div>
261
+ <div class="flex justify-between"><kbd class="bg-slate-700 text-white px-2 py-1 rounded text-xs">Ctrl+S</kbd><span class="text-theme-secondary">Process current</span></div>
262
+ <div class="flex justify-between"><kbd class="bg-slate-700 text-white px-2 py-1 rounded text-xs">Escape</kbd><span class="text-theme-secondary">Close modals</span></div>
263
+ </div>
264
+ <button onclick="document.getElementById('shortcuts-modal').classList.remove('active')" class="mt-5 w-full py-2 bg-yellow-500 text-slate-900 rounded-lg font-bold">ุญุณู†ุงู‹</button>
265
+ </div>
266
+ </div>
267
+
268
+ <!-- Mobile Drawer Overlay -->
269
+ <div class="mobile-drawer-overlay lg:hidden" id="drawer-overlay" onclick="toggleDrawer(false)"></div>
270
+ <div class="mobile-drawer lg:hidden studio-card" id="mobile-drawer">
271
+ <div class="flex justify-between items-center mb-4">
272
+ <h3 class="font-bold text-sm text-theme-secondary">ู‚ุงุฆู…ุฉ ุงู„ู…ุนุงู„ุฌุฉ</h3>
273
+ <button onclick="toggleDrawer(false)" class="w-8 h-8 rounded-full bg-slate-700 flex items-center justify-center"><i class="fa-solid fa-xmark text-white text-sm"></i></button>
274
+ </div>
275
+ <div id="mobile-image-list" class="flex flex-col gap-3 overflow-y-auto"></div>
276
+ </div>
277
+
278
+ <!-- Header -->
279
+ <header class="max-w-[1400px] mx-auto mb-8 flex flex-col md:flex-row justify-between items-center gap-4">
280
+ <div>
281
+ <h1 class="text-4xl font-bold gradient-text text-right">ุณุชูˆุฏูŠูˆ ุงู„ู‡ู„ุงู„</h1>
282
+ <p class="text-theme-secondary text-right">ุณูŠุฑ ุนู…ู„ ู…ุชูƒุงู…ู„</p>
283
+ </div>
284
+ <div class="flex flex-wrap justify-center md:justify-end gap-2">
285
+ <a href="https://esmailx50-job.hf.space" target="_blank" class="flex items-center gap-2 px-3 py-2 rounded-full bg-indigo-900/30 border border-indigo-500/50 text-sm text-indigo-300 hover:bg-indigo-500 hover:text-white transition-all font-bold">
286
+ <i class="fa-solid fa-wand-magic-sparkles"></i>
287
+ <span class="hidden sm:inline">ุฃุฏุงุฉ ุชุญุณูŠู† ุงู„ุฌูˆุฏุฉ</span>
288
+ </a>
289
+ <a href="https://esmailx50-rmbg2.hf.space" target="_blank" class="flex items-center gap-2 px-3 py-2 rounded-full bg-rose-900/30 border border-rose-500/50 text-sm text-rose-300 hover:bg-rose-500 hover:text-white transition-all font-bold">
290
+ <i class="fa-solid fa-eraser"></i>
291
+ <span class="hidden sm:inline">ุฃุฏุงุฉ ุฅุฒุงู„ุฉ ุงู„ุฎู„ููŠุฉ</span>
292
+ </a>
293
+
294
+ <!-- Theme Toggle (#11) -->
295
+ <button id="theme-toggle" onclick="toggleTheme()" title="ุชุจุฏูŠู„ ุงู„ุณู…ุฉ" class="w-10 h-10 rounded-full bg-slate-800 border border-slate-700 flex items-center justify-center hover:bg-slate-700 transition-all">
296
+ <i class="fa-solid fa-sun text-yellow-400 text-sm" id="theme-icon"></i>
297
+ </button>
298
+
299
+ <!-- Keyboard Shortcuts Hint -->
300
+ <button onclick="document.getElementById('shortcuts-modal').classList.add('active')" title="ุงุฎุชุตุงุฑุงุช ู„ูˆุญุฉ ุงู„ู…ูุงุชูŠุญ" class="w-10 h-10 rounded-full bg-slate-800 border border-slate-700 flex items-center justify-center hover:bg-slate-700 transition-all">
301
+ <i class="fa-regular fa-keyboard text-slate-400 text-sm"></i>
302
+ </button>
303
+
304
+ <div id="ai-status" class="flex items-center gap-2 px-4 py-2 rounded-full bg-slate-800 border border-slate-700 text-sm text-slate-400">
305
+ <div class="w-3 h-3 rounded-full bg-yellow-500 animate-pulse"></div>
306
+ </div>
307
+ </div>
308
+ </header>
309
+
310
+ <main class="max-w-[1400px] mx-auto grid grid-cols-1 lg:grid-cols-12 gap-6 lg:gap-6">
311
+
312
+ <!-- Mobile Queue Toggle Button (#10) -->
313
+ <div class="lg:hidden flex gap-3">
314
+ <button onclick="toggleDrawer(true)" class="flex-1 studio-card rounded-xl p-3 flex items-center justify-center gap-3 active:scale-95 transition-transform">
315
+ <i class="fa-solid fa-list text-yellow-500"></i>
316
+ <span class="font-bold text-sm text-theme-secondary">ู‚ุงุฆู…ุฉ ุงู„ุตูˆุฑ</span>
317
+ <span id="mobile-queue-count" class="text-xs bg-yellow-500 text-slate-900 px-2 py-0.5 rounded-full font-bold">0</span>
318
+ </button>
319
+ </div>
320
+
321
+ <!-- โ”€โ”€ COLUMN 1: QUEUE (RIGHT SIDE) โ”€โ”€ -->
322
+ <div class="hidden lg:flex lg:col-span-3 flex-col gap-4 order-1">
323
+ <div class="studio-card rounded-2xl p-5 text-center border-dashed border-2 hover:border-yellow-500 transition-all cursor-pointer group" style="border-color: var(--border-card);" id="drop-zone">
324
+ <input type="file" id="file-input" multiple class="hidden" accept="image/*">
325
+ <div class="float-anim">
326
+ <i class="fa-solid fa-cloud-arrow-up text-4xl mb-2 text-slate-400 group-hover:text-yellow-500 transition-colors"></i>
327
+ </div>
328
+ <h3 class="font-semibold text-sm text-theme-primary">ุงุฑูุน ุตูˆุฑุฉ</h3>
329
+ <p class="text-theme-muted text-[10px] mt-1">ุฃูˆ ุงุณุญุจ ูˆุฃูู„ุช ู‡ู†ุง</p>
330
+ <div id="upload-progress-wrapper" class="hidden">
331
+ <div class="upload-progress-bar">
332
+ <div id="upload-progress-fill" class="upload-progress-fill"></div>
333
+ </div>
334
+ <p id="upload-progress-text" class="text-xs text-theme-muted mt-1">ุฌุงุฑูŠ ุงู„ุฑูุน...</p>
335
+ </div>
336
+ </div>
337
+
338
+ <div class="studio-card rounded-2xl p-4 flex flex-col">
339
+ <div class="flex justify-between items-center mb-2 px-1">
340
+ <h3 class="font-bold uppercase text-[10px] tracking-widest text-theme-muted">ู‚ุงุฆู…ุฉ ุงู„ู…ุนุงู„ุฌุฉ</h3>
341
+ <span id="queue-count" class="text-[10px] bg-slate-700 text-white px-2 py-0.5 rounded">ู  ุตูˆุฑ</span>
342
+ </div>
343
+ <div id="image-list" class="queue-slider">
344
+ <div class="text-center w-full py-6" id="empty-queue-placeholder">
345
+ <p class="text-theme-muted text-xs font-medium">ุงู„ู‚ุงุฆู…ุฉ ูุงุฑุบุฉ</p>
346
+ </div>
347
+ </div>
348
+ </div>
349
+ </div>
350
+
351
+ <!-- Mobile Drop Zone (shown only on mobile when drawer is closed) -->
352
+ <div class="lg:hidden">
353
+ <div class="studio-card rounded-2xl p-4 text-center border-dashed border-2 hover:border-yellow-500 transition-all cursor-pointer group" style="border-color: var(--border-card);" id="drop-zone-mobile">
354
+ <div class="float-anim inline-block">
355
+ <i class="fa-solid fa-cloud-arrow-up text-3xl text-slate-400 group-hover:text-yellow-500 transition-colors"></i>
356
+ </div>
357
+ <p class="text-sm text-theme-muted mt-2">ุงุถุบุท ู‡ู†ุง ู„ุฑูุน ุงู„ุตูˆุฑ</p>
358
+ <div id="upload-progress-wrapper-mobile" class="hidden">
359
+ <div class="upload-progress-bar">
360
+ <div id="upload-progress-fill-mobile" class="upload-progress-fill"></div>
361
+ </div>
362
+ <p id="upload-progress-text-mobile" class="text-xs text-theme-muted mt-1">ุฌุงุฑูŠ ุงู„ุฑูุน...</p>
363
+ </div>
364
+ </div>
365
+ </div>
366
+
367
+ <!-- โ”€โ”€ COLUMN 2: CENTER (inputs + preview) โ”€โ”€ -->
368
+ <div class="lg:col-span-6 flex flex-col gap-6 order-2">
369
+
370
+ <!-- Data Input Card -->
371
+ <div class="studio-card rounded-2xl p-4 shadow-lg">
372
+ <div class="flex items-center justify-between mb-4 flex-wrap gap-2">
373
+ <div class="flex items-center gap-3">
374
+ <h3 class="font-bold uppercase text-xs tracking-widest text-theme-muted">ุจูŠุงู†ุงุช ุงู„ุทุงู„ุจ</h3>
375
+ <span id="current-filename" class="text-xs font-bold text-theme-secondary truncate max-w-[140px]" style="border-right: 1px solid var(--border-card); padding-right: 12px;"></span>
376
+ </div>
377
+ <!-- Moved navigation here -->
378
+ <div class="flex items-center gap-2" dir="ltr">
379
+ <button onclick="navigate(-1)" class="w-8 h-8 rounded-full bg-slate-800 hover:bg-slate-700 border border-slate-700 flex items-center justify-center transition-all">
380
+ <i class="fa-solid fa-chevron-left text-slate-400 text-xs"></i>
381
+ </button>
382
+ <span id="nav-counter" class="text-xs font-mono font-bold text-yellow-500 mx-1">0/0</span>
383
+ <button onclick="navigate(1)" class="w-8 h-8 rounded-full bg-slate-800 hover:bg-slate-700 border border-slate-700 flex items-center justify-center transition-all">
384
+ <i class="fa-solid fa-chevron-right text-slate-400 text-xs"></i>
385
+ </button>
386
+ </div>
387
+ </div>
388
+
389
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
390
+ <div>
391
+ <label class="block text-xs font-bold text-theme-muted uppercase mb-2 tracking-widest text-right">ุงุณู… ุงู„ุทุงู„ุจ</label>
392
+ <input type="text" id="student-name" dir="rtl" class="w-full studio-input border rounded-lg px-4 py-2.5 focus:ring-2 focus:ring-yellow-500 outline-none transition-all text-right" placeholder="ุงู„ุงุณู… ู‡ู†ุง...">
393
+ </div>
394
+ <div>
395
+ <label class="block text-xs font-bold text-theme-muted uppercase mb-2 tracking-widest text-right">ุงู„ุฑู‚ู… ุงู„ู‚ูˆู…ูŠ</label>
396
+ <input type="text" id="student-id" class="w-full studio-input border rounded-lg px-4 py-2.5 focus:ring-2 focus:ring-yellow-500 outline-none transition-all text-right" placeholder="ูฃู ู ู ...">
397
+ </div>
398
+ </div>
399
+ </div>
400
+
401
+ <!-- Editor/Preview Area -->
402
+ <div class="studio-card rounded-2xl p-2 flex-grow relative min-h-[450px] flex items-center justify-center overflow-hidden" id="main-area">
403
+
404
+ <!-- Before/After Toggle (#3) -->
405
+ <div class="ba-toggle hidden" id="ba-toggle-wrapper">
406
+ <button onclick="toggleBeforeAfter()" id="ba-toggle-btn" class="px-3 py-1.5 rounded-lg text-xs font-bold flex items-center gap-2 transition-all" style="background: rgba(0,0,0,0.6); color: white; border: 1px solid rgba(255,255,255,0.15);">
407
+ <i class="fa-solid fa-eye"></i>
408
+ <span id="ba-toggle-label">ุงู„ุฃุตู„ูŠุฉ</span>
409
+ </button>
410
+ </div>
411
+
412
+ <!-- Zoom Hint (#14) -->
413
+ <button onclick="openZoom()" id="zoom-hint" class="hidden absolute bottom-3 left-3 z-10 px-3 py-1.5 rounded-lg text-xs font-bold flex items-center gap-2 transition-all hover:scale-105" style="background: rgba(0,0,0,0.6); color: white; border: 1px solid rgba(255,255,255,0.15);">
414
+ <i class="fa-solid fa-magnifying-glass-plus"></i>
415
+ ุชูƒุจูŠุฑ
416
+ </button>
417
+
418
+ <!-- Cropper Mode -->
419
+ <div id="cropper-container" class="hidden w-full h-full p-4 flex flex-col gap-4">
420
+ <div class="flex-1 overflow-hidden rounded-lg bg-black">
421
+ <img id="cropper-img" src="">
422
+ </div>
423
+ <div class="flex justify-end gap-4">
424
+ <button onclick="cancelCrop()" class="px-6 py-2 bg-slate-700 rounded-lg font-bold text-white">ุฅู„ุบุงุก</button>
425
+ <button onclick="applyCrop()" class="px-6 py-2 bg-yellow-500 text-slate-900 rounded-lg font-bold">ุญูุธ ูˆุฅุนุงุฏุฉ ุงู„ู…ุนุงู„ุฌุฉ</button>
426
+ </div>
427
+ </div>
428
+
429
+ <!-- Preview Mode -->
430
+ <div id="preview-container" class="w-full h-full flex items-center justify-center">
431
+ <div id="preview-skeleton" class="hidden w-[80%] h-[80%] skeleton rounded-lg shadow-2xl opacity-50"></div>
432
+ <img id="main-preview" src="" class="max-w-full max-h-[500px] hidden rounded shadow-2xl preview-transition opacity-0 cursor-zoom-in" onclick="openZoom()">
433
+ <!-- Improved Empty State (#13) -->
434
+ <div id="preview-placeholder" class="text-center px-4" style="color: var(--text-muted);">
435
+ <div class="float-anim inline-block mb-4">
436
+ <svg width="100" height="100" viewBox="0 0 100 100" fill="none" class="mx-auto opacity-25">
437
+ <rect x="12" y="18" width="76" height="64" rx="10" stroke="currentColor" stroke-width="2.5"/>
438
+ <circle cx="35" cy="40" r="8" stroke="currentColor" stroke-width="2.5"/>
439
+ <path d="M12 70 L35 50 L52 62 L65 45 L88 65" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
440
+ <path d="M62 22 L62 14 M62 14 L56 20 M62 14 L68 20" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
441
+ </svg>
442
+ </div>
443
+ <p class="font-semibold text-sm">ุงุฎุชุฑ ุตูˆุฑุฉ ู…ู† ุงู„ู‚ุงุฆู…ุฉ</p>
444
+ </div>
445
+ </div>
446
+
447
+ <!-- Batch Processing Overlay -->
448
+ <div id="batch-overlay" class="absolute inset-0 hidden items-center justify-center flex-col gap-5 z-50 rounded-2xl" style="background: rgba(0,0,0,0.7); backdrop-filter: blur(6px);">
449
+ <div class="w-14 h-14 border-4 border-yellow-500 border-t-transparent rounded-full animate-spin"></div>
450
+ <p id="batch-counter" class="text-5xl font-extrabold text-yellow-400 font-mono tracking-wider" dir="ltr">0/0</p>
451
+ <p class="text-sm text-slate-300 font-semibold">ุฌุงุฑูŠ ุงู„ู…ุนุงู„ุฌุฉ...</p>
452
+ </div>
453
+ </div>
454
+ </div>
455
+
456
+ <!-- โ”€โ”€ COLUMN 3: OPTIONS (LEFT SIDE) โ”€โ”€ -->
457
+ <div class="lg:col-span-3 flex flex-col gap-6 order-3">
458
+ <div class="studio-card rounded-2xl p-5 shadow-xl sticky top-6">
459
+ <label class="block text-[10px] font-bold text-theme-muted uppercase mb-4 tracking-widest text-right">ุงู„ุฎูŠุงุฑุงุช ูˆุงู„ุฅุนุฏุงุฏุงุช</label>
460
+
461
+ <!-- Processing Steps -->
462
+ <div class="space-y-3 mb-6" dir="rtl">
463
+ <label class="flex items-center gap-3 cursor-pointer group p-2 rounded-lg hover:bg-slate-500/5 transition-colors">
464
+ <input type="checkbox" id="step-retouch" checked class="w-5 h-5 rounded border-slate-700 bg-slate-800 text-yellow-500 focus:ring-yellow-500">
465
+ <span class="text-sm font-medium text-theme-secondary group-hover:text-yellow-500 transition-colors">ุชุฌู…ูŠู„ ุงู„ุจุดุฑุฉ</span>
466
+ </label>
467
+ <label class="flex items-center gap-3 cursor-pointer group p-2 rounded-lg hover:bg-slate-500/5 transition-colors">
468
+ <input type="checkbox" id="step-color" checked class="w-5 h-5 rounded border-slate-700 bg-slate-800 text-yellow-500 focus:ring-yellow-500">
469
+ <span class="text-sm font-medium text-theme-secondary group-hover:text-yellow-500 transition-colors">ุชุญุณูŠู† ุงู„ุฃู„ูˆุงู†</span>
470
+ </label>
471
+ <label class="flex items-center gap-3 cursor-pointer group p-2 rounded-lg hover:bg-slate-500/5 transition-colors">
472
+ <input type="checkbox" id="step-rmbg" checked class="w-5 h-5 rounded border-slate-700 bg-slate-800 text-yellow-500 focus:ring-yellow-500">
473
+ <span class="text-sm font-medium text-theme-secondary group-hover:text-yellow-500 transition-colors">ุฅุฒุงู„ุฉ ุงู„ุฎู„ููŠุฉ</span>
474
+ </label>
475
+ <label class="flex items-center gap-3 cursor-pointer group p-2 rounded-lg hover:bg-slate-500/5 transition-colors">
476
+ <input type="checkbox" id="step-crop" checked class="w-5 h-5 rounded border-slate-700 bg-slate-800 text-yellow-500 focus:ring-yellow-500">
477
+ <span class="text-sm font-medium text-theme-secondary group-hover:text-yellow-500 transition-colors">ู‚ุต ุชู„ู‚ุงุฆูŠ</span>
478
+ </label>
479
+
480
+ <div class="border-t my-3" style="border-color: var(--border-card);"></div>
481
+
482
+ <label class="flex items-center gap-3 cursor-pointer group p-2 rounded-lg hover:bg-slate-500/5 transition-colors">
483
+ <input type="checkbox" id="add-studio" checked class="w-5 h-5 rounded border-slate-700 bg-slate-800 text-emerald-500 focus:ring-emerald-500">
484
+ <span class="text-sm font-medium text-theme-secondary group-hover:text-emerald-500 transition-colors">ุงุณู… ุงู„ุงุณุชูˆุฏูŠูˆ</span>
485
+ </label>
486
+ <label class="flex items-center gap-3 cursor-pointer group p-2 rounded-lg hover:bg-slate-500/5 transition-colors">
487
+ <input type="checkbox" id="add-logo" checked class="w-5 h-5 rounded border-slate-700 bg-slate-800 text-emerald-500 focus:ring-emerald-500">
488
+ <span class="text-sm font-medium text-theme-secondary group-hover:text-emerald-500 transition-colors">ุดุนุงุฑ ุงู„ุงุณุชูˆุฏูŠูˆ</span>
489
+ </label>
490
+ <label class="flex items-center gap-3 cursor-pointer group p-2 rounded-lg hover:bg-slate-500/5 transition-colors">
491
+ <input type="checkbox" id="add-date" checked class="w-5 h-5 rounded border-slate-700 bg-slate-800 text-emerald-500 focus:ring-emerald-500">
492
+ <span class="text-sm font-medium text-theme-secondary group-hover:text-emerald-500 transition-colors">ุฅุถุงูุฉ ุงู„ุชุงุฑูŠุฎ</span>
493
+ </label>
494
+ </div>
495
+
496
+ <!-- Settings Panel (#5) - Always visible in side column? Or collapsible? Let's keep collapsible but cleaner -->
497
+ <div class="mb-6">
498
+ <button onclick="toggleSettings()" class="w-full justify-between flex items-center gap-2 text-xs text-theme-muted hover:text-yellow-500 transition-colors font-bold p-2 bg-slate-500/5 rounded-lg">
499
+ <div class="flex items-center gap-2">
500
+ <i class="fa-solid fa-sliders text-xs"></i>
501
+ <span>ุฅุนุฏุงุฏุงุช ู…ุชู‚ุฏู…ุฉ</span>
502
+ </div>
503
+ <i class="fa-solid fa-chevron-down text-[10px] transition-transform" id="settings-chevron"></i>
504
+ </button>
505
+ <div id="settings-panel" class="settings-panel mt-2">
506
+ <div class="p-3 rounded-lg border space-y-4 text-right" style="background: var(--bg-card-alt); border-color: var(--border-card);">
507
+ <!-- Retouch Sensitivity -->
508
+ <div>
509
+ <div class="flex justify-between items-center mb-1">
510
+ <label class="text-[10px] font-bold text-theme-muted">ุญุณุงุณูŠุฉ ุงู„ุชุฌู…ูŠู„</label>
511
+ <span id="sensitivity-val" class="text-[10px] font-mono text-yellow-500">3.0</span>
512
+ </div>
513
+ <input type="range" id="setting-sensitivity" min="1" max="5" step="0.5" value="3.0" class="w-full accent-yellow-500 h-1.5" oninput="document.getElementById('sensitivity-val').textContent=this.value">
514
+ </div>
515
+ <!-- Tone Smoothing -->
516
+ <div>
517
+ <div class="flex justify-between items-center mb-1">
518
+ <label class="text-[10px] font-bold text-theme-muted">ู†ุนูˆู…ุฉ ุงู„ุจุดุฑุฉ</label>
519
+ <span id="smoothing-val" class="text-[10px] font-mono text-yellow-500">0.6</span>
520
+ </div>
521
+ <input type="range" id="setting-smoothing" min="0" max="1" step="0.1" value="0.6" class="w-full accent-yellow-500 h-1.5" oninput="document.getElementById('smoothing-val').textContent=parseFloat(this.value).toFixed(1)">
522
+ </div>
523
+ <!-- ID Font Size -->
524
+ <div>
525
+ <div class="flex justify-between items-center mb-1">
526
+ <label class="text-[10px] font-bold text-theme-muted">ุญุฌู… ุฎุท ุงู„ุฑู‚ู…</label>
527
+ <span id="fontsize-val" class="text-[10px] font-mono text-yellow-500">63</span>
528
+ </div>
529
+ <input type="range" id="setting-fontsize" min="30" max="120" step="1" value="63" class="w-full accent-yellow-500 h-1.5" oninput="document.getElementById('fontsize-val').textContent=this.value">
530
+ </div>
531
+ <!-- Name Font Size -->
532
+ <div>
533
+ <div class="flex justify-between items-center mb-1">
534
+ <label class="text-[10px] font-bold text-theme-muted">ุญุฌู… ุฎุท ุงู„ุงุณู…</label>
535
+ <span id="namefontsize-val" class="text-[10px] font-mono text-yellow-500">43</span>
536
+ </div>
537
+ <input type="range" id="setting-namefontsize" min="20" max="80" step="1" value="43" class="w-full accent-yellow-500 h-1.5" oninput="document.getElementById('namefontsize-val').textContent=this.value">
538
+ </div>
539
+ <button onclick="saveSettings()" class="w-full py-2 bg-yellow-500 hover:bg-yellow-600 text-slate-900 rounded-lg font-bold text-xs transition-all flex items-center justify-center gap-2">
540
+ <i class="fa-solid fa-floppy-disk"></i> ุญูุธ
541
+ </button>
542
+ </div>
543
+ </div>
544
+ </div>
545
+
546
+ <!-- Actions List -->
547
+ <div class="flex flex-col gap-2">
548
+ <button id="process-all-btn" disabled class="w-full bg-yellow-500 hover:bg-yellow-600 disabled:opacity-50 disabled:cursor-not-allowed text-slate-900 font-bold py-3 rounded-lg transition-all flex items-center justify-center gap-2 text-sm shadow-lg shadow-yellow-500/20">
549
+ <i class="fa-solid fa-play"></i> ุจุฏุก ู…ุนุงู„ุฌุฉ ุงู„ูƒู„
550
+ </button>
551
+
552
+ <div class="grid grid-cols-2 gap-2 mt-2">
553
+ <!-- Re-process Single (#7) -->
554
+ <button id="reprocess-btn" disabled onclick="reprocessCurrent()" title="ุฅุนุงุฏุฉ ู…ุนุงู„ุฌุฉ ู‡ุฐู‡ ุงู„ุตูˆุฑุฉ" class="bg-orange-600 hover:bg-orange-700 disabled:opacity-50 disabled:cursor-not-allowed text-white font-bold py-2 rounded-lg transition-all flex items-center justify-center gap-2 text-xs">
555
+ <i class="fa-solid fa-rotate"></i> ุฅุนุงุฏุฉ
556
+ </button>
557
+
558
+ <button id="edit-crop-btn" disabled class="bg-slate-700 hover:bg-slate-600 text-white font-bold py-2 rounded-lg transition-all flex items-center justify-center gap-2 text-xs">
559
+ <i class="fa-solid fa-crop-simple"></i> ู‚ุต
560
+ </button>
561
+ </div>
562
+
563
+ <div class="grid grid-cols-2 gap-2 mt-1">
564
+ <button id="save-btn" disabled title="ุชุญู…ูŠู„" class="bg-emerald-600 hover:bg-emerald-700 disabled:opacity-50 text-white font-bold py-2 rounded-lg transition-all">
565
+ <i class="fa-solid fa-download"></i>
566
+ </button>
567
+ <button id="download-all-btn" disabled title="ุถุบุท ุงู„ูƒู„" class="bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white font-bold py-2 rounded-lg transition-all flex items-center justify-center gap-1">
568
+ <i class="fa-solid fa-file-zipper"></i>
569
+ </button>
570
+ </div>
571
+
572
+ <button id="clear-all-btn" title="ุญุฐู ุงู„ูƒู„" class="mt-4 w-full bg-transparent border border-rose-600/30 hover:bg-rose-600 hover:text-white text-rose-500 font-bold py-2 rounded-lg transition-all text-xs">
573
+ <i class="fa-solid fa-trash-can ml-1"></i> ุญุฐู ุฌู…ูŠุน ุงู„ุตูˆุฑ
574
+ </button>
575
+ </div>
576
+
577
+ </div>
578
+ </div>
579
+
580
+ </main>
581
+
582
+ <script>
583
+ // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
584
+ // STATE
585
+ // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
586
+ let imageData = [];
587
+ let currentIndex = -1;
588
+ let cropper = null;
589
+ let isAIReady = false;
590
+ let showingOriginal = false; // Before/After state
591
+
592
+ // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
593
+ // UI ELEMENTS
594
+ // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
595
+ const fileInput = document.getElementById('file-input');
596
+ const imageList = document.getElementById('image-list');
597
+ const aiStatus = document.getElementById('ai-status');
598
+ const processAllBtn = document.getElementById('process-all-btn');
599
+ const downloadAllBtn = document.getElementById('download-all-btn');
600
+ const clearAllBtn = document.getElementById('clear-all-btn');
601
+ const editBtn = document.getElementById('edit-crop-btn');
602
+ const saveBtn = document.getElementById('save-btn');
603
+ const reprocessBtn = document.getElementById('reprocess-btn');
604
+ const mainPreview = document.getElementById('main-preview');
605
+ const previewPlaceholder = document.getElementById('preview-placeholder');
606
+ const previewContainer = document.getElementById('preview-container');
607
+ const cropperContainer = document.getElementById('cropper-container');
608
+ const cropperImg = document.getElementById('cropper-img');
609
+ const baToggleWrapper = document.getElementById('ba-toggle-wrapper');
610
+ const zoomHint = document.getElementById('zoom-hint');
611
+
612
+ // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
613
+ // THEME TOGGLE (#11)
614
+ // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
615
+ function initTheme() {
616
+ const saved = localStorage.getItem('studio-theme') || 'dark';
617
+ document.documentElement.setAttribute('data-theme', saved);
618
+ updateThemeIcon(saved);
619
+ }
620
+ function toggleTheme() {
621
+ const current = document.documentElement.getAttribute('data-theme');
622
+ const next = current === 'dark' ? 'light' : 'dark';
623
+ document.documentElement.setAttribute('data-theme', next);
624
+ localStorage.setItem('studio-theme', next);
625
+ updateThemeIcon(next);
626
+ }
627
+ function updateThemeIcon(theme) {
628
+ const icon = document.getElementById('theme-icon');
629
+ if (theme === 'dark') {
630
+ icon.className = 'fa-solid fa-sun text-yellow-400 text-sm';
631
+ } else {
632
+ icon.className = 'fa-solid fa-moon text-slate-600 text-sm';
633
+ }
634
+ }
635
+ initTheme();
636
+
637
+ // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
638
+ // TOAST NOTIFICATIONS
639
+ // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
640
+ function showToast(message, type = 'info', duration = 4000) {
641
+ const container = document.getElementById('toast-container');
642
+ const toast = document.createElement('div');
643
+ const icons = { success: 'fa-circle-check', error: 'fa-circle-exclamation', info: 'fa-circle-info', warning: 'fa-triangle-exclamation' };
644
+ toast.className = `toast toast-${type}`;
645
+ toast.innerHTML = `<i class="fa-solid ${icons[type] || icons.info}"></i><span>${message}</span>`;
646
+ container.appendChild(toast);
647
+ setTimeout(() => {
648
+ toast.style.animation = 'toastOut 0.3s ease-in forwards';
649
+ setTimeout(() => toast.remove(), 300);
650
+ }, duration);
651
+ }
652
+
653
+ // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
654
+ // SETTINGS PANEL (#5)
655
+ // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
656
+ function toggleSettings() {
657
+ const panel = document.getElementById('settings-panel');
658
+ const chevron = document.getElementById('settings-chevron');
659
+ panel.classList.toggle('open');
660
+ chevron.style.transform = panel.classList.contains('open') ? 'rotate(180deg)' : '';
661
+ }
662
+
663
+ async function loadSettingsFromServer() {
664
+ try {
665
+ const res = await fetch('/settings');
666
+ const data = await res.json();
667
+ if (data.retouch) {
668
+ if (data.retouch.sensitivity !== undefined) {
669
+ document.getElementById('setting-sensitivity').value = data.retouch.sensitivity;
670
+ document.getElementById('sensitivity-val').textContent = data.retouch.sensitivity;
671
+ }
672
+ if (data.retouch.tone_smoothing !== undefined) {
673
+ document.getElementById('setting-smoothing').value = data.retouch.tone_smoothing;
674
+ document.getElementById('smoothing-val').textContent = parseFloat(data.retouch.tone_smoothing).toFixed(1);
675
+ }
676
+ }
677
+ if (data.overlays) {
678
+ if (data.overlays.id_font_size !== undefined) {
679
+ document.getElementById('setting-fontsize').value = data.overlays.id_font_size;
680
+ document.getElementById('fontsize-val').textContent = data.overlays.id_font_size;
681
+ }
682
+ if (data.overlays.name_font_size !== undefined) {
683
+ document.getElementById('setting-namefontsize').value = data.overlays.name_font_size;
684
+ document.getElementById('namefontsize-val').textContent = data.overlays.name_font_size;
685
+ }
686
+ }
687
+ } catch (e) { console.warn('Could not load settings', e); }
688
+ }
689
+
690
+ async function saveSettings() {
691
+ const payload = {
692
+ retouch: {
693
+ sensitivity: parseFloat(document.getElementById('setting-sensitivity').value),
694
+ tone_smoothing: parseFloat(document.getElementById('setting-smoothing').value)
695
+ },
696
+ overlays: {
697
+ id_font_size: parseInt(document.getElementById('setting-fontsize').value),
698
+ name_font_size: parseInt(document.getElementById('setting-namefontsize').value)
699
+ }
700
+ };
701
+ try {
702
+ const res = await fetch('/settings', {
703
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload)
704
+ });
705
+ const result = await res.json();
706
+ if (result.status === 'success') {
707
+ showToast('ุชู… ุญูุธ ุงู„ุฅุนุฏุงุฏุงุช ุจู†ุฌุงุญ', 'success');
708
+ } else {
709
+ showToast('ูุดู„ ุญูุธ ุงู„ุฅุนุฏุงุฏุงุช', 'error');
710
+ }
711
+ } catch (e) {
712
+ showToast('ุฎุทุฃ ููŠ ุงู„ุงุชุตุงู„ ุจุงู„ุณูŠุฑูุฑ', 'error');
713
+ }
714
+ }
715
+ loadSettingsFromServer();
716
+
717
+ // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
718
+ // MOBILE DRAWER (#10)
719
+ // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
720
+ function toggleDrawer(open) {
721
+ document.getElementById('drawer-overlay').classList.toggle('active', open);
722
+ document.getElementById('mobile-drawer').classList.toggle('active', open);
723
+ }
724
+ const dropZoneMobile = document.getElementById('drop-zone-mobile');
725
+ if (dropZoneMobile) {
726
+ dropZoneMobile.onclick = () => fileInput.click();
727
+ }
728
+
729
+ // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
730
+ // AI STATUS CHECK
731
+ // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
732
+ async function checkStatus() {
733
+ try {
734
+ const res = await fetch('/status');
735
+ const data = await res.json();
736
+ if (data.ai_ready) {
737
+ isAIReady = true;
738
+ aiStatus.innerHTML = '<div class="w-3 h-3 rounded-full bg-emerald-500"></div>';
739
+ aiStatus.className = "flex items-center gap-2 px-4 py-2 rounded-full bg-emerald-900/20 border border-emerald-800/50 text-sm text-emerald-400 font-bold";
740
+ }
741
+ } catch (e) {}
742
+ }
743
+ setInterval(checkStatus, 2000);
744
+
745
+ // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
746
+ // UPLOAD & QUEUE
747
+ // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
748
+ const dropZone = document.getElementById('drop-zone');
749
+ dropZone.onclick = () => fileInput.click();
750
+
751
+ ['dragenter', 'dragover'].forEach(name => {
752
+ dropZone.addEventListener(name, (e) => { e.preventDefault(); dropZone.classList.add('drop-glow'); }, false);
753
+ });
754
+ ['dragleave', 'drop'].forEach(name => {
755
+ dropZone.addEventListener(name, (e) => { e.preventDefault(); dropZone.classList.remove('drop-glow'); }, false);
756
+ });
757
+ dropZone.addEventListener('drop', (e) => { handleFiles(e.dataTransfer.files); }, false);
758
+ fileInput.onchange = (e) => handleFiles(e.target.files);
759
+
760
+ function uploadFileWithProgress(file) {
761
+ return new Promise((resolve, reject) => {
762
+ const xhr = new XMLHttpRequest();
763
+ const formData = new FormData();
764
+ formData.append('file', file);
765
+ const wrappers = [document.getElementById('upload-progress-wrapper'), document.getElementById('upload-progress-wrapper-mobile')];
766
+ const fills = [document.getElementById('upload-progress-fill'), document.getElementById('upload-progress-fill-mobile')];
767
+ const texts = [document.getElementById('upload-progress-text'), document.getElementById('upload-progress-text-mobile')];
768
+
769
+ wrappers.forEach(w => { if (w) w.classList.remove('hidden'); });
770
+ xhr.upload.onprogress = (e) => {
771
+ if (e.lengthComputable) {
772
+ const pct = Math.round((e.loaded / e.total) * 100);
773
+ fills.forEach(f => { if (f) f.style.width = pct + '%'; });
774
+ texts.forEach(t => { if (t) t.textContent = `ุฌุงุฑูŠ ุงู„ุฑูุน... ${pct}%`; });
775
+ }
776
+ };
777
+ xhr.onload = () => {
778
+ wrappers.forEach(w => { if (w) w.classList.add('hidden'); });
779
+ fills.forEach(f => { if (f) f.style.width = '0%'; });
780
+ if (xhr.status >= 200 && xhr.status < 300) resolve(JSON.parse(xhr.responseText));
781
+ else reject(new Error(`Upload failed: ${xhr.status}`));
782
+ };
783
+ xhr.onerror = () => {
784
+ wrappers.forEach(w => { if (w) w.classList.add('hidden'); });
785
+ reject(new Error('Network error'));
786
+ };
787
+ xhr.open('POST', '/upload');
788
+ xhr.send(formData);
789
+ });
790
+ }
791
+
792
+ async function handleFiles(files) {
793
+ for (let file of files) {
794
+ try {
795
+ const data = await uploadFileWithProgress(file);
796
+ imageData.push({ ...data, name: "", id_num: "", result_url: null, custom_crop: null, steps: null, status: 'waiting' });
797
+ renderQueue();
798
+ if (currentIndex === -1) selectImage(imageData.length - 1);
799
+ } catch (e) {
800
+ showToast(`ูุดู„ ุฑูุน ${file.name}`, 'error');
801
+ }
802
+ }
803
+ fileInput.value = "";
804
+ }
805
+
806
+ function getQualityBadge(width, height) {
807
+ const megapixels = (width * height) / 1000000;
808
+ const minDim = Math.min(width, height);
809
+ if (minDim >= 1500 || megapixels >= 3) return '<span class="badge-quality badge-hd">HD</span>';
810
+ if (minDim >= 800 || megapixels >= 1) return '<span class="badge-quality badge-mid">OK</span>';
811
+ return '<span class="badge-quality badge-lowres" title="ุฌูˆุฏุฉ ู…ู†ุฎูุถุฉ">โš  LOW</span>';
812
+ }
813
+
814
+ // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
815
+ // QUEUE RENDER - WITH DELETE BUTTON
816
+ // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
817
+ function renderQueue() {
818
+ const count = imageData.length;
819
+ document.getElementById('queue-count').innerText = `${count} ุตูˆุฑ`;
820
+ document.getElementById('nav-counter').innerText = count > 0 ? `${currentIndex + 1}/${count}` : "0/0";
821
+ const mobileCount = document.getElementById('mobile-queue-count');
822
+ if (mobileCount) mobileCount.innerText = count;
823
+
824
+ if (count === 0) {
825
+ const emptyHTML = `
826
+ <div class="text-center py-12">
827
+ <div class="float-anim inline-block mb-4">
828
+ <i class="fa-solid fa-layer-group text-3xl opacity-30"></i>
829
+ </div>
830
+ <p class="text-theme-muted text-sm font-medium">ุงู„ู‚ุงุฆู…ุฉ ูุงุฑุบุฉ</p>
831
+ </div>`;
832
+ imageList.innerHTML = emptyHTML;
833
+ const mobileList = document.getElementById('mobile-image-list');
834
+ if (mobileList) mobileList.innerHTML = `<div class="text-center py-10 text-theme-muted italic text-sm">ุงู„ู‚ุงุฆู…ุฉ ูุงุฑุบุฉ</div>`;
835
+ processAllBtn.disabled = true;
836
+ downloadAllBtn.disabled = true;
837
+ editBtn.disabled = true;
838
+ saveBtn.disabled = true;
839
+ reprocessBtn.disabled = true;
840
+ return;
841
+ }
842
+
843
+ const t = new Date().getTime();
844
+ const html = imageData.map((img, idx) => {
845
+ let statusIcon = '';
846
+ if (img.status === 'waiting') statusIcon = '<i class="fa-regular fa-clock text-slate-500"></i>';
847
+ else if (img.status === 'processing') statusIcon = '<i class="fa-solid fa-gear processing-spin text-yellow-500"></i>';
848
+ else if (img.status === 'done') statusIcon = '<i class="fa-solid fa-circle-check text-emerald-500"></i>';
849
+ else if (img.status === 'error') statusIcon = '<i class="fa-solid fa-circle-exclamation text-rose-500"></i>';
850
+
851
+ return `
852
+ <div onclick="selectImage(${idx})" class="queue-slide ${currentIndex === idx ? 'active' : ''}">
853
+ <img src="${img.result_url ? img.result_url + '?t=' + t : img.thumb_url}" alt="">
854
+ <div class="slide-status">${statusIcon}</div>
855
+ <button onclick="deleteImage(event, ${idx})" class="slide-delete" title="ุญุฐู"><i class="fa-solid fa-xmark"></i></button>
856
+ <div class="slide-name">${img.filename}</div>
857
+ </div>
858
+ `}).join('');
859
+
860
+ imageList.innerHTML = html;
861
+ const mobileList = document.getElementById('mobile-image-list');
862
+ if (mobileList) mobileList.innerHTML = html;
863
+
864
+ // Auto-scroll to the active slide
865
+ const activeSlide = imageList.querySelector('.queue-slide.active');
866
+ if (activeSlide) activeSlide.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
867
+
868
+ processAllBtn.disabled = count === 0;
869
+ const processedCount = imageData.filter(d => d.result_url).length;
870
+ downloadAllBtn.disabled = processedCount === 0;
871
+ }
872
+
873
+ // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
874
+ // SELECTION & NAVIGATION
875
+ // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
876
+ function navigate(direction) {
877
+ if (imageData.length === 0) return;
878
+ let nextIndex = currentIndex + direction;
879
+ if (nextIndex >= 0 && nextIndex < imageData.length) selectImage(nextIndex);
880
+ }
881
+
882
+ function selectImage(idx) {
883
+ saveCurrentFields();
884
+ currentIndex = idx;
885
+ showingOriginal = false;
886
+ const data = imageData[idx];
887
+
888
+ document.getElementById('student-name').value = data.name;
889
+ document.getElementById('student-id').value = data.id_num;
890
+ document.getElementById('current-filename').innerText = data.filename;
891
+
892
+ baToggleWrapper.classList.toggle('hidden', !data.result_url);
893
+ document.getElementById('ba-toggle-label').textContent = 'ุงู„ุฃุตู„ูŠุฉ';
894
+ zoomHint.classList.toggle('hidden', !data.result_url && !data.thumb_url);
895
+ reprocessBtn.disabled = !data.result_url;
896
+
897
+ const previewSkeleton = document.getElementById('preview-skeleton');
898
+ const url = data.result_url ? data.result_url + '?t=' + Date.now() : data.thumb_url;
899
+
900
+ // Reset the image completely before loading the new one
901
+ mainPreview.classList.add('hidden');
902
+ mainPreview.classList.remove('opacity-100');
903
+ mainPreview.classList.add('opacity-0');
904
+ mainPreview.removeAttribute('src');
905
+
906
+ if (!data.result_url) {
907
+ previewSkeleton.classList.remove('hidden');
908
+ } else {
909
+ previewSkeleton.classList.add('hidden');
910
+ }
911
+ previewPlaceholder.classList.add('hidden');
912
+
913
+ // Create a fresh Image to avoid any caching/onload issues
914
+ const tempImg = new Image();
915
+ tempImg.onload = () => {
916
+ mainPreview.src = tempImg.src;
917
+ mainPreview.classList.remove('hidden');
918
+ mainPreview.classList.remove('opacity-0');
919
+ mainPreview.classList.add('opacity-100');
920
+ previewSkeleton.classList.add('hidden');
921
+ };
922
+ tempImg.onerror = () => {
923
+ console.error('Failed to load preview:', url);
924
+ previewSkeleton.classList.add('hidden');
925
+ };
926
+ tempImg.src = url;
927
+
928
+ editBtn.disabled = false;
929
+ saveBtn.disabled = !data.result_url;
930
+ renderQueue();
931
+ toggleDrawer(false);
932
+ }
933
+
934
+ function saveCurrentFields() {
935
+ if (currentIndex === -1) return;
936
+ imageData[currentIndex].name = document.getElementById('student-name').value;
937
+ imageData[currentIndex].id_num = document.getElementById('student-id').value;
938
+ }
939
+
940
+ function toggleBeforeAfter() {
941
+ if (currentIndex === -1) return;
942
+ const data = imageData[currentIndex];
943
+ if (!data.result_url) return;
944
+ showingOriginal = !showingOriginal;
945
+ const label = document.getElementById('ba-toggle-label');
946
+ const t = new Date().getTime();
947
+ mainPreview.classList.add('opacity-0');
948
+ setTimeout(() => {
949
+ if (showingOriginal) {
950
+ mainPreview.src = data.thumb_url;
951
+ label.textContent = 'ุงู„ู†ุชูŠุฌุฉ';
952
+ } else {
953
+ mainPreview.src = data.result_url + '?t=' + t;
954
+ label.textContent = 'ุงู„ุฃุตู„ูŠุฉ';
955
+ }
956
+ mainPreview.onload = () => {
957
+ mainPreview.classList.remove('opacity-0');
958
+ mainPreview.classList.add('opacity-100');
959
+ };
960
+ }, 150);
961
+ }
962
+
963
+ // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
964
+ // ZOOM
965
+ // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
966
+ function openZoom() {
967
+ if (!mainPreview.src) return;
968
+ const modal = document.getElementById('zoom-modal');
969
+ const zoomImg = document.getElementById('zoom-img');
970
+ zoomImg.src = mainPreview.src;
971
+ zoomImg.style.transform = 'scale(1)';
972
+ modal.classList.add('active');
973
+ modal.onwheel = (e) => {
974
+ e.preventDefault();
975
+ const current = parseFloat(zoomImg.style.transform.replace('scale(', '').replace(')', '')) || 1;
976
+ const delta = e.deltaY > 0 ? -0.15 : 0.15;
977
+ const next = Math.max(0.5, Math.min(5, current + delta));
978
+ zoomImg.style.transform = `scale(${next})`;
979
+ };
980
+ }
981
+ function closeZoom() { document.getElementById('zoom-modal').classList.remove('active'); }
982
+
983
+ // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
984
+ // PROCESSING
985
+ // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
986
+ processAllBtn.onclick = async () => {
987
+ if (!isAIReady) return showToast("ุงู†ุชุธุฑ ุชุญู…ูŠู„ ุงู„ู…ุญุฑูƒ...", 'warning');
988
+ saveCurrentFields();
989
+ processAllBtn.disabled = true;
990
+ fileInput.disabled = true;
991
+
992
+ const toProcess = imageData.filter(d => !d.result_url);
993
+ const total = toProcess.length;
994
+ if (total === 0) { processAllBtn.disabled = false; fileInput.disabled = false; return; }
995
+
996
+ // Show batch overlay with counter
997
+ const overlay = document.getElementById('batch-overlay');
998
+ const counter = document.getElementById('batch-counter');
999
+ overlay.classList.replace('hidden', 'flex');
1000
+ let done = 0;
1001
+ counter.textContent = `0/${total}`;
1002
+
1003
+ for (let i = 0; i < imageData.length; i++) {
1004
+ if (imageData[i].result_url) continue;
1005
+ await runPipelineForIndex(i);
1006
+ done++;
1007
+ counter.textContent = `${done}/${total}`;
1008
+ }
1009
+
1010
+ // Hide overlay
1011
+ overlay.classList.replace('flex', 'hidden');
1012
+ processAllBtn.disabled = false;
1013
+ fileInput.disabled = false;
1014
+
1015
+ // Refresh preview to show result of currently selected image
1016
+ if (currentIndex !== -1) selectImage(currentIndex);
1017
+ showToast(`ุชู…ุช ู…ุนุงู„ุฌุฉ ${done} ุตูˆุฑุฉ`, 'success');
1018
+ };
1019
+
1020
+ async function runPipelineForIndex(idx, isCustom = false) {
1021
+ const data = imageData[idx];
1022
+ data.status = 'processing';
1023
+ renderQueue();
1024
+ const formData = new FormData();
1025
+ formData.append('name', data.name);
1026
+ formData.append('id_number', data.id_num);
1027
+ formData.append('do_rmbg', document.getElementById('step-rmbg').checked);
1028
+ formData.append('do_color', document.getElementById('step-color').checked);
1029
+ formData.append('do_retouch', document.getElementById('step-retouch').checked);
1030
+ formData.append('do_crop', document.getElementById('step-crop').checked);
1031
+ formData.append('add_studio_name', document.getElementById('add-studio').checked);
1032
+ formData.append('add_logo', document.getElementById('add-logo').checked);
1033
+ formData.append('add_date', document.getElementById('add-date').checked);
1034
+ if (isCustom && data.custom_crop) {
1035
+ formData.append('x1', data.custom_crop.x1);
1036
+ formData.append('y1', data.custom_crop.y1);
1037
+ formData.append('x2', data.custom_crop.x2);
1038
+ formData.append('y2', data.custom_crop.y2);
1039
+ }
1040
+ try {
1041
+ const res = await fetch(`/process/${data.id}`, { method: 'POST', body: formData });
1042
+ const result = await res.json();
1043
+ if (result.error) throw new Error(result.error);
1044
+ imageData[idx].result_url = result.result_url;
1045
+ imageData[idx].status = 'done';
1046
+ imageData[idx].steps = {
1047
+ rmbg: document.getElementById('step-rmbg').checked,
1048
+ color: document.getElementById('step-color').checked,
1049
+ retouch: document.getElementById('step-retouch').checked,
1050
+ crop: document.getElementById('step-crop').checked || !!data.custom_crop
1051
+ };
1052
+ } catch (e) {
1053
+ console.error("Processing", e);
1054
+ imageData[idx].status = 'error';
1055
+ showToast(`ุฎุทุฃ ููŠ ${data.filename}`, 'error');
1056
+ }
1057
+ renderQueue();
1058
+ }
1059
+
1060
+ async function reprocessCurrent() {
1061
+ if (currentIndex === -1 || !isAIReady) return;
1062
+ saveCurrentFields();
1063
+ const data = imageData[currentIndex];
1064
+ data.result_url = null;
1065
+ data.status = 'waiting';
1066
+ data.steps = null;
1067
+ renderQueue();
1068
+ await runPipelineForIndex(currentIndex, !!data.custom_crop);
1069
+ selectImage(currentIndex);
1070
+ showToast('ุชู…ุช ุฅุนุงุฏุฉ ุงู„ู…ุนุงู„ุฌุฉ', 'success');
1071
+ }
1072
+
1073
+ // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
1074
+ // CROP & DOWNLOAD
1075
+ // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
1076
+ editBtn.onclick = () => {
1077
+ const data = imageData[currentIndex];
1078
+ cropperImg.src = data.thumb_url;
1079
+ previewContainer.classList.add('hidden');
1080
+ baToggleWrapper.classList.add('hidden');
1081
+ zoomHint.classList.add('hidden');
1082
+ cropperContainer.classList.remove('hidden');
1083
+ if (cropper) cropper.destroy();
1084
+ cropper = new Cropper(cropperImg, { aspectRatio: 5/7, viewMode: 1, autoCropArea: 0.8 });
1085
+ };
1086
+ function cancelCrop() {
1087
+ cropperContainer.classList.add('hidden');
1088
+ previewContainer.classList.remove('hidden');
1089
+ if (currentIndex >= 0 && imageData[currentIndex].result_url) baToggleWrapper.classList.remove('hidden');
1090
+ zoomHint.classList.remove('hidden');
1091
+ }
1092
+ async function applyCrop() {
1093
+ const cropData = cropper.getData(true);
1094
+ const imgData = imageData[currentIndex];
1095
+ const displayImg = cropper.getImageData();
1096
+ const scaleX = imgData.width / displayImg.naturalWidth;
1097
+ const scaleY = imgData.height / displayImg.naturalHeight;
1098
+ imgData.custom_crop = {
1099
+ x1: Math.max(0, Math.round(cropData.x * scaleX)),
1100
+ y1: Math.max(0, Math.round(cropData.y * scaleY)),
1101
+ x2: Math.min(imgData.width, Math.round((cropData.x + cropData.width) * scaleX)),
1102
+ y2: Math.min(imgData.height, Math.round((cropData.y + cropData.height) * scaleY))
1103
+ };
1104
+ cancelCrop();
1105
+ imgData.result_url = null;
1106
+ imgData.status = 'waiting';
1107
+ await runPipelineForIndex(currentIndex, true);
1108
+ selectImage(currentIndex);
1109
+ }
1110
+
1111
+ downloadAllBtn.onclick = async () => {
1112
+ const processed = imageData.filter(d => d.result_url);
1113
+ if (processed.length === 0) return;
1114
+ const overlay = document.getElementById('batch-overlay');
1115
+ const counter = document.getElementById('batch-counter');
1116
+ overlay.classList.replace('hidden', 'flex');
1117
+ counter.textContent = 'ZIP...';
1118
+ const zip = new JSZip();
1119
+ const folder = zip.folder("studio_layouts");
1120
+ for (let data of processed) {
1121
+ const res = await fetch(data.result_url);
1122
+ const blob = await res.blob();
1123
+ folder.file(`${PathStem(data.filename)}_layout.jpg`, blob);
1124
+ }
1125
+ const content = await zip.generateAsync({ type: "blob" });
1126
+ const link = document.createElement('a');
1127
+ link.href = URL.createObjectURL(content);
1128
+ link.download = `Studio_Batch_${new Date().getTime()}.zip`;
1129
+ link.click();
1130
+ overlay.classList.replace('flex', 'hidden');
1131
+ showToast(`ุชู… ุชุญู…ูŠู„ ${processed.length} ุตูˆุฑุฉ`, 'success');
1132
+ };
1133
+
1134
+ clearAllBtn.onclick = async () => {
1135
+ if (!confirm("ู‡ู„ ุชุฑูŠุฏ ุญุฐู ุฌู…ูŠุน ุงู„ุตูˆุฑุŸ")) return;
1136
+ try {
1137
+ const res = await fetch('/clear-all', { method: 'POST' });
1138
+ imageData = [];
1139
+ currentIndex = -1;
1140
+ fileInput.value = "";
1141
+ mainPreview.classList.add('hidden');
1142
+ previewPlaceholder.classList.remove('hidden');
1143
+ baToggleWrapper.classList.add('hidden');
1144
+ zoomHint.classList.add('hidden');
1145
+ document.getElementById('student-name').value = "";
1146
+ document.getElementById('student-id').value = "";
1147
+ document.getElementById('current-filename').innerText = "";
1148
+ renderQueue();
1149
+ showToast("ุชู… ุงู„ุญุฐู", 'success');
1150
+ } catch (e) {}
1151
+ };
1152
+
1153
+ function deleteImage(e, idx) {
1154
+ e.stopPropagation();
1155
+ if(!confirm("ุญุฐู ุงู„ุตูˆุฑุฉุŸ")) return;
1156
+ imageData.splice(idx, 1);
1157
+ if (imageData.length === 0) {
1158
+ currentIndex = -1;
1159
+ mainPreview.classList.add('hidden');
1160
+ previewPlaceholder.classList.remove('hidden');
1161
+ baToggleWrapper.classList.add('hidden');
1162
+ zoomHint.classList.add('hidden');
1163
+ document.getElementById('student-name').value = "";
1164
+ } else {
1165
+ if (currentIndex === idx) {
1166
+ currentIndex = Math.max(0, currentIndex - 1);
1167
+ selectImage(currentIndex);
1168
+ } else if (currentIndex > idx) {
1169
+ currentIndex--;
1170
+ }
1171
+ }
1172
+ renderQueue();
1173
+ }
1174
+
1175
+ // Global Delete function for header button (keep for keyboard/compatibility)
1176
+ function deleteSelected() {
1177
+ if (currentIndex !== -1) deleteImage({stopPropagation:()=>{}}, currentIndex);
1178
+ }
1179
+
1180
+ saveBtn.onclick = () => {
1181
+ const link = document.createElement('a');
1182
+ link.href = mainPreview.src;
1183
+ link.download = `${PathStem(imageData[currentIndex].filename)}_layout.jpg`;
1184
+ link.click();
1185
+ };
1186
+
1187
+ function PathStem(filename) { return filename.substring(0, filename.lastIndexOf('.')) || filename; }
1188
+
1189
+ window.addEventListener('keydown', (e) => {
1190
+ if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
1191
+ if (e.key === 'Escape') { closeZoom(); document.getElementById('shortcuts-modal').classList.remove('active'); toggleDrawer(false); return; }
1192
+ if (e.key === 'ArrowRight') navigate(1);
1193
+ else if (e.key === 'ArrowLeft') navigate(-1);
1194
+ else if (e.key === 'Delete') deleteSelected();
1195
+ else if (e.key === 'Enter') { saveCurrentFields(); if (currentIndex < imageData.length - 1) navigate(1); }
1196
+ else if (e.key === 's' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); saveCurrentFields(); runPipelineForIndex(currentIndex); }
1197
+ });
1198
+ </script>
1199
+ </body>
1200
+ </html>