diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000000000000000000000000000000000000..c1ffff3a1e03fefccae86b8e0fda448b95821d90
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,8 @@
+backend/models/depth_anything.onnx filter=lfs diff=lfs merge=lfs -text
+backend/text_editor/fonts/hind_regular.ttf filter=lfs diff=lfs merge=lfs -text
+backend/text_editor/fonts/kalam_regular.ttf filter=lfs diff=lfs merge=lfs -text
+backend/text_editor/fonts/martel_regular.ttf filter=lfs diff=lfs merge=lfs -text
+backend/text_editor/fonts/notosansdevanagari_regular.ttf filter=lfs diff=lfs merge=lfs -text
+backend/text_editor/fonts/rozhaone_regular.ttf filter=lfs diff=lfs merge=lfs -text
+backend/text_editor/fonts/teko_regular.ttf filter=lfs diff=lfs merge=lfs -text
+backend/text_editor/fonts/yatraone_regular.ttf filter=lfs diff=lfs merge=lfs -text
diff --git a/README.md b/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..7e4e179042003975f3d07cda6db9f2f4891122e5
--- /dev/null
+++ b/README.md
@@ -0,0 +1,122 @@
+---
+title: Helpful AI
+emoji: ⚡
+colorFrom: indigo
+colorTo: pink
+sdk: docker
+pinned: false
+---
+
+# ⚡ Antigravity Studio - AI Image & Document Workspace
+
+A complete, production-grade, state-of-the-art **Image & PDF Manipulation Web Application** consisting of two primary workspaces:
+1. **AI Background Remover Workspace**: Isolate foreground subjects with U²-Net deep learning or GrabCut, refine fine hair details with Guided Filter matting, and generate customizable realistic drop shadows.
+2. **AI In-Image Text Editor Workspace**: Automatically detect, erase, and replace text in images or multi-page PDFs while perfectly matching original fonts, sizes, and ink colors.
+
+Built with **Streamlit** (frontend) and **Python + OpenCV + Deep Learning** (backend).
+
+---
+
+## ✨ Workspace 1: AI Background Remover
+
+Allows users to upload an image, extract the subject with professional-grade borders, and customize visual compositions.
+
+- **AI U²-Net Neural Network Mode**: Leverages pre-trained deep learning to automatically segment complex shapes (like portraits, hair, and soft details) without bounding box configurations.
+- **GrabCut General Subject Mode**: Detects bounding boxes automatically or supports manual coordinate tuning.
+- **Signature & Text (Ink Extraction) Mode**: Uses color distance maps to isolate handwritten ink from paper backdrops (perfect for signatures).
+- **Advanced Edge Matting**:
+ - *Guided Filter Matting*: An edge-preserving linear filtering algorithm that aligns alpha transitions to individual hair fibers.
+ - *Morphological Closing*: Fills inner holes and smooths contours.
+ - *Speckle Filtering*: Cleans isolated floating noise.
+- **Realistic Drop Shadow Engine**: Generates natural drop shadows beneath isolated subjects. Customize opacity, blur softness, offset distance, and angle.
+- **Interactive Composite Studio**: Composite transparent cutouts over Checkerboard grids, Solid colors, gradients (*Midnight Glow, Sunset Studio, Neon Cyber*), or uploaded background images.
+
+---
+
+## ✨ Workspace 2: AI Document & PDF Text Editor
+
+Allows users to upload an image or multi-page PDF, find text, and perform clean in-place corrections.
+
+- **Automated Layout Scan**: Scans images or document page textures using **EasyOCR** to discover bounding boxes of all text segments.
+- **Translucent OCR Overlays**: Renders glowing overlays displaying editable text fields in the document.
+- **Zero-Configuration PDF Engine**: Powered by **PyMuPDF (Fitz)**. Render multi-page PDFs, navigate pages, and compile edited page images back into a single multi-page PDF document.
+- **Adaptive Ink & Paper Color Sampling**: Extracts the median BGR ink color (foreground character strokes) and surrounding paper texture color (background) using Otsu's adaptive binarization of characters.
+- **Seamless Texture-Preserving Eraser**: Creates a character-level binary mask, dilates it by 2px, and runs OpenCV Fast Inpainting (**Telea's algorithm**). This erases old text while preserving paper folds, grain, gridlines, and shadows.
+- **Adaptive Text Synthesis**: Scales the replacement text according to the bounding box height, matches standard font families (Serif, Sans-Serif, Monospace), and renders it anti-aliased with the sampled ink color.
+
+---
+
+## 📂 Project Structure
+
+```text
+e:\bg remover\
+├── app.py # Main Streamlit dashboard routing entrypoint & CSS styles
+├── requirements.txt # Unified dependency manifest
+├── README.md # Detailed workspace and setup documentation
+│
+├── frontend/ # Modern UI Layout Modules
+│ ├── __init__.py
+│ ├── home.py # Premium Glassmorphic Studio Navigation landing page
+│ ├── bg_remover_ui.py # Custom Background Remover controls and workflows
+│ ├── text_editor_ui.py # PDF page navigation, OCR list, Find/Replace editor
+│ ├── preview_ui.py # Before/After comparison and backdrop composites
+│ └── download_ui.py # Downscaled/lazy high-res download compiler
+│
+└── backend/ # Algorithms and Heavy Processing Backends
+ ├── __init__.py
+ ├── utilities.py # Image format converters and checkerboard pattern generators
+ │
+ ├── bg_remover/ # Background Remover Subpackage
+ │ ├── __init__.py
+ │ ├── segmentation.py # GrabCut, contour analysis, and corner color GMM seeding
+ │ ├── edge_detection.py # Fast Guided Filter matting and morphological refinements
+ │ ├── shadow_generator.py# Affine transformation drop shadow translations and blenders
+ │ └── image_processor.py # Background remover high-level pipeline orchestrator
+ │
+ └── text_editor/ # Text Editor & Document Subpackage
+ ├── __init__.py
+ ├── pdf_processor.py # PyMuPDF page extractors and PDF compilers
+ ├── ocr_engine.py # EasyOCR layout scanning engine
+ ├── color_detector.py # Character-level ink/paper color samplers
+ ├── font_detector.py # System TTF font loader and height-based size mapper
+ ├── text_replacer.py # Telea inpainter and PIL anti-aliased text renderer
+ └── orchestrator.py # Text correction orchestrator
+```
+
+---
+
+## 🚀 Installation & Running Locally
+
+Ensure you have **Python 3.10+** installed. Follow these steps:
+
+### 1. Install Dependencies
+Open a terminal in the root of the workspace (`e:\bg remover\`) and run:
+```bash
+pip install -r requirements.txt
+```
+
+### 2. Run the Application
+Start the Streamlit dashboard:
+```bash
+streamlit run app.py
+```
+
+### 3. Open in Browser
+The local server will start and prompt you to open the dashboard at:
+- **Local URL**: `http://localhost:8501`
+
+---
+
+## 🛠️ Offline Verification & Test Suites
+
+We have built extensive automated tests to guarantee system stability and code correctness:
+
+- **Background Remover Tests**:
+ ```bash
+ python "C:\Users\Asus\.gemini\antigravity\brain\76a54bfc-09a7-4a96-809d-8b8c797bd925\scratch\test_pipeline.py"
+ ```
+- **Document Text Editor Tests**:
+ ```bash
+ python "C:\Users\Asus\.gemini\antigravity\brain\76a54bfc-09a7-4a96-809d-8b8c797bd925\scratch\test_text_editor.py"
+ ```
+Both test suites should print `[SUCCESS]` and pass seamlessly!
diff --git a/app.py b/app.py
new file mode 100644
index 0000000000000000000000000000000000000000..7f3584e5b3f711ba857998791bbb3cba16202f67
--- /dev/null
+++ b/app.py
@@ -0,0 +1,183 @@
+import streamlit as st
+import os
+import sys
+
+# Add current workspace directory to sys.path to allow absolute imports
+sys.path.append(os.path.abspath(os.path.dirname(__file__)))
+
+from frontend.home import render_sidebar_brand, render_home_dashboard
+from frontend.bg_remover_ui import render_bg_remover_ui
+from frontend.text_editor_ui import render_text_editor_ui
+from frontend.dslr_blur_ui import render_dslr_blur_ui
+
+# Set Streamlit page config
+st.set_page_config(
+ page_title="Antigravity Studio - AI Image & Document Workspace",
+ page_icon="⚡",
+ layout="wide",
+ initial_sidebar_state="expanded"
+)
+
+# Custom CSS for Glassmorphic Dark Theme with glowing neon accents
+st.markdown(
+ """
+
+ """,
+ unsafe_allow_html=True
+)
+
+# ----------------- SESSION STATE INITIALIZATION -----------------
+if "active_workspace" not in st.session_state:
+ st.session_state.active_workspace = "Home"
+
+# ----------------- SIDEBAR BRANDING & ROUTING -----------------
+render_sidebar_brand()
+
+st.sidebar.markdown("### 🧭 NAVIGATION")
+nav_choice = st.sidebar.selectbox(
+ "Active Workspace",
+ ["Home Dashboard", "AI Background Remover", "AI DSLR Background Blur", "AI In-Image Text Editor"],
+ index=["Home Dashboard", "AI Background Remover", "AI DSLR Background Blur", "AI In-Image Text Editor"].index(
+ "Home Dashboard" if st.session_state.active_workspace == "Home" else st.session_state.active_workspace
+ ),
+ key="nav_choice_select"
+)
+
+# Sync sidebar navigation selection with active session state
+choice_mapped = "Home" if nav_choice == "Home Dashboard" else nav_choice
+if choice_mapped != st.session_state.active_workspace:
+ st.session_state.active_workspace = choice_mapped
+ st.rerun()
+
+# Quick nav back to Home from sidebar footer
+st.sidebar.markdown("
", unsafe_allow_html=True)
+if st.session_state.active_workspace != "Home":
+ if st.sidebar.button("← Back to Dashboard", use_container_width=True):
+ st.session_state.active_workspace = "Home"
+ st.rerun()
+
+# ----------------- WORKSPACE RENDERING -----------------
+if st.session_state.active_workspace == "Home":
+ render_home_dashboard()
+elif st.session_state.active_workspace == "AI Background Remover":
+ render_bg_remover_ui()
+elif st.session_state.active_workspace == "AI DSLR Background Blur":
+ render_dslr_blur_ui()
+elif st.session_state.active_workspace == "AI In-Image Text Editor":
+ render_text_editor_ui()
diff --git a/backend/__init__.py b/backend/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..7f831694f530a6ab8065262da0af99cd4631d15c
--- /dev/null
+++ b/backend/__init__.py
@@ -0,0 +1 @@
+# Backend package
diff --git a/backend/__pycache__/__init__.cpython-311.pyc b/backend/__pycache__/__init__.cpython-311.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..cb4ac43f629496ccf44bebea3bacc29b4da2948a
Binary files /dev/null and b/backend/__pycache__/__init__.cpython-311.pyc differ
diff --git a/backend/__pycache__/__init__.cpython-314.pyc b/backend/__pycache__/__init__.cpython-314.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..cc984ba17cd0592240c8efedd256f2b1b5c10a8e
Binary files /dev/null and b/backend/__pycache__/__init__.cpython-314.pyc differ
diff --git a/backend/__pycache__/edge_detector.cpython-311.pyc b/backend/__pycache__/edge_detector.cpython-311.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..967e95f4c557599d6af365684c4199fc849c7375
Binary files /dev/null and b/backend/__pycache__/edge_detector.cpython-311.pyc differ
diff --git a/backend/__pycache__/edge_detector.cpython-314.pyc b/backend/__pycache__/edge_detector.cpython-314.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..08612c2f83786e5e5c9deae5c578f947b7e3c7f9
Binary files /dev/null and b/backend/__pycache__/edge_detector.cpython-314.pyc differ
diff --git a/backend/__pycache__/image_processor.cpython-311.pyc b/backend/__pycache__/image_processor.cpython-311.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..8bea9e8cf838a764b762bc947a20c9d8c9e389e6
Binary files /dev/null and b/backend/__pycache__/image_processor.cpython-311.pyc differ
diff --git a/backend/__pycache__/image_processor.cpython-314.pyc b/backend/__pycache__/image_processor.cpython-314.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..2d6dbc05b73ba744cd64f0fac2b02519259eb207
Binary files /dev/null and b/backend/__pycache__/image_processor.cpython-314.pyc differ
diff --git a/backend/__pycache__/segmentation.cpython-311.pyc b/backend/__pycache__/segmentation.cpython-311.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..de8059376a5d6ceff5fb921dfa8de1d33b225942
Binary files /dev/null and b/backend/__pycache__/segmentation.cpython-311.pyc differ
diff --git a/backend/__pycache__/segmentation.cpython-314.pyc b/backend/__pycache__/segmentation.cpython-314.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..3e525e2d13d635f7923a86f559a01cbfc9b78383
Binary files /dev/null and b/backend/__pycache__/segmentation.cpython-314.pyc differ
diff --git a/backend/__pycache__/shadow_generator.cpython-311.pyc b/backend/__pycache__/shadow_generator.cpython-311.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..c71fc3c44eb9323b13bcdf6c287440e8a3d2c718
Binary files /dev/null and b/backend/__pycache__/shadow_generator.cpython-311.pyc differ
diff --git a/backend/__pycache__/shadow_generator.cpython-314.pyc b/backend/__pycache__/shadow_generator.cpython-314.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..ef5f0111e4702ce20867d5da1543ac9814dc5d71
Binary files /dev/null and b/backend/__pycache__/shadow_generator.cpython-314.pyc differ
diff --git a/backend/__pycache__/utilities.cpython-311.pyc b/backend/__pycache__/utilities.cpython-311.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..88703f67df7607a3424c03d289769601eb64eebe
Binary files /dev/null and b/backend/__pycache__/utilities.cpython-311.pyc differ
diff --git a/backend/__pycache__/utilities.cpython-314.pyc b/backend/__pycache__/utilities.cpython-314.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..223e08c05baec2de360ced9da668636e9fd6ecef
Binary files /dev/null and b/backend/__pycache__/utilities.cpython-314.pyc differ
diff --git a/backend/bg_remover/__init__.py b/backend/bg_remover/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..759f3450af085d5c8a5ceca0c7c336ee6b165f9c
--- /dev/null
+++ b/backend/bg_remover/__init__.py
@@ -0,0 +1 @@
+# Background remover subpackage
diff --git a/backend/bg_remover/__pycache__/__init__.cpython-311.pyc b/backend/bg_remover/__pycache__/__init__.cpython-311.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..f8b603bb8c99664e9ee4a6febfc0ec1b2871acf7
Binary files /dev/null and b/backend/bg_remover/__pycache__/__init__.cpython-311.pyc differ
diff --git a/backend/bg_remover/__pycache__/__init__.cpython-314.pyc b/backend/bg_remover/__pycache__/__init__.cpython-314.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..faa766761334cf591195dfebb2ca39976e4e7760
Binary files /dev/null and b/backend/bg_remover/__pycache__/__init__.cpython-314.pyc differ
diff --git a/backend/bg_remover/__pycache__/edge_detection.cpython-311.pyc b/backend/bg_remover/__pycache__/edge_detection.cpython-311.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..531cd560edc7cb74ff9ccd34a910f22662676beb
Binary files /dev/null and b/backend/bg_remover/__pycache__/edge_detection.cpython-311.pyc differ
diff --git a/backend/bg_remover/__pycache__/edge_detection.cpython-314.pyc b/backend/bg_remover/__pycache__/edge_detection.cpython-314.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..fb3e594b4c47312f0200de6be5cef6d84ee76434
Binary files /dev/null and b/backend/bg_remover/__pycache__/edge_detection.cpython-314.pyc differ
diff --git a/backend/bg_remover/__pycache__/image_processor.cpython-311.pyc b/backend/bg_remover/__pycache__/image_processor.cpython-311.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..1e1fb70a586d163bcdcb772c14ce9d47e612f5ee
Binary files /dev/null and b/backend/bg_remover/__pycache__/image_processor.cpython-311.pyc differ
diff --git a/backend/bg_remover/__pycache__/image_processor.cpython-314.pyc b/backend/bg_remover/__pycache__/image_processor.cpython-314.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..0053248d144e5e1fdd80df6d9e77d01a14c27339
Binary files /dev/null and b/backend/bg_remover/__pycache__/image_processor.cpython-314.pyc differ
diff --git a/backend/bg_remover/__pycache__/segmentation.cpython-311.pyc b/backend/bg_remover/__pycache__/segmentation.cpython-311.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..4d389ff0d728906400bd51bb60eb8bb6863f3c5b
Binary files /dev/null and b/backend/bg_remover/__pycache__/segmentation.cpython-311.pyc differ
diff --git a/backend/bg_remover/__pycache__/segmentation.cpython-314.pyc b/backend/bg_remover/__pycache__/segmentation.cpython-314.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..25f27f73f1acf869e12d8e6a0eaec2190efd7fbc
Binary files /dev/null and b/backend/bg_remover/__pycache__/segmentation.cpython-314.pyc differ
diff --git a/backend/bg_remover/__pycache__/shadow_generator.cpython-311.pyc b/backend/bg_remover/__pycache__/shadow_generator.cpython-311.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..af1c2b8671bf7b6511e8e504bc22c8cc97d0bdc1
Binary files /dev/null and b/backend/bg_remover/__pycache__/shadow_generator.cpython-311.pyc differ
diff --git a/backend/bg_remover/__pycache__/shadow_generator.cpython-314.pyc b/backend/bg_remover/__pycache__/shadow_generator.cpython-314.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..aedc084f698a3cecc65dcaf653b1867977d7afda
Binary files /dev/null and b/backend/bg_remover/__pycache__/shadow_generator.cpython-314.pyc differ
diff --git a/backend/bg_remover/edge_detection.py b/backend/bg_remover/edge_detection.py
new file mode 100644
index 0000000000000000000000000000000000000000..f5760254c566f7732290484062e7ab97254fc527
--- /dev/null
+++ b/backend/bg_remover/edge_detection.py
@@ -0,0 +1,85 @@
+import cv2
+import numpy as np
+
+def guided_filter(I: np.ndarray, p: np.ndarray, r: int, eps: float) -> np.ndarray:
+ """
+ Fast Guided Filter implementation for edge-preserving matting.
+ I: Guidance image (BGR, uint8)
+ p: Grayscale mask to filter (uint8 or float, normalized to [0, 1])
+ r: Local window radius
+ eps: Regularization parameter
+ """
+ if I.dtype == np.uint8:
+ I = I.astype(np.float32) / 255.0
+ else:
+ I = I.astype(np.float32)
+
+ p = p.astype(np.float32)
+
+ # Extract grayscale guidance for fast processing
+ if len(I.shape) == 3:
+ I_gray = cv2.cvtColor(I, cv2.COLOR_BGR2GRAY)
+ else:
+ I_gray = I
+
+ # Local window means
+ mean_I = cv2.boxFilter(I_gray, -1, (r, r))
+ mean_p = cv2.boxFilter(p, -1, (r, r))
+
+ mean_II = cv2.boxFilter(I_gray * I_gray, -1, (r, r))
+ mean_Ip = cv2.boxFilter(I_gray * p, -1, (r, r))
+
+ # Variance & Covariance
+ var_I = mean_II - mean_I * mean_I
+ cov_Ip = mean_Ip - mean_I * mean_p
+
+ # Solve linear coefficients
+ a = cov_Ip / (var_I + eps)
+ b = mean_p - a * mean_I
+
+ # Average coefficients
+ mean_a = cv2.boxFilter(a, -1, (r, r))
+ mean_b = cv2.boxFilter(b, -1, (r, r))
+
+ # Reconstruct filtered mask
+ q = mean_a * I_gray + mean_b
+ return np.clip(q, 0.0, 1.0)
+
+def refine_mask(mask: np.ndarray,
+ img: np.ndarray = None,
+ closing_size: int = 5,
+ keep_largest_only: bool = True,
+ feather_radius: int = 3,
+ matting_enabled: bool = False,
+ matting_radius: int = 10,
+ matting_eps: float = 1e-3) -> np.ndarray:
+ """
+ Refines a binary mask by morphological closing, speckle filtering, and edge matting/feathering.
+ """
+ h, w = mask.shape[:2]
+ refined = mask.copy()
+
+ # 1. Morphological closing to fill holes inside the object
+ if closing_size > 0:
+ kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (closing_size, closing_size))
+ refined = cv2.morphologyEx(refined, cv2.MORPH_CLOSE, kernel)
+ refined = cv2.morphologyEx(refined, cv2.MORPH_OPEN, kernel) # clean small edge noise
+
+ # 2. Keep only the largest connected component to eliminate floating background noise
+ if keep_largest_only:
+ num_labels, labels, stats, _ = cv2.connectedComponentsWithStats(refined)
+ if num_labels > 1:
+ largest_label = 1 + np.argmax(stats[1:, cv2.CC_STAT_AREA])
+ refined = np.where(labels == largest_label, 255, 0).astype(np.uint8)
+
+ # 3. Apply Edge Matting (Guided Filter) or Edge Feathering
+ if matting_enabled and img is not None:
+ mask_norm = refined.astype(np.float32) / 255.0
+ filtered_mask = guided_filter(img, mask_norm, matting_radius, matting_eps)
+ refined = (filtered_mask * 255.0).astype(np.uint8)
+ elif feather_radius > 0:
+ ksize = 2 * feather_radius + 1
+ refined_floats = cv2.GaussianBlur(refined.astype(float), (ksize, ksize), 0)
+ refined = np.clip(refined_floats, 0, 255).astype(np.uint8)
+
+ return refined
diff --git a/backend/bg_remover/image_processor.py b/backend/bg_remover/image_processor.py
new file mode 100644
index 0000000000000000000000000000000000000000..fe398e06c0f1f6c51f1ef0457a108f7512827158
--- /dev/null
+++ b/backend/bg_remover/image_processor.py
@@ -0,0 +1,194 @@
+import cv2
+import numpy as np
+from PIL import Image
+from backend.utilities import pil_to_cv, cv_to_pil, resize_for_processing
+from backend.bg_remover.segmentation import detect_automatic_bounding_box, run_grabcut
+from backend.bg_remover.edge_detection import refine_mask
+from backend.bg_remover.shadow_generator import generate_drop_shadow
+
+try:
+ from rembg import remove as rembg_remove
+ REMBG_AVAILABLE = True
+except ImportError:
+ REMBG_AVAILABLE = False
+
+_session_cache = {}
+
+def get_rembg_session(model_name: str):
+ """Retrieves or initializes a cached rembg model session to prevent reloading weights."""
+ global _session_cache
+ if model_name not in _session_cache:
+ try:
+ from rembg import new_session
+ _session_cache[model_name] = new_session(model_name)
+ except Exception as e:
+ _session_cache[model_name] = None
+ return _session_cache[model_name]
+
+class ImageProcessor:
+ """
+ High-level orchestrator class to execute the background removal image processing pipeline.
+ """
+ @staticmethod
+ def process_image(
+ pil_image: Image.Image,
+ rect: tuple = None,
+ margin_percentage: float = 5.0,
+ iter_count: int = 5,
+ bg_seed_sensitivity: float = 35.0,
+ closing_size: int = 5,
+ keep_largest_only: bool = True,
+ feather_radius: int = 3,
+ matting_enabled: bool = True,
+ matting_radius: int = 10,
+ matting_eps: float = 1e-3,
+ shadow_enabled: bool = False,
+ shadow_opacity: float = 0.5,
+ shadow_blur: int = 15,
+ shadow_distance: int = 20,
+ shadow_angle: float = 45.0,
+ max_preview_dim: int = None,
+ subject_mode: str = "AI Neural Network (U²-Net)"
+ ) -> dict:
+ """
+ Processes the input PIL image and returns a dictionary of output PIL images.
+ """
+ # 1. Automatically normalize EXIF camera orientation tags
+ from PIL import ImageOps
+ pil_image = ImageOps.exif_transpose(pil_image)
+
+ # Convert PIL to OpenCV (BGR)
+ cv_raw = pil_to_cv(pil_image)
+
+ # 2. Downscale for interactive preview speed if requested
+ if max_preview_dim is not None:
+ cv_img = resize_for_processing(cv_raw, max_preview_dim)
+ else:
+ cv_img = cv_raw.copy()
+
+ h, w = cv_img.shape[:2]
+
+ # 3. Bounding Box & Segmentation Determination
+ is_neural = (subject_mode in ["AI BiRefNet (SOTA General)", "AI U²-Net (Legacy Neural)", "AI Neural Network (U²-Net)"] and REMBG_AVAILABLE)
+
+ if is_neural:
+ try:
+ # Resolve correct model session
+ if "BiRefNet" in subject_mode:
+ session = get_rembg_session("birefnet-general")
+ else:
+ session = get_rembg_session("u2net")
+
+ if max_preview_dim is not None:
+ w_p, h_p = cv_img.shape[1], cv_img.shape[0]
+ pil_preview = pil_image.resize((w_p, h_p), Image.Resampling.LANCZOS)
+ cutout_pil = rembg_remove(pil_preview, session=session)
+ else:
+ cutout_pil = rembg_remove(pil_image, session=session)
+
+ cv_cutout = pil_to_cv(cutout_pil)
+ refined_mask = cv_cutout[:, :, 3].copy()
+
+ # Resilient shape normalization to handle EXIF or transpose mismatches from rembg
+ if refined_mask.shape[:2] != (h, w):
+ if refined_mask.shape[0] == w and refined_mask.shape[1] == h:
+ refined_mask = refined_mask.T
+ else:
+ refined_mask = cv2.resize(refined_mask, (w, h), interpolation=cv2.INTER_LINEAR)
+
+ actual_rect = (0, 0, w, h)
+ except Exception as e:
+ is_neural = False
+
+ if not is_neural:
+ if subject_mode == "Signature & Text (Ink)":
+ pw = max(2, min(20, w // 20))
+ ph = max(2, min(20, h // 20))
+ c_tl = np.mean(cv_img[0:ph, 0:pw, :3], axis=(0, 1))
+ c_tr = np.mean(cv_img[0:ph, w-pw:w, :3], axis=(0, 1))
+ c_bg = (c_tl + c_tr) / 2.0
+
+ dist = np.sqrt(np.sum((cv_img[:, :, :3] - c_bg) ** 2, axis=2))
+
+ low_t = 15.0
+ high_t = 45.0
+ alpha = np.clip((dist - low_t) / (high_t - low_t) * 255.0, 0, 255).astype(np.uint8)
+
+ refined_mask = alpha
+ if closing_size > 0:
+ kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (closing_size, closing_size))
+ refined_mask = cv2.morphologyEx(refined_mask, cv2.MORPH_CLOSE, kernel)
+
+ actual_rect = (0, 0, w, h)
+ else:
+ if rect is None:
+ actual_rect = detect_automatic_bounding_box(cv_img, margin_percentage)
+ else:
+ if max_preview_dim is not None:
+ orig_h, orig_w = cv_raw.shape[:2]
+ scale_x = w / orig_w
+ scale_y = h / orig_h
+ rx, ry, rw, rh = rect
+ actual_rect = (
+ int(rx * scale_x),
+ int(ry * scale_y),
+ int(rw * scale_x),
+ int(rh * scale_y)
+ )
+ else:
+ actual_rect = rect
+
+ raw_mask = run_grabcut(cv_img, actual_rect, iter_count, bg_seed_sensitivity=bg_seed_sensitivity)
+
+ refined_mask = refine_mask(
+ mask=raw_mask,
+ img=cv_img,
+ closing_size=closing_size,
+ keep_largest_only=keep_largest_only,
+ feather_radius=feather_radius,
+ matting_enabled=matting_enabled,
+ matting_radius=matting_radius,
+ matting_eps=matting_eps
+ )
+
+ # 6. Generate Transparent PNG Cutout
+ cutout = np.zeros((h, w, 4), dtype=np.uint8)
+ cutout[:, :, :3] = cv_img[:, :, :3]
+ cutout[:, :, 3] = refined_mask
+
+ # 7. Generate Drop Shadow Composite
+ if shadow_enabled:
+ shadow_composite = generate_drop_shadow(
+ cv_img,
+ refined_mask,
+ opacity=shadow_opacity,
+ blur_radius=shadow_blur,
+ distance=shadow_distance,
+ angle_degrees=shadow_angle
+ )
+ else:
+ shadow_composite = cutout
+
+ # 8. Scale Bounding Box back to original coords if resized (for UI display overlay)
+ if max_preview_dim is not None:
+ orig_h, orig_w = cv_raw.shape[:2]
+ scale_x = orig_w / w
+ scale_y = orig_h / h
+ ax, ay, aw, ah = actual_rect
+ rect_out = (
+ int(ax * scale_x),
+ int(ay * scale_y),
+ int(aw * scale_x),
+ int(ah * scale_y)
+ )
+ else:
+ rect_out = actual_rect
+
+ # 9. Convert outputs back to PIL
+ return {
+ "original": cv_to_pil(cv_img),
+ "mask": Image.fromarray(refined_mask).convert("L"),
+ "transparent": cv_to_pil(cutout),
+ "shadow": cv_to_pil(shadow_composite),
+ "rect": rect_out
+ }
diff --git a/backend/bg_remover/segmentation.py b/backend/bg_remover/segmentation.py
new file mode 100644
index 0000000000000000000000000000000000000000..ef0f1209015ffc2f00424b4a9f158e5f92de82f6
--- /dev/null
+++ b/backend/bg_remover/segmentation.py
@@ -0,0 +1,137 @@
+import cv2
+import numpy as np
+
+def detect_automatic_bounding_box(img: np.ndarray, margin_percentage: float = 5.0) -> tuple:
+ """
+ Detect the main subject's bounding box using edge density and contour analysis.
+ Filters out full-frame border contours.
+ """
+ h, w = img.shape[:2]
+
+ # 1. Preprocess: Convert to grayscale and blur
+ gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
+ blurred = cv2.GaussianBlur(gray, (5, 5), 0)
+
+ # 2. Compute edge density using Canny
+ edges = cv2.Canny(blurred, 30, 100)
+
+ # 3. Morphological closing to join close edge segments
+ kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (15, 15))
+ closed = cv2.morphologyEx(edges, cv2.MORPH_CLOSE, kernel)
+ dilated = cv2.dilate(closed, kernel, iterations=2)
+
+ # 4. Find contours of the edge-dense regions
+ contours, _ = cv2.findContours(dilated, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
+
+ # If contours exist, find the bounding box of the largest ones
+ if contours:
+ # Filter contours by area and ignore frame border contours that cover the entire image
+ valid_contours = []
+ for c in contours:
+ cx, cy, ccw, cch = cv2.boundingRect(c)
+ if ccw >= w - 10 and cch >= h - 10:
+ continue
+ if cv2.contourArea(c) > (w * h * 0.002):
+ valid_contours.append(c)
+
+ # Sort valid contours by area descending
+ valid_contours = sorted(valid_contours, key=cv2.contourArea, reverse=True)
+
+ if valid_contours:
+ # Get the unified bounding box of the significant contours
+ x_min, y_min = w, h
+ x_max, y_max = 0, 0
+ for c in valid_contours[:3]: # Top 3 largest contours
+ x, y, cw, ch = cv2.boundingRect(c)
+ x_min = min(x_min, x)
+ y_min = min(y_min, y)
+ x_max = max(x_max, x + cw)
+ y_max = max(y_max, y + ch)
+
+ # Add a slight padding/margin
+ pad_x = int((x_max - x_min) * 0.05)
+ pad_y = int((y_max - y_min) * 0.05)
+
+ x = max(0, x_min - pad_x)
+ y = max(0, y_min - pad_y)
+ cw = min(w - x, (x_max - x_min) + 2 * pad_x)
+ ch = min(h - y, (y_max - y_min) + 2 * pad_y)
+
+ # Ensure it is valid
+ if cw > 10 and ch > 10:
+ return (x, y, cw, ch)
+
+ # Fallback: Center-based bounding box with specified margin
+ margin_w = int(w * (margin_percentage / 100.0))
+ margin_h = int(h * (margin_percentage / 100.0))
+
+ x = margin_w
+ y = margin_h
+ cw = w - (2 * margin_w)
+ ch = h - (2 * margin_h)
+
+ return (x, y, cw, ch)
+
+def run_grabcut(img: np.ndarray, rect: tuple, iter_count: int = 5, bg_seed_sensitivity: float = 35.0) -> np.ndarray:
+ """
+ Run GrabCut algorithm on BGR image using the provided bounding box.
+ Optionally initializes the GrabCut mask with background seeds from the top corners.
+ """
+ h, w = img.shape[:2]
+
+ # Ensure rect is valid and within image boundaries
+ rx, ry, rw, rh = rect
+ rx = max(0, min(rx, w - 2))
+ ry = max(0, min(ry, h - 2))
+ rw = max(1, min(rw, w - rx))
+ rh = max(1, min(rh, h - ry))
+ safe_rect = (rx, ry, rw, rh)
+
+ # Initialize GrabCut background/foreground models
+ bgd_model = np.zeros((1, 65), dtype=np.float64)
+ fgd_model = np.zeros((1, 65), dtype=np.float64)
+
+ # Create GrabCut mask
+ mask = np.zeros((h, w), dtype=np.uint8)
+
+ try:
+ if bg_seed_sensitivity > 0.0 and len(img.shape) == 3:
+ # 1. Initialize entire mask inside the bounding box as Probable Foreground (3)
+ # and outside as Sure Background (0)
+ cv2.rectangle(mask, (rx, ry), (rx + rw, ry + rh), cv2.GC_PR_FGD, -1)
+
+ # 2. Sample only top corner regions (Top-Left and Top-Right) to detect background color
+ pw = max(2, min(20, w // 20))
+ ph = max(2, min(20, h // 20))
+
+ # Extract corner colors (BGR channels average)
+ c_tl = np.mean(img[0:ph, 0:pw, :3], axis=(0, 1))
+ c_tr = np.mean(img[0:ph, w-pw:w, :3], axis=(0, 1))
+
+ # 3. For each pixel, compute distance to nearest corner color
+ diff_tl = np.sqrt(np.sum((img[:, :, :3] - c_tl) ** 2, axis=2))
+ diff_tr = np.sqrt(np.sum((img[:, :, :3] - c_tr) ** 2, axis=2))
+
+ min_diff = np.minimum(diff_tl, diff_tr)
+
+ # 4. Mark pixels inside safe_rect that are very close to corner colors as Probable Background (2)
+ bg_mask = (min_diff < bg_seed_sensitivity)
+ bbox_mask = np.zeros((h, w), dtype=bool)
+ bbox_mask[ry:ry+rh, rx:rx+rw] = True
+
+ # Final probable background assignments
+ mask[bbox_mask & bg_mask] = cv2.GC_PR_BGD
+
+ # Run GrabCut in MASK mode
+ cv2.grabCut(img, mask, safe_rect, bgd_model, fgd_model, iter_count, cv2.GC_INIT_WITH_MASK)
+ else:
+ # Traditional RECT-only initialization
+ cv2.grabCut(img, mask, safe_rect, bgd_model, fgd_model, iter_count, cv2.GC_INIT_WITH_RECT)
+
+ # Convert output mask to binary
+ binary_mask = np.where((mask == cv2.GC_FGD) | (mask == cv2.GC_PR_FGD), 255, 0).astype(np.uint8)
+ return binary_mask
+ except Exception as e:
+ fallback_mask = np.zeros((h, w), dtype=np.uint8)
+ cv2.rectangle(fallback_mask, (rx, ry), (rx + rw, ry + rh), 255, -1)
+ return fallback_mask
diff --git a/backend/bg_remover/shadow_generator.py b/backend/bg_remover/shadow_generator.py
new file mode 100644
index 0000000000000000000000000000000000000000..7118ba1f1d5eeb57980a049b8b5510cfa700d914
--- /dev/null
+++ b/backend/bg_remover/shadow_generator.py
@@ -0,0 +1,68 @@
+import cv2
+import numpy as np
+import math
+
+def blend_rgba(top: np.ndarray, bottom: np.ndarray) -> np.ndarray:
+ """
+ Perform alpha blending of an RGBA image 'top' over an RGBA image 'bottom'.
+ """
+ top_a = top[:, :, 3:4].astype(float) / 255.0
+ bottom_a = bottom[:, :, 3:4].astype(float) / 255.0
+
+ out_a = top_a + bottom_a * (1.0 - top_a)
+ out_a_safe = np.where(out_a == 0, 1.0, out_a)
+
+ top_rgb = top[:, :, :3].astype(float)
+ bottom_rgb = bottom[:, :, :3].astype(float)
+
+ out_rgb = (top_rgb * top_a + bottom_rgb * bottom_a * (1.0 - top_a)) / out_a_safe
+
+ out_img = np.zeros_like(top)
+ out_img[:, :, :3] = np.clip(out_rgb, 0, 255).astype(np.uint8)
+ out_img[:, :, 3] = np.clip(out_a * 255, 0, 255).astype(np.uint8)[:, :, 0]
+
+ return out_img
+
+def generate_drop_shadow(img: np.ndarray,
+ mask: np.ndarray,
+ opacity: float = 0.5,
+ blur_radius: int = 15,
+ distance: int = 20,
+ angle_degrees: float = 45.0) -> np.ndarray:
+ """
+ Generate a realistic drop shadow layer behind the foreground cutout.
+ """
+ h, w = mask.shape[:2]
+
+ cutout = np.zeros((h, w, 4), dtype=np.uint8)
+ if img.shape[2] == 4:
+ cutout[:, :, :3] = img[:, :, :3]
+ else:
+ cutout[:, :, :3] = img
+ cutout[:, :, 3] = mask
+
+ if opacity <= 0.0 or (distance <= 0 and blur_radius <= 0):
+ return cutout
+
+ shadow = np.zeros((h, w, 4), dtype=np.uint8)
+ shadow[:, :, 3] = mask
+
+ angle_radians = math.radians(angle_degrees)
+ dx = int(distance * math.cos(angle_radians))
+ dy = int(distance * math.sin(angle_radians))
+
+ M = np.float32([[1, 0, dx], [0, 1, dy]])
+ translated_shadow = cv2.warpAffine(
+ shadow, M, (w, h),
+ borderMode=cv2.BORDER_CONSTANT,
+ borderValue=(0, 0, 0, 0)
+ )
+
+ if blur_radius > 0:
+ ksize = 2 * blur_radius + 1
+ translated_shadow = cv2.GaussianBlur(translated_shadow, (ksize, ksize), 0)
+
+ translated_shadow[:, :, 3] = (translated_shadow[:, :, 3] * opacity).astype(np.uint8)
+
+ final_composite = blend_rgba(top=cutout, bottom=translated_shadow)
+ return final_composite
diff --git a/backend/dslr_blur/__pycache__/blur_processor.cpython-311.pyc b/backend/dslr_blur/__pycache__/blur_processor.cpython-311.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..af4ff41d05a0958d0c81f956abab10a57bdc8aa5
Binary files /dev/null and b/backend/dslr_blur/__pycache__/blur_processor.cpython-311.pyc differ
diff --git a/backend/dslr_blur/__pycache__/depth_blur.cpython-311.pyc b/backend/dslr_blur/__pycache__/depth_blur.cpython-311.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..adc305d0c8f0df10e5ac9a1e8a61347d9679fdec
Binary files /dev/null and b/backend/dslr_blur/__pycache__/depth_blur.cpython-311.pyc differ
diff --git a/backend/dslr_blur/blur_processor.py b/backend/dslr_blur/blur_processor.py
new file mode 100644
index 0000000000000000000000000000000000000000..bcbde06d011c4d416ee29f3afe0a51941eeeafb4
--- /dev/null
+++ b/backend/dslr_blur/blur_processor.py
@@ -0,0 +1,172 @@
+import cv2
+import numpy as np
+from PIL import Image
+from backend.utilities import pil_to_cv, cv_to_pil
+
+class DSLRBlurProcessor:
+ """
+ Orchestrator class to execute the DSLR Background Blur image processing pipeline.
+ """
+
+ @staticmethod
+ def apply_feathering(mask: np.ndarray, radius: int) -> np.ndarray:
+ """
+ Feathers the binary mask to create a soft, anti-aliased edge transition.
+ Returns a float32 mask scaled between 0.0 and 1.0.
+ """
+ if radius <= 0:
+ return mask.astype(np.float32) / 255.0
+
+ # Ensure kernel size is odd
+ k_size = radius * 2 + 1
+ feathered = cv2.GaussianBlur(mask, (k_size, k_size), 0)
+ return feathered.astype(np.float32) / 255.0
+
+ @staticmethod
+ def inpaint_background(img: np.ndarray, mask: np.ndarray) -> np.ndarray:
+ """
+ Inpaints/erases the foreground subject out of the background.
+ Uses a highly optimized downscaled inpainting approach to prevent color bleeding
+ and edge-halos when the background gets blurred.
+ """
+ h, w = img.shape[:2]
+
+ # 1. Dilate the mask by 15px to fully cover edge transition and anti-aliasing zones
+ kernel_size = 15
+ kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (kernel_size, kernel_size))
+ dilated_mask = cv2.dilate(mask, kernel, iterations=1)
+
+ # 2. Downscale the image and mask to 25% size for lightning-fast inpainting
+ scale = 0.25
+ down_w = int(w * scale)
+ down_h = int(h * scale)
+
+ img_small = cv2.resize(img, (down_w, down_h), interpolation=cv2.INTER_AREA)
+ mask_small = cv2.resize(dilated_mask, (down_w, down_h), interpolation=cv2.INTER_NEAREST)
+
+ # 3. Perform Fast Telea inpainting on the downscaled image
+ inpainted_small = cv2.inpaint(img_small, mask_small, 5, cv2.INPAINT_TELEA)
+
+ # 4. Upscale back to the original image dimensions
+ inpainted = cv2.resize(inpainted_small, (w, h), interpolation=cv2.INTER_CUBIC)
+
+ # 5. Composite back the real background pixels (keeping inpainted pixels only under dilated mask)
+ bg_only = img.copy()
+ mask_indices = dilated_mask > 0
+ bg_only[mask_indices] = inpainted[mask_indices]
+
+ return bg_only
+
+ @staticmethod
+ def apply_blur(img: np.ndarray, mode: str, strength: float, smoothness: float) -> np.ndarray:
+ """
+ Applies a natural, aesthetically pleasing blur to the background.
+ Supports:
+ - "Gaussian Blur (Soft & Smooth)"
+ - "Lens Blur / Circular Bokeh (Realistic DSLR)"
+ """
+ # Map strength (1 - 100) to actual kernel/radius dimensions
+ # For Gaussian: map to odd numbers from 3 to 101
+ g_strength = int(strength / 100.0 * 50.0) * 2 + 1
+ g_strength = max(3, g_strength)
+
+ # For Circular Bokeh: map circular kernel diameter from 3 to 61
+ l_diameter = int(strength / 100.0 * 30.0) * 2 + 1
+ l_diameter = max(3, l_diameter)
+
+ if mode == "Lens Blur / Circular Bokeh (Realistic DSLR)":
+ # Create a flat circular convolution kernel representing lens aperture
+ kernel = np.zeros((l_diameter, l_diameter), dtype=np.float32)
+ cv2.circle(kernel, (l_diameter // 2, l_diameter // 2), l_diameter // 2, 1, -1)
+
+ # Normalize the kernel
+ kernel_sum = np.sum(kernel)
+ if kernel_sum > 0:
+ kernel /= kernel_sum
+ else:
+ kernel[l_diameter // 2, l_diameter // 2] = 1.0
+
+ # Convolve background to form circular bokeh discs
+ blurred = cv2.filter2D(img, -1, kernel)
+ else:
+ # Gaussian Blur
+ blurred = cv2.GaussianBlur(img, (g_strength, g_strength), 0)
+
+ # Bilateral filter post-smoothing for a creamy, noise-free studio look
+ if smoothness > 0:
+ d = int(smoothness / 100.0 * 15)
+ d = max(3, d | 1) # must be odd
+ sigma_color = smoothness / 100.0 * 150.0
+ sigma_space = smoothness / 100.0 * 150.0
+ blurred = cv2.bilateralFilter(blurred, d, sigma_color, sigma_space)
+
+ return blurred
+
+ @staticmethod
+ def composite_layers(
+ fg_img: np.ndarray,
+ bg_img: np.ndarray,
+ alpha: np.ndarray,
+ subject_protection: float
+ ) -> np.ndarray:
+ """
+ Composites the sharp foreground subject over the blurred background.
+ alpha: float32 grayscale feathered mask in range [0, 1.0]. Shape is (H, W).
+ subject_protection: float (0 - 100) -> Protects original fine details.
+ """
+ # Expand alpha to 3 channels for RGB broadcasting
+ alpha_3d = np.expand_dims(alpha, axis=2)
+
+ # Subject Protection clamps the minimum alpha of subject pixels to prevent them blurring
+ if subject_protection > 0:
+ protection_factor = subject_protection / 100.0
+ mask_fg = alpha > 0.05
+ alpha_3d[mask_fg] = np.maximum(alpha_3d[mask_fg], protection_factor)
+
+ # Alpha blend: out = fg * alpha + bg * (1 - alpha)
+ composited = fg_img.astype(np.float32) * alpha_3d + bg_img.astype(np.float32) * (1.0 - alpha_3d)
+ return np.clip(composited, 0, 255).astype(np.uint8)
+
+ @classmethod
+ def process_dslr_blur(
+ cls,
+ pil_image: Image.Image,
+ mask_pil: Image.Image,
+ blur_mode: str = "Lens Blur / Circular Bokeh (Realistic DSLR)",
+ blur_strength: float = 30.0,
+ edge_feathering: int = 5,
+ subject_protection: float = 80.0,
+ background_smoothness: float = 30.0
+ ) -> Image.Image:
+ """
+ Main entry point to execute the DSLR Background Blur pipeline.
+ """
+ # 1. Convert to CV BGR/BGRA arrays
+ cv_img = pil_to_cv(pil_image)
+ mask = np.array(mask_pil.convert("L"))
+
+ # Ensure matching shapes
+ h, w = cv_img.shape[:2]
+ if mask.shape[:2] != (h, w):
+ mask = cv2.resize(mask, (w, h), interpolation=cv2.INTER_NEAREST)
+
+ # 2. Feather the mask to create anti-aliased subject edges
+ alpha = cls.apply_feathering(mask, edge_feathering)
+
+ # 3. Inpaint the background to erase the subject and prevent colored edge halos/bleeding
+ bg_inpainted = cls.inpaint_background(cv_img[:, :, :3], mask)
+
+ # 4. Apply Gaussian or circular lens bokeh blur to the background
+ bg_blurred = cls.apply_blur(bg_inpainted, blur_mode, blur_strength, background_smoothness)
+
+ # 5. Composite original sharp subject over the blurred background using feathered alpha
+ result_cv = cls.composite_layers(cv_img[:, :, :3], bg_blurred, alpha, subject_protection)
+
+ # 6. Re-apply alpha channel if original image was RGBA
+ if cv_img.shape[2] == 4:
+ result_rgba = np.zeros((h, w, 4), dtype=np.uint8)
+ result_rgba[:, :, :3] = result_cv
+ result_rgba[:, :, 3] = cv_img[:, :, 3]
+ return cv_to_pil(result_rgba)
+ else:
+ return cv_to_pil(result_cv)
diff --git a/backend/dslr_blur/depth_blur.py b/backend/dslr_blur/depth_blur.py
new file mode 100644
index 0000000000000000000000000000000000000000..c57554883ac86a201d11587a039712b4c4682dd4
--- /dev/null
+++ b/backend/dslr_blur/depth_blur.py
@@ -0,0 +1,170 @@
+import cv2
+import numpy as np
+from PIL import Image
+from backend.utilities import pil_to_cv, cv_to_pil
+from backend.bg_remover.image_processor import ImageProcessor
+from backend.dslr_blur.blur_processor import DSLRBlurProcessor
+from backend.enhancement.onnx_engine import ONNXInferenceEngine
+
+class DepthBlurEngine:
+ """
+ Fuses Depth Anything V2 depth maps and BiRefNet masks to execute
+ spatially-varying, optical DSLR depth-of-field blurs.
+ """
+
+ @classmethod
+ def generate_synthetic_depth_map(cls, h: int, w: int, mask: np.ndarray) -> np.ndarray:
+ """
+ Fallback generator that constructs a smooth vertical linear perspective depth map
+ in case the neural ONNX model is offline or unavailable.
+ """
+ # Create vertical gradient: 0 at top (infinity), 255 at bottom (foreground ground plane)
+ y, x = np.indices((h, w))
+ depth = (y / h * 255.0).astype(np.uint8)
+
+ # Clamp subject pixels to foreground (240) to keep the focus plane sharp
+ depth[mask > 127] = 230
+ return depth
+
+ @classmethod
+ def run_depth_estimation(cls, cv_img: np.ndarray, mask: np.ndarray) -> np.ndarray:
+ """
+ Attempts to run Depth Anything V2 neural depth estimation with a graceful
+ vertical linear perspective fallback on network/load failures.
+ """
+ h, w = cv_img.shape[:2]
+ try:
+ # Try running ONNX Depth Anything session
+ depth_map = ONNXInferenceEngine.run_depth_anything(cv_img[:, :, :3])
+ return depth_map
+ except Exception as e:
+ print(f"[Depth Engine] Depth Anything V2 failed: {e}. Generating synthetic linear depth gradient.")
+ return cls.generate_synthetic_depth_map(h, w, mask)
+
+ @classmethod
+ def process_depth_blur(
+ cls,
+ pil_image: Image.Image,
+ mask_pil: Image.Image,
+ blur_mode: str = "Lens Blur / Circular Bokeh (Realistic DSLR)",
+ blur_preset: str = "DSLR 85mm",
+ blur_strength: float = 45.0,
+ edge_feathering: int = 5,
+ subject_protection: float = 85.0,
+ background_smoothness: float = 30.0
+ ) -> dict:
+ """
+ Executes the complete spatially-varying optical depth blur pipeline.
+ Returns a dictionary containing:
+ - "result": PIL composite image with depth blur
+ - "depth_map": PIL depth map image (for visual developer previews)
+ """
+ # 1. Convert formats
+ cv_img = pil_to_cv(pil_image)
+ mask = np.array(mask_pil.convert("L"))
+ h, w = cv_img.shape[:2]
+
+ # Ensure matching shapes
+ if mask.shape[:2] != (h, w):
+ mask = cv2.resize(mask, (w, h), interpolation=cv2.INTER_NEAREST)
+
+ # 2. Pre-generate or run depth estimation
+ depth = cls.run_depth_estimation(cv_img, mask)
+
+ # 3. Soft-feather the mask
+ alpha = DSLRBlurProcessor.apply_feathering(mask, edge_feathering)
+
+ # 4. Calibrate Focus Plane Depth (D_subject) to the isolated subject
+ subject_pixels = depth[mask > 127]
+ if len(subject_pixels) > 0:
+ D_subject = float(np.median(subject_pixels))
+ else:
+ D_subject = 220.0 # Default foreground depth focus
+
+ # 5. Map Preset parameters to Max Blur Strength
+ # Presets: Portrait, DSLR 50mm, DSLR 85mm, Studio, Cinematic, Custom
+ preset_max_blur = {
+ "Portrait": 25.0,
+ "DSLR 50mm": 40.0,
+ "DSLR 85mm": 55.0,
+ "Studio": 20.0,
+ "Cinematic": 75.0,
+ "Custom": blur_strength
+ }
+ max_blur = preset_max_blur.get(blur_preset, blur_strength)
+
+ # 6. Calculate Spatially-Varying Blur Radius map
+ # Dist from focus plane scaled [0.0, 1.0]
+ dist_from_focus = np.abs(depth.astype(np.float32) - D_subject) / 255.0
+
+ # Blur radius maps from 0 to max_blur (subject has 0 blur because of (1 - alpha))
+ radius_map = max_blur * (1.0 - alpha) * dist_from_focus
+
+ # 7. Reconstruct Background to prevent colored halos/subject bleeding
+ bg_inpainted = DSLRBlurProcessor.inpaint_background(cv_img[:, :, :3], mask)
+
+ # 8. Vectorized N-Layer Depth-of-Field Blending
+ # Generate N=6 discrete blur radius stops for linear interpolation
+ radii = [0, int(0.15 * max_blur), int(0.35 * max_blur), int(0.60 * max_blur), int(0.85 * max_blur), int(max_blur)]
+ # Filter duplicates and ensure they are sorted ascending
+ radii = sorted(list(set(radii)))
+
+ # Pre-compute N blurred background layers
+ blurred_layers = []
+ for r in radii:
+ if r == 0:
+ blurred_layers.append(bg_inpainted)
+ else:
+ # Ensure the radius maps to an odd number internally in apply_blur
+ blurred_layers.append(DSLRBlurProcessor.apply_blur(bg_inpainted, blur_mode, r, background_smoothness))
+
+ # Perform vectorized linear interpolation across depth intervals
+ composite_bg = np.zeros_like(cv_img[:, :, :3], dtype=np.float32)
+
+ for k in range(len(radii) - 1):
+ r_low = radii[k]
+ r_high = radii[k+1]
+
+ # Identify pixels whose target blur radius falls inside this interval
+ if k == len(radii) - 2:
+ interval_mask = (radius_map >= r_low) & (radius_map <= r_high)
+ else:
+ interval_mask = (radius_map >= r_low) & (radius_map < r_high)
+
+ if not np.any(interval_mask):
+ continue
+
+ # Grab target radii values in this interval
+ r_vals = radius_map[interval_mask]
+
+ # Linear interpolation weight: w = 1.0 at low_radius, 0.0 at high_radius
+ w_interp = (r_high - r_vals) / (r_high - r_low)
+ w_interp_3d = np.expand_dims(w_interp, axis=1) # shape (num_pixels, 1)
+
+ # Slice pixels from adjacent blurred layers
+ low_pixels = blurred_layers[k][interval_mask].astype(np.float32)
+ high_pixels = blurred_layers[k+1][interval_mask].astype(np.float32)
+
+ # Blend pixels and write back
+ blended_pixels = low_pixels * w_interp_3d + high_pixels * (1.0 - w_interp_3d)
+ composite_bg[interval_mask] = blended_pixels
+
+ # Clip to valid range and cast
+ bg_depth_blurred = np.clip(composite_bg, 0, 255).astype(np.uint8)
+
+ # 9. Composite the original sharp subject over this depth-blurred background
+ result_cv = DSLRBlurProcessor.composite_layers(cv_img[:, :, :3], bg_depth_blurred, alpha, subject_protection)
+
+ # 10. Re-apply alpha transparency if present
+ if cv_img.shape[2] == 4:
+ result_rgba = np.zeros((h, w, 4), dtype=np.uint8)
+ result_rgba[:, :, :3] = result_cv
+ result_rgba[:, :, 3] = cv_img[:, :, 3]
+ output_pil = cv_to_pil(result_rgba)
+ else:
+ output_pil = cv_to_pil(result_cv)
+
+ return {
+ "result": output_pil,
+ "depth_map": Image.fromarray(depth).convert("L")
+ }
diff --git a/backend/enhancement/__pycache__/onnx_engine.cpython-311.pyc b/backend/enhancement/__pycache__/onnx_engine.cpython-311.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..dc77b38207d6ca794a4c41e92ad607fcae1a4681
Binary files /dev/null and b/backend/enhancement/__pycache__/onnx_engine.cpython-311.pyc differ
diff --git a/backend/enhancement/__pycache__/photo_enhancer.cpython-311.pyc b/backend/enhancement/__pycache__/photo_enhancer.cpython-311.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..9fc6a3f901bb80ad823c45bf915be2e4f3b3218c
Binary files /dev/null and b/backend/enhancement/__pycache__/photo_enhancer.cpython-311.pyc differ
diff --git a/backend/enhancement/onnx_engine.py b/backend/enhancement/onnx_engine.py
new file mode 100644
index 0000000000000000000000000000000000000000..fdd3636fc1222c240ae84c3202c0a58cc50a307f
--- /dev/null
+++ b/backend/enhancement/onnx_engine.py
@@ -0,0 +1,205 @@
+import os
+import cv2
+import numpy as np
+import urllib.request
+from PIL import Image
+
+class ONNXInferenceEngine:
+ """
+ Handles downloading, caching, and inference of ONNX models for:
+ - Depth Anything V2 (Depth Estimation)
+ - GFPGAN v1.4 (Face Restoration)
+ - Real-ESRGAN x2 (Super Resolution & Artifact Removal)
+ """
+
+ MODELS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "models")
+
+ MODEL_URLS = {
+ "depth_anything": "https://huggingface.co/onnx-community/depth-anything-v2-small/resolve/main/onnx/model.onnx", # 97 MB
+ "gfpgan": "https://huggingface.co/Gourieff/ReActor/resolve/main/models/facerestore_models/GFPGANv1.4.onnx", # 140 MB
+ "realesrgan": "https://huggingface.co/sberbank-ai/Real-ESRGAN/resolve/main/RealESRGAN_x2.onnx" # 67 MB
+ }
+
+ @classmethod
+ def get_model_path(cls, model_key: str) -> str:
+ """Gets local path to the model, downloading it if not present."""
+ os.makedirs(cls.MODELS_DIR, exist_ok=True)
+ filename = f"{model_key}.onnx"
+ dest_path = os.path.join(cls.MODELS_DIR, filename)
+
+ if os.path.exists(dest_path) and os.path.getsize(dest_path) > 10 * 1024 * 1024:
+ return dest_path
+
+ url = cls.MODEL_URLS[model_key]
+ temp_path = dest_path + ".tmp"
+
+ print(f"[ONNX Engine] Downloading {model_key} model weights (~{cls._get_size_str(model_key)})...")
+ try:
+ req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
+ with urllib.request.urlopen(req) as response, open(temp_path, "wb") as out_file:
+ block_size = 1024 * 1024 # 1 MB chunks
+ while True:
+ buffer = response.read(block_size)
+ if not buffer:
+ break
+ out_file.write(buffer)
+ os.replace(temp_path, dest_path)
+ print(f"[ONNX Engine] Successfully downloaded and cached {model_key} model.")
+ return dest_path
+ except Exception as e:
+ if os.path.exists(temp_path):
+ os.remove(temp_path)
+ print(f"[ONNX Engine] Failed to download {model_key}: {e}")
+ raise e
+
+ @staticmethod
+ def _get_size_str(model_key: str) -> str:
+ sizes = {"depth_anything": "97MB", "gfpgan": "140MB", "realesrgan": "67MB"}
+ return sizes.get(model_key, "Unknown")
+
+ @classmethod
+ def get_session(cls, model_key: str):
+ """Initializes and returns an ONNX Runtime inference session."""
+ try:
+ import onnxruntime as ort
+ model_path = cls.get_model_path(model_key)
+ # Use CPU execution provider with optimized thread configurations
+ sess_options = ort.SessionOptions()
+ sess_options.intra_op_num_threads = 2
+ sess_options.execution_mode = ort.ExecutionMode.ORT_SEQUENTIAL
+ session = ort.InferenceSession(model_path, sess_options, providers=["CPUExecutionProvider"])
+ return session
+ except Exception as e:
+ print(f"[ONNX Engine] Error loading {model_key} session: {e}")
+ return None
+
+ @classmethod
+ def run_depth_anything(cls, cv_img: np.ndarray) -> np.ndarray:
+ """
+ Executes Depth Anything V2 Small ONNX model to extract a relative depth map.
+ Input: cv_img (BGR numpy array). Output: Grayscale depth map normalized to [0, 255].
+ """
+ session = cls.get_session("depth_anything")
+ if session is None:
+ raise RuntimeError("Depth Anything V2 ONNX session is not available.")
+
+ h, w = cv_img.shape[:2]
+
+ # 1. Preprocess: Resize to 518x518 (multiple of 14, model input standard)
+ input_size = 518
+ img_rgb = cv2.cvtColor(cv_img, cv2.COLOR_BGR2RGB)
+ img_resized = cv2.resize(img_rgb, (input_size, input_size), interpolation=cv2.INTER_AREA)
+
+ # Normalize with ImageNet stats
+ img_float = img_resized.astype(np.float32) / 255.0
+ mean = np.array([0.485, 0.456, 0.406], dtype=np.float32)
+ std = np.array([0.229, 0.224, 0.225], dtype=np.float32)
+ img_norm = (img_float - mean) / std
+
+ # Transpose to CHW (3, 518, 518) and add batch dim
+ img_tensor = np.transpose(img_norm, (2, 0, 1))
+ img_tensor = np.expand_dims(img_tensor, axis=0)
+
+ # 2. Run Inference
+ inputs = {session.get_inputs()[0].name: img_tensor}
+ depth_out = session.run(None, inputs)[0]
+
+ # Depth output shape is (1, 1, 518, 518) or (1, 518, 518)
+ depth_map = np.squeeze(depth_out)
+
+ # 3. Postprocess: Min-Max normalize to [0, 255]
+ d_min, d_max = depth_map.min(), depth_map.max()
+ if d_max > d_min:
+ depth_map = (depth_map - d_min) / (d_max - d_min) * 255.0
+ else:
+ depth_map = np.zeros_like(depth_map)
+
+ depth_map = depth_map.astype(np.uint8)
+
+ # Resize back to original dimensions using bilinear interpolation
+ depth_original = cv2.resize(depth_map, (w, h), interpolation=cv2.INTER_LINEAR)
+ return depth_original
+
+ @classmethod
+ def run_gfpgan(cls, face_bgr: np.ndarray) -> np.ndarray:
+ """
+ Executes GFPGAN v1.4 ONNX model on a cropped facial BGR image.
+ Input: face_bgr (BGR array, expected 512x512). Output: Restored BGR face.
+ """
+ session = cls.get_session("gfpgan")
+ if session is None:
+ raise RuntimeError("GFPGAN ONNX session is not available.")
+
+ # 1. Preprocess: Resize to 512x512
+ face_resized = cv2.resize(face_bgr, (512, 512), interpolation=cv2.INTER_AREA)
+ face_rgb = cv2.cvtColor(face_resized, cv2.COLOR_BGR2RGB)
+
+ # Normalize to [-1.0, 1.0]
+ face_float = face_rgb.astype(np.float32)
+ face_norm = (face_float - 127.5) / 127.5
+
+ # Transpose to CHW and expand batch
+ face_tensor = np.transpose(face_norm, (2, 0, 1))
+ face_tensor = np.expand_dims(face_tensor, axis=0)
+
+ # 2. Run Inference
+ inputs = {session.get_inputs()[0].name: face_tensor}
+ restored_out = session.run(None, inputs)[0]
+
+ # 3. Postprocess: Scale back to [0, 255]
+ restored_face = np.squeeze(restored_out)
+ restored_face = np.transpose(restored_face, (1, 2, 0)) # CHW -> HWC
+ restored_face = np.clip((restored_face + 1.0) * 127.5, 0, 255).astype(np.uint8)
+
+ # Convert RGB back to BGR
+ restored_bgr = cv2.cvtColor(restored_face, cv2.COLOR_RGB2BGR)
+ return restored_bgr
+
+ @classmethod
+ def run_realesrgan(cls, cv_img: np.ndarray) -> np.ndarray:
+ """
+ Executes Real-ESRGAN x2 ONNX model to upscale and remove compression artifacts.
+ Input: cv_img (BGR). Output: 2x Super-Resolved BGR image.
+ """
+ session = cls.get_session("realesrgan")
+ if session is None:
+ raise RuntimeError("Real-ESRGAN ONNX session is not available.")
+
+ h, w = cv_img.shape[:2]
+
+ # For CPU safety, if the input image is already large, we scale it down before upscaling
+ # to prevent memory overflows and extremely slow processing.
+ max_input_dim = 700
+ if max(h, w) > max_input_dim:
+ if h > w:
+ new_h = max_input_dim
+ new_w = int(w * (max_input_dim / h))
+ else:
+ new_w = max_input_dim
+ new_h = int(h * (max_input_dim / w))
+ processing_img = cv2.resize(cv_img, (new_w, new_h), interpolation=cv2.INTER_AREA)
+ else:
+ processing_img = cv_img.copy()
+
+ ph, pw = processing_img.shape[:2]
+
+ # 1. Preprocess: Convert to RGB and normalize to [0.0, 1.0]
+ img_rgb = cv2.cvtColor(processing_img, cv2.COLOR_BGR2RGB)
+ img_float = img_rgb.astype(np.float32) / 255.0
+
+ # Transpose to CHW and add batch dimension
+ img_tensor = np.transpose(img_float, (2, 0, 1))
+ img_tensor = np.expand_dims(img_tensor, axis=0)
+
+ # 2. Run Inference
+ inputs = {session.get_inputs()[0].name: img_tensor}
+ upscaled_out = session.run(None, inputs)[0]
+
+ # 3. Postprocess
+ upscaled_img = np.squeeze(upscaled_out)
+ upscaled_img = np.transpose(upscaled_img, (1, 2, 0)) # CHW -> HWC
+ upscaled_img = np.clip(upscaled_img * 255.0, 0, 255).astype(np.uint8)
+
+ # Convert RGB to BGR
+ upscaled_bgr = cv2.cvtColor(upscaled_img, cv2.COLOR_RGB2BGR)
+ return upscaled_bgr
diff --git a/backend/enhancement/photo_enhancer.py b/backend/enhancement/photo_enhancer.py
new file mode 100644
index 0000000000000000000000000000000000000000..415442fcca43a756575e2692ea14ddb1cd537870
--- /dev/null
+++ b/backend/enhancement/photo_enhancer.py
@@ -0,0 +1,150 @@
+import cv2
+import numpy as np
+from PIL import Image
+from backend.utilities import pil_to_cv, cv_to_pil
+from backend.enhancement.onnx_engine import ONNXInferenceEngine
+
+class PhotoEnhancer:
+ """
+ Orchestrates AI Photo Enhancement, Face Restoration, and Color Grading.
+ """
+
+ @staticmethod
+ def apply_color_grading(img: np.ndarray, vibrancy_boost: float = 1.15, contrast_boost: float = 1.02) -> np.ndarray:
+ """
+ Preserves 100% of the original photo's natural color palette, balance, and contrast.
+ Returns the input image completely untouched.
+ """
+ return img
+
+ @classmethod
+ def restore_faces(cls, img: np.ndarray) -> np.ndarray:
+ """
+ Detects faces in the image, runs GFPGAN ONNX inference on crops,
+ and blends them back seamlessly using feathered alpha blending.
+ """
+ # Load OpenCV Haar Cascade face detector
+ face_cascade = cv2.CascadeClassifier(cv2.data.haarcascades + 'haarcascade_frontalface_default.xml')
+ gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
+
+ # Detect faces
+ faces = face_cascade.detectMultiScale(gray, scaleFactor=1.1, minNeighbors=5, minSize=(40, 40))
+ if len(faces) == 0:
+ return img
+
+ result = img.copy()
+ h, w = img.shape[:2]
+
+ print(f"[Photo Enhancer] Detected {len(faces)} face(s) for GFPGAN restoration.")
+ for idx, (fx, fy, fw, fh) in enumerate(faces):
+ try:
+ # Add 40% padding around the detected face region for natural context
+ pad_x = int(fw * 0.4)
+ pad_y = int(fh * 0.4)
+
+ x1 = max(0, fx - pad_x)
+ y1 = max(0, fy - pad_y)
+ x2 = min(w, fx + fw + pad_x)
+ y2 = min(h, fy + fh + pad_y)
+
+ crop_w = x2 - x1
+ crop_h = y2 - y1
+
+ if crop_w < 20 or crop_h < 20:
+ continue
+
+ face_crop = img[y1:y2, x1:x2]
+
+ # Execute GFPGAN ONNX restoration
+ restored_face_512 = ONNXInferenceEngine.run_gfpgan(face_crop)
+
+ # Resize the restored face back to the crop's original dimensions
+ restored_face = cv2.resize(restored_face_512, (crop_w, crop_h), interpolation=cv2.INTER_CUBIC)
+
+ # Create a feathered ellipse blending mask to overlay the face crop seamlessly
+ mask = np.zeros((crop_h, crop_w), dtype=np.float32)
+ cx = crop_w // 2
+ cy = crop_h // 2
+ rx = int(crop_w * 0.45)
+ ry = int(crop_h * 0.45)
+ cv2.ellipse(mask, (cx, cy), (rx, ry), 0, 0, 360, 1.0, -1)
+
+ # Blur the mask to create a smooth, linear alpha transition
+ mask_blurred = cv2.GaussianBlur(mask, (31, 31), 0)
+ mask_3d = np.expand_dims(mask_blurred, axis=2)
+
+ # Blend the restored face back onto the image
+ original_crop = result[y1:y2, x1:x2].astype(np.float32)
+ restored_crop = restored_face.astype(np.float32)
+
+ blended_crop = restored_crop * mask_3d + original_crop * (1.0 - mask_3d)
+ result[y1:y2, x1:x2] = np.clip(blended_crop, 0, 255).astype(np.uint8)
+ except Exception as e:
+ print(f"[Photo Enhancer] Face {idx+1} restoration failed: {e}. Falling back to original face.")
+
+ return result
+
+ @classmethod
+ def apply_opencv_enhancement(cls, img: np.ndarray) -> np.ndarray:
+ """
+ High-performance zero-dependency detail enhancer (Bilateral denoising + Unsharp Masking).
+ Preserves 100% original color, contrast, and histogram distributions.
+ """
+ # 1. Bilateral filtering (reduces noise, preserves sharp object edges)
+ denoised = cv2.bilateralFilter(img, 9, 35, 35)
+
+ # 2. High-Pass Unsharp Masking (recovers micro-details and textures)
+ blurred = cv2.GaussianBlur(denoised, (0, 0), 3)
+ sharpened = cv2.addWeighted(denoised, 1.4, blurred, -0.4, 0)
+
+ return sharpened
+
+ @classmethod
+ def process_enhancement(
+ cls,
+ pil_image: Image.Image,
+ mode: str = "Professional DSLR",
+ face_restoration: bool = True
+ ) -> Image.Image:
+ """
+ Main entry point to execute the photo enhancement pipeline.
+ Modes:
+ - "Standard": Fast, zero-dependency detail sharpener + color grading.
+ - "High Quality (Neural)": Real-ESRGAN super-resolution + color grading.
+ - "Professional DSLR": Full pipeline (Real-ESRGAN + GFPGAN Face Restore + Color Grading).
+ """
+ cv_img = pil_to_cv(pil_image)
+
+ # 1. Photo Upscaling & Detail Sharpness
+ if mode in ["High Quality (Neural)", "Professional DSLR"]:
+ try:
+ # Try running Real-ESRGAN ONNX
+ enhanced_cv = ONNXInferenceEngine.run_realesrgan(cv_img[:, :, :3])
+ except Exception as e:
+ print(f"[Photo Enhancer] Real-ESRGAN upscale failed: {e}. Falling back to OpenCV sharpener.")
+ # Fallback to high-speed OpenCV sharpener
+ enhanced_cv = cls.apply_opencv_enhancement(cv_img[:, :, :3])
+ else:
+ # Standard Mode (OpenCV sharpener)
+ enhanced_cv = cls.apply_opencv_enhancement(cv_img[:, :, :3])
+
+ # 2. Face Restoration (GFPGAN)
+ if face_restoration and mode == "Professional DSLR":
+ try:
+ enhanced_cv = cls.restore_faces(enhanced_cv)
+ except Exception as e:
+ print(f"[Photo Enhancer] GFPGAN face restoration failed: {e}.")
+
+ # 3. Color Grading & Aesthetics
+ final_cv = cls.apply_color_grading(enhanced_cv)
+
+ # Apply original transparency alpha channel if present
+ if cv_img.shape[2] == 4:
+ h, w = final_cv.shape[:2]
+ alpha = cv2.resize(cv_img[:, :, 3], (w, h), interpolation=cv2.INTER_LINEAR)
+ result_rgba = np.zeros((h, w, 4), dtype=np.uint8)
+ result_rgba[:, :, :3] = final_cv
+ result_rgba[:, :, 3] = alpha
+ return cv_to_pil(result_rgba)
+ else:
+ return cv_to_pil(final_cv)
diff --git a/backend/models/depth_anything.onnx b/backend/models/depth_anything.onnx
new file mode 100644
index 0000000000000000000000000000000000000000..95a8023be105e159cb56e437fb40528f26be5ebc
--- /dev/null
+++ b/backend/models/depth_anything.onnx
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:afb6a5c28f3b6bf1618c6e43f02073ef9dfdc70e937502d51603e57b0a1df10c
+size 99060839
diff --git a/backend/text_editor/__init__.py b/backend/text_editor/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..46d940fbb41fbd8357637a6691a5858c71957c55
--- /dev/null
+++ b/backend/text_editor/__init__.py
@@ -0,0 +1,2 @@
+from backend.text_editor.orchestrator import TextEditorOrchestrator
+from backend.text_editor.pdf_processor import get_pdf_page_count, pdf_page_to_pil, compile_images_to_pdf
diff --git a/backend/text_editor/__pycache__/__init__.cpython-311.pyc b/backend/text_editor/__pycache__/__init__.cpython-311.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..9e962ec4cf93530495dd50309be15c326a9b40c8
Binary files /dev/null and b/backend/text_editor/__pycache__/__init__.cpython-311.pyc differ
diff --git a/backend/text_editor/__pycache__/__init__.cpython-314.pyc b/backend/text_editor/__pycache__/__init__.cpython-314.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..25a93c116bffd60e248c81749a16b1f178838200
Binary files /dev/null and b/backend/text_editor/__pycache__/__init__.cpython-314.pyc differ
diff --git a/backend/text_editor/__pycache__/color_detector.cpython-311.pyc b/backend/text_editor/__pycache__/color_detector.cpython-311.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..08184f7d193b89c03ca6d82f4b1e99f53a37eb0f
Binary files /dev/null and b/backend/text_editor/__pycache__/color_detector.cpython-311.pyc differ
diff --git a/backend/text_editor/__pycache__/color_detector.cpython-314.pyc b/backend/text_editor/__pycache__/color_detector.cpython-314.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..be69e5dfc415467e16bd9b5b5802c37b703e9652
Binary files /dev/null and b/backend/text_editor/__pycache__/color_detector.cpython-314.pyc differ
diff --git a/backend/text_editor/__pycache__/font_detector.cpython-311.pyc b/backend/text_editor/__pycache__/font_detector.cpython-311.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..2923b626ba2b5c607e78f67a6586ce7fa57ba5ac
Binary files /dev/null and b/backend/text_editor/__pycache__/font_detector.cpython-311.pyc differ
diff --git a/backend/text_editor/__pycache__/font_detector.cpython-314.pyc b/backend/text_editor/__pycache__/font_detector.cpython-314.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..b88284289b24a83cb7aa604b4a557c55add7d412
Binary files /dev/null and b/backend/text_editor/__pycache__/font_detector.cpython-314.pyc differ
diff --git a/backend/text_editor/__pycache__/font_downloader.cpython-311.pyc b/backend/text_editor/__pycache__/font_downloader.cpython-311.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..ecb7d6a95c90d80ba4e6917797946995d16bc9bc
Binary files /dev/null and b/backend/text_editor/__pycache__/font_downloader.cpython-311.pyc differ
diff --git a/backend/text_editor/__pycache__/ocr_engine.cpython-311.pyc b/backend/text_editor/__pycache__/ocr_engine.cpython-311.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..0d0b3b556942e1d883e7299df19877d146258a82
Binary files /dev/null and b/backend/text_editor/__pycache__/ocr_engine.cpython-311.pyc differ
diff --git a/backend/text_editor/__pycache__/ocr_engine.cpython-314.pyc b/backend/text_editor/__pycache__/ocr_engine.cpython-314.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..2e3966f82261f96f66cdf8b8925810595adfeee1
Binary files /dev/null and b/backend/text_editor/__pycache__/ocr_engine.cpython-314.pyc differ
diff --git a/backend/text_editor/__pycache__/orchestrator.cpython-311.pyc b/backend/text_editor/__pycache__/orchestrator.cpython-311.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..4957d9a01d263380a41b53d24324d0bf246d827e
Binary files /dev/null and b/backend/text_editor/__pycache__/orchestrator.cpython-311.pyc differ
diff --git a/backend/text_editor/__pycache__/orchestrator.cpython-314.pyc b/backend/text_editor/__pycache__/orchestrator.cpython-314.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..81cb834ba582fe57698be288656c36c0cab8f3f8
Binary files /dev/null and b/backend/text_editor/__pycache__/orchestrator.cpython-314.pyc differ
diff --git a/backend/text_editor/__pycache__/pdf_processor.cpython-311.pyc b/backend/text_editor/__pycache__/pdf_processor.cpython-311.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..9de8262919e10090ef2d4f85b336f91bc940e0f9
Binary files /dev/null and b/backend/text_editor/__pycache__/pdf_processor.cpython-311.pyc differ
diff --git a/backend/text_editor/__pycache__/pdf_processor.cpython-314.pyc b/backend/text_editor/__pycache__/pdf_processor.cpython-314.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..f24199e7d7cd94d3cf248853270a917e516c91f2
Binary files /dev/null and b/backend/text_editor/__pycache__/pdf_processor.cpython-314.pyc differ
diff --git a/backend/text_editor/__pycache__/text_replacer.cpython-311.pyc b/backend/text_editor/__pycache__/text_replacer.cpython-311.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..c4adf186c68e63f6a02868af3fdcc6f10d8f18c9
Binary files /dev/null and b/backend/text_editor/__pycache__/text_replacer.cpython-311.pyc differ
diff --git a/backend/text_editor/__pycache__/text_replacer.cpython-314.pyc b/backend/text_editor/__pycache__/text_replacer.cpython-314.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..81a3d36a7712d132783a9ae5632dc8b863887b0e
Binary files /dev/null and b/backend/text_editor/__pycache__/text_replacer.cpython-314.pyc differ
diff --git a/backend/text_editor/color_detector.py b/backend/text_editor/color_detector.py
new file mode 100644
index 0000000000000000000000000000000000000000..bd86d948d36ff0c2caeb6acd590f49f6ebe4d0d3
--- /dev/null
+++ b/backend/text_editor/color_detector.py
@@ -0,0 +1,72 @@
+import cv2
+import numpy as np
+
+def detect_ink_and_paper_colors(img_bgr: np.ndarray, bbox: tuple) -> tuple:
+ """
+ Analyzes a local bounding box region to extract the BGR colors of:
+ 1. The ink (foreground text)
+ 2. The paper (background backdrop)
+
+ Parameters:
+ - img_bgr: Source BGR image
+ - bbox: Bounding box coordinates (x, y, w, h)
+
+ Returns:
+ A tuple (ink_color_bgr, paper_color_bgr) as lists of 3 integers.
+ """
+ h_img, w_img = img_bgr.shape[:2]
+ x, y, w, h = bbox
+
+ # Ensure coordinates are within image bounds
+ x = max(0, min(x, w_img - 2))
+ y = max(0, min(y, h_img - 2))
+ w = max(1, min(w, w_img - x))
+ h = max(1, min(h, h_img - y))
+
+ # Crop local patch
+ crop = img_bgr[y:y+h, x:x+w]
+
+ # Defaults
+ default_ink = [0, 0, 0] # Black
+ default_paper = [255, 255, 255] # White
+
+ if crop.size == 0:
+ return default_ink, default_paper
+
+ try:
+ # Convert to grayscale
+ gray = cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY)
+
+ # Apply Otsu's thresholding to separate ink from paper
+ # If paper is bright and ink is dark, THRESH_BINARY_INV makes ink pixels 255, paper pixels 0
+ _, thresh = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
+
+ # Count foreground vs background pixel coverage
+ fg_count = np.sum(thresh == 255)
+ bg_count = np.sum(thresh == 0)
+
+ # If OTSU thresholding binarized incorrectly (e.g. inverted because text is white on black background)
+ # We assume the background (paper) takes up the majority of the crop area
+ if fg_count > bg_count:
+ # Invert the mask
+ thresh = cv2.bitwise_not(thresh)
+ fg_count, bg_count = bg_count, fg_count
+
+ ink_pixels = crop[thresh == 255]
+ paper_pixels = crop[thresh == 0]
+
+ if len(ink_pixels) > 0:
+ # Use median to avoid outliers (like gradient noise near text borders)
+ ink_color = np.median(ink_pixels, axis=0).astype(int).tolist()
+ else:
+ ink_color = default_ink
+
+ if len(paper_pixels) > 0:
+ paper_color = np.median(paper_pixels, axis=0).astype(int).tolist()
+ else:
+ paper_color = default_paper
+
+ return ink_color, paper_color
+ except Exception as e:
+ print(f"Error extracting colors from bounding box: {e}")
+ return default_ink, default_paper
diff --git a/backend/text_editor/font_detector.py b/backend/text_editor/font_detector.py
new file mode 100644
index 0000000000000000000000000000000000000000..9082612950bbe8f1cf11f406de2451d64eba4960
--- /dev/null
+++ b/backend/text_editor/font_detector.py
@@ -0,0 +1,101 @@
+import os
+from PIL import ImageFont
+from backend.text_editor.font_downloader import download_google_font
+
+def get_system_font_path(font_family: str = "Sans-Serif") -> str:
+ """
+ Get the path to a standard system font on Windows.
+ Defaults to Arial (Sans-Serif), Times New Roman (Serif), or Courier New (Monospace).
+ """
+ windows_font_dir = "C:\\Windows\\Fonts"
+
+ font_mapping = {
+ "Sans-Serif": ["arial.ttf", "calibri.ttf", "segoeui.ttf"],
+ "Serif": ["times.ttf", "georgia.ttf", "cambria.ttf"],
+ "Monospace": ["cour.ttf", "consola.ttf", "lucon.ttf"]
+ }
+
+ candidates = font_mapping.get(font_family, font_mapping["Sans-Serif"])
+
+ # Check if we are on Windows and directory exists
+ if os.path.exists(windows_font_dir):
+ for candidate in candidates:
+ path = os.path.join(windows_font_dir, candidate)
+ if os.path.exists(path):
+ return path
+
+ # Fallbacks for standard platform-independent locations or names
+ for candidate in candidates:
+ if os.path.exists(candidate):
+ return candidate
+
+ return ""
+
+def load_matching_font(bbox_height: int, font_family: str = "Sans-Serif", size_multiplier: float = 0.85, text: str = "") -> tuple:
+ """
+ Loads a PIL ImageFont matching the requested family (either standard or dynamic Google Font)
+ and scaled to the bounding box height. Auto-detects Hindi/Devanagari characters and overrides
+ fallbacks with appropriate Devanagari-supporting fonts.
+
+ Parameters:
+ - bbox_height: Height of the bounding box in pixels.
+ - font_family: Font style family or custom Google Font name.
+ - size_multiplier: Multiplier to adjust font size relative to bounding box height.
+ - text: The replacement text content to analyze for character-level font requirements.
+
+ Returns:
+ A tuple (font_object, font_size)
+ """
+ font_size = max(8, int(bbox_height * size_multiplier))
+
+ # 1. Detect if the text contains Devanagari (Hindi) characters
+ is_hindi = False
+ for char in text:
+ if '\u0900' <= char <= '\u097F':
+ is_hindi = True
+ break
+
+ # 2. Intercept and auto-resolve Hindi fonts
+ if is_hindi:
+ # If standard family is chosen, automatically upgrade to Google Noto Sans Devanagari
+ if font_family in ["Sans-Serif", "Serif", "Monospace"]:
+ font_family = "Noto Sans Devanagari"
+
+ font_path = ""
+
+ # 3. Check if a custom Google Font (or auto-resolved Hindi font) is requested
+ if font_family not in ["Sans-Serif", "Serif", "Monospace"]:
+ font_path = download_google_font(font_family)
+
+ # 4. If it is standard family or download failed, resolve system fallback
+ if not font_path:
+ if is_hindi:
+ # Fallback to standard Windows Devanagari system fonts
+ windows_font_dir = "C:\\Windows\\Fonts"
+ hindi_candidates = ["mangal.ttf", "utsah.ttf", "aparaj.ttf", "kokila.ttf"]
+ for candidate in hindi_candidates:
+ path = os.path.join(windows_font_dir, candidate)
+ if os.path.exists(path):
+ font_path = path
+ break
+
+ # If still no path resolved, fallback to standard system families
+ if not font_path:
+ standard_family = "Sans-Serif"
+ if font_family in ["Serif", "Monospace"]:
+ standard_family = font_family
+ font_path = get_system_font_path(standard_family)
+
+ if font_path:
+ try:
+ font = ImageFont.truetype(font_path, font_size)
+ return font, font_size
+ except Exception as e:
+ print(f"Failed to load TrueType font {font_path}: {e}")
+
+ # Ultimate fallback to default PIL font
+ try:
+ font = ImageFont.load_default()
+ return font, 12
+ except Exception:
+ return None, 0
diff --git a/backend/text_editor/font_downloader.py b/backend/text_editor/font_downloader.py
new file mode 100644
index 0000000000000000000000000000000000000000..73b614865b304e2ec1bcd91fadba4eb27e1dbdb7
--- /dev/null
+++ b/backend/text_editor/font_downloader.py
@@ -0,0 +1,81 @@
+import os
+import requests
+import re
+
+def clean_font_name(font_name: str) -> str:
+ """Cleans up font family name and formats it properly (e.g. Poppins, Noto Sans Devanagari)."""
+ # Strip whitespace and capitalize words properly
+ parts = [part.strip().capitalize() for part in font_name.split(" ") if part.strip()]
+ return " ".join(parts)
+
+def download_google_font(font_family: str) -> str:
+ """
+ Downloads a font family from Google Fonts by leveraging the CSS API with an older
+ User-Agent to fetch raw TrueType (.ttf) URLs, requesting the Devanagari and Latin subsets,
+ and caches them locally.
+
+ Returns:
+ Absolute path to the cached TTF file, or empty string if it fails.
+ """
+ cleaned_name = clean_font_name(font_family)
+ if not cleaned_name:
+ return ""
+
+ # Local fonts cache directory in backend/text_editor/fonts/
+ base_dir = os.path.dirname(os.path.abspath(__file__))
+ fonts_dir = os.path.join(base_dir, "fonts")
+ os.makedirs(fonts_dir, exist_ok=True)
+
+ # Cache filename prefix e.g. "poppins_regular.ttf" or "notosansdevanagari_regular.ttf"
+ safe_prefix = cleaned_name.lower().replace(" ", "")
+ cache_path = os.path.join(fonts_dir, f"{safe_prefix}_regular.ttf")
+
+ # If already cached, verify that it contains full character support (over 35 KB)
+ # Subsetted Latin-only fonts are typically under 25 KB. Devanagari subsets are >100 KB.
+ if os.path.exists(cache_path):
+ if os.path.getsize(cache_path) > 35000 or "devanagari" not in safe_prefix:
+ return cache_path
+ else:
+ print(f"Cached font '{cleaned_name}' is subsetted Latin-only ({os.path.getsize(cache_path)} bytes). Upgrading to Devanagari subset...")
+
+ # Fetch Google Fonts CSS API using an iOS 4 Safari User-Agent to force TTF formats
+ # Request Devanagari and Latin subsets to ensure full multi-lingual rendering support
+ url_name = cleaned_name.replace(" ", "+")
+ css_url = f"https://fonts.googleapis.com/css?family={url_name}&subset=devanagari,latin,latin-ext"
+ headers = {
+ 'User-Agent': 'Mozilla/5.0 (iPhone; U; CPU iPhone OS 4_2_1 like Mac OS X; en-us) AppleWebKit/533.17.9 (KHTML, like Gecko) Version/5.0.2 Mobile/8C148 Safari/5653.8'
+ }
+
+ print(f"Searching Google Fonts for '{cleaned_name}' using: {css_url}")
+
+ try:
+ response = requests.get(css_url, headers=headers, timeout=10)
+ if response.status_code != 200:
+ print(f"Font '{cleaned_name}' not found on Google Fonts (CSS status code: {response.status_code})")
+ return ""
+
+ # Parse direct .ttf URL from the CSS body
+ match = re.search(r'url\((https://[^\)]+\.ttf)\)', response.text)
+ if not match:
+ print(f"No direct TrueType (.ttf) URL found in Google Fonts CSS for '{cleaned_name}'.")
+ return ""
+
+ ttf_url = match.group(1)
+ print(f"Downloading raw TTF file from gstatic: {ttf_url}")
+
+ # Download the binary TTF content
+ font_response = requests.get(ttf_url, timeout=10)
+ if font_response.status_code != 200:
+ print(f"Failed to download TTF binary from gstatic (HTTP status: {font_response.status_code})")
+ return ""
+
+ # Write to local cache directory
+ with open(cache_path, "wb") as f:
+ f.write(font_response.content)
+
+ print(f"Successfully downloaded and cached Google Font: '{cleaned_name}' ({len(font_response.content)} bytes) -> {cache_path}")
+ return cache_path
+
+ except Exception as e:
+ print(f"Error dynamically acquiring Google Font '{cleaned_name}': {e}")
+ return ""
diff --git a/backend/text_editor/fonts/hind_regular.ttf b/backend/text_editor/fonts/hind_regular.ttf
new file mode 100644
index 0000000000000000000000000000000000000000..421bdd3e93064c22cd3dc6696319ae8a297d98be
--- /dev/null
+++ b/backend/text_editor/fonts/hind_regular.ttf
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:ecb6de0b873be74e894300f83f58c8cd80e78c4d8f31a9581f2e967e70ea145e
+size 147420
diff --git a/backend/text_editor/fonts/kalam_regular.ttf b/backend/text_editor/fonts/kalam_regular.ttf
new file mode 100644
index 0000000000000000000000000000000000000000..b3ffc2453ea9aa7bda2679e6eca4811123e7bb6f
--- /dev/null
+++ b/backend/text_editor/fonts/kalam_regular.ttf
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:51dfc2750ae21fe20e1f80e0e7ffd33cbdc06908c6b30891088c83c0e607fb9a
+size 219924
diff --git a/backend/text_editor/fonts/martel_regular.ttf b/backend/text_editor/fonts/martel_regular.ttf
new file mode 100644
index 0000000000000000000000000000000000000000..fc6e61bc4f3370f8d3b7e96f332c9b5339cbac60
--- /dev/null
+++ b/backend/text_editor/fonts/martel_regular.ttf
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:8adbf3fa5b35501a50259ab0c6e6020f00e47e0d97c05056489e035ecd1baedd
+size 117388
diff --git a/backend/text_editor/fonts/notosansdevanagari_regular.ttf b/backend/text_editor/fonts/notosansdevanagari_regular.ttf
new file mode 100644
index 0000000000000000000000000000000000000000..8e3d0ee70675573f2e5db1be699acc7b0736f3cb
--- /dev/null
+++ b/backend/text_editor/fonts/notosansdevanagari_regular.ttf
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:0c5502855cb4f5aea1c0a0e724d678372c4c54ee62ca9bc35dc417a04d59777b
+size 219436
diff --git a/backend/text_editor/fonts/rozhaone_regular.ttf b/backend/text_editor/fonts/rozhaone_regular.ttf
new file mode 100644
index 0000000000000000000000000000000000000000..8fab5c999444d33423e5d04d62e08c3b3f0a7ce0
--- /dev/null
+++ b/backend/text_editor/fonts/rozhaone_regular.ttf
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:d8b564a2294dd0a0864362778ed014526075e307adbec370e860ff6ef7baa846
+size 187760
diff --git a/backend/text_editor/fonts/teko_regular.ttf b/backend/text_editor/fonts/teko_regular.ttf
new file mode 100644
index 0000000000000000000000000000000000000000..29a31a863ee95be3a51930ab230182146c337834
--- /dev/null
+++ b/backend/text_editor/fonts/teko_regular.ttf
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:fef304f9337a0e58e188021f641b98b19a74b0f2557b0c0fb5e1b3fa8842b799
+size 153748
diff --git a/backend/text_editor/fonts/yatraone_regular.ttf b/backend/text_editor/fonts/yatraone_regular.ttf
new file mode 100644
index 0000000000000000000000000000000000000000..5ff16535236b59949ffd3048c6de6a83f51460e2
--- /dev/null
+++ b/backend/text_editor/fonts/yatraone_regular.ttf
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:de8121c016646186feb903ff7d0e84e6d900b2c8128cbf012026ff15f4bb8795
+size 195904
diff --git a/backend/text_editor/ocr_engine.py b/backend/text_editor/ocr_engine.py
new file mode 100644
index 0000000000000000000000000000000000000000..c7092001ed6bd9a7b5c51f68c9f27df22e9db094
--- /dev/null
+++ b/backend/text_editor/ocr_engine.py
@@ -0,0 +1,70 @@
+import numpy as np
+
+try:
+ import easyocr
+ EASYOCR_AVAILABLE = True
+except ImportError:
+ EASYOCR_AVAILABLE = False
+
+_reader = None
+
+def get_ocr_reader():
+ """Lazy initializer for EasyOCR reader."""
+ global _reader
+ if not EASYOCR_AVAILABLE:
+ return None
+
+ if _reader is None:
+ try:
+ # Initialize with English and Hindi, CPU only for maximum compatibility
+ _reader = easyocr.Reader(['hi', 'en'], gpu=False)
+ except Exception as e:
+ print(f"Error initializing EasyOCR: {e}")
+ return None
+ return _reader
+
+def detect_text_in_image(img_bgr: np.ndarray) -> list:
+ """
+ Detect all text strings and their bounding boxes in the BGR image.
+
+ Returns:
+ A list of dictionaries:
+ [
+ {
+ "text": str,
+ "bbox": (x, y, w, h),
+ "confidence": float
+ }
+ ]
+ """
+ reader = get_ocr_reader()
+ if reader is None:
+ print("OCR Engine not available. Skipping automatic text detection.")
+ return []
+
+ try:
+ # EasyOCR requires RGB or BGR numpy array
+ # It returns: [([[x0,y0], [x1,y1], [x2,y2], [x3,y3]], text, confidence), ...]
+ raw_results = reader.readtext(img_bgr)
+
+ processed_results = []
+ for coords, text, confidence in raw_results:
+ # Map four-point coordinates to standard (x, y, w, h) bounding box
+ xs = [p[0] for p in coords]
+ ys = [p[1] for p in coords]
+
+ x = int(min(xs))
+ y = int(min(ys))
+ w = int(max(xs) - x)
+ h = int(max(ys) - y)
+
+ processed_results.append({
+ "text": text.strip(),
+ "bbox": (x, y, w, h),
+ "confidence": float(confidence)
+ })
+
+ return processed_results
+ except Exception as e:
+ print(f"OCR detection encountered an error: {e}")
+ return []
diff --git a/backend/text_editor/orchestrator.py b/backend/text_editor/orchestrator.py
new file mode 100644
index 0000000000000000000000000000000000000000..41da739c0120eb13b92b852efc488c9d46aefb8d
--- /dev/null
+++ b/backend/text_editor/orchestrator.py
@@ -0,0 +1,72 @@
+import numpy as np
+from PIL import Image
+from backend.utilities import pil_to_cv, cv_to_pil
+from backend.text_editor.ocr_engine import detect_text_in_image
+from backend.text_editor.color_detector import detect_ink_and_paper_colors
+from backend.text_editor.text_replacer import replace_text_in_image
+
+class TextEditorOrchestrator:
+ @staticmethod
+ def run_ocr(pil_image: Image.Image) -> list:
+ """
+ Runs OCR on the given PIL image to detect all text fields.
+
+ Returns:
+ List of dicts: [{"text": str, "bbox": (x, y, w, h), "confidence": float}]
+ """
+ if pil_image is None:
+ return []
+
+ img_bgr = pil_to_cv(pil_image)
+ return detect_text_in_image(img_bgr)
+
+ @staticmethod
+ def apply_replacements(
+ pil_image: Image.Image,
+ replacements: list,
+ font_family: str = "Sans-Serif",
+ size_multiplier: float = 0.85
+ ) -> Image.Image:
+ """
+ Applies a list of text replacements to the image.
+
+ Parameters:
+ - pil_image: The original PIL Image.
+ - replacements: List of dicts, each with:
+ - "bbox": (x, y, w, h)
+ - "replacement_text": str
+ - font_family: Font family style to use ("Sans-Serif", "Serif", "Monospace").
+ - size_multiplier: Text size scale multiplier.
+
+ Returns:
+ The edited PIL Image.
+ """
+ if pil_image is None or not replacements:
+ return pil_image
+
+ # Make a copy of the image and convert to OpenCV BGR
+ img_bgr = pil_to_cv(pil_image).copy()
+
+ for rep in replacements:
+ bbox = rep.get("bbox")
+ new_text = rep.get("replacement_text")
+
+ if bbox is None or new_text is None:
+ continue
+
+ # 1. Detect ink and paper colors in the bounding box
+ ink_color, paper_color = detect_ink_and_paper_colors(img_bgr, bbox)
+
+ # 2. Erase, inpaint, and synthesize replacement text
+ img_bgr = replace_text_in_image(
+ img_bgr=img_bgr,
+ bbox=bbox,
+ replacement_text=new_text,
+ ink_color_bgr=ink_color,
+ paper_color_bgr=paper_color,
+ font_family=font_family,
+ size_multiplier=size_multiplier
+ )
+
+ # Convert back to PIL Image
+ return cv_to_pil(img_bgr)
diff --git a/backend/text_editor/pdf_processor.py b/backend/text_editor/pdf_processor.py
new file mode 100644
index 0000000000000000000000000000000000000000..3f2e93c832c32f43a170fbd334c00770f7bfae66
--- /dev/null
+++ b/backend/text_editor/pdf_processor.py
@@ -0,0 +1,45 @@
+import fitz # PyMuPDF
+from PIL import Image
+import io
+
+def get_pdf_page_count(pdf_bytes: bytes) -> int:
+ """Return the total number of pages in the PDF bytes."""
+ try:
+ doc = fitz.open(stream=pdf_bytes, filetype="pdf")
+ return len(doc)
+ except Exception as e:
+ print(f"Error reading PDF page count: {e}")
+ return 0
+
+def pdf_page_to_pil(pdf_bytes: bytes, page_index: int, dpi: int = 150) -> Image.Image:
+ """Render a PDF page to a high-DPI RGB PIL Image."""
+ doc = fitz.open(stream=pdf_bytes, filetype="pdf")
+ page = doc.load_page(page_index)
+
+ # Scale matrix for high quality DPI rendering (72 is default PDF DPI)
+ zoom = dpi / 72.0
+ mat = fitz.Matrix(zoom, zoom)
+
+ pix = page.get_pixmap(matrix=mat)
+ png_bytes = pix.tobytes("png")
+
+ return Image.open(io.BytesIO(png_bytes)).convert("RGB")
+
+def compile_images_to_pdf(pil_images: list) -> bytes:
+ """Compile a list of PIL Images into a single PDF bytes object."""
+ doc = fitz.open()
+ for pil_img in pil_images:
+ # Save PIL image as a single-page PDF in a bytes buffer
+ pdf_buffer = io.BytesIO()
+ pil_img.save(pdf_buffer, format="PDF")
+ pdf_buffer.seek(0)
+
+ # Open single page PDF and insert into compiled document
+ page_doc = fitz.open("pdf", pdf_buffer.read())
+ doc.insert_pdf(page_doc)
+
+ out_buffer = io.BytesIO()
+ doc.save(out_buffer)
+ doc.close()
+
+ return out_buffer.getvalue()
diff --git a/backend/text_editor/text_replacer.py b/backend/text_editor/text_replacer.py
new file mode 100644
index 0000000000000000000000000000000000000000..f8c17b92060579ec8f8a1361916cc0012c526f87
--- /dev/null
+++ b/backend/text_editor/text_replacer.py
@@ -0,0 +1,108 @@
+import cv2
+import numpy as np
+from PIL import Image, ImageDraw, ImageFont
+from backend.utilities import cv_to_pil, pil_to_cv
+from backend.text_editor.font_detector import load_matching_font
+
+def erase_text_in_bbox(img_bgr: np.ndarray, bbox: tuple, paper_color_bgr: list) -> np.ndarray:
+ """
+ Erases text in the specified bounding box by creating a character-level binary mask
+ and applying OpenCV Inpainting (Telea) to preserve background texture.
+ """
+ h_img, w_img = img_bgr.shape[:2]
+ x, y, w, h = bbox
+
+ # Ensure coordinates are within image bounds
+ x = max(0, min(x, w_img - 2))
+ y = max(0, min(y, h_img - 2))
+ w = max(1, min(w, w_img - x))
+ h = max(1, min(h, h_img - y))
+
+ # Create a full-image black mask
+ full_mask = np.zeros((h_img, w_img), dtype=np.uint8)
+
+ # Crop local patch
+ crop = img_bgr[y:y+h, x:x+w]
+
+ try:
+ # Convert crop to grayscale
+ gray = cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY)
+
+ # Segment character strokes using Otsu's thresholding
+ _, thresh = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
+
+ # Ensure we target the ink strokes rather than the background
+ fg_count = np.sum(thresh == 255)
+ bg_count = np.sum(thresh == 0)
+ if fg_count > bg_count:
+ thresh = cv2.bitwise_not(thresh)
+
+ # Dilate the character strokes to fully cover complex Devanagari horizontal bars and matras
+ kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3))
+ dilated_thresh = cv2.dilate(thresh, kernel, iterations=3)
+
+ # Inject local mask back into full-image mask
+ full_mask[y:y+h, x:x+w] = dilated_thresh
+
+ except Exception as e:
+ print(f"Error during character mask generation: {e}")
+ # Fallback: mask the entire bounding box
+ full_mask[y:y+h, x:x+w] = 255
+
+ # Apply OpenCV Telea Inpainting to restore background paper textures
+ inpainted = cv2.inpaint(img_bgr, full_mask, inpaintRadius=4, flags=cv2.INPAINT_TELEA)
+ return inpainted
+
+def replace_text_in_image(
+ img_bgr: np.ndarray,
+ bbox: tuple,
+ replacement_text: str,
+ ink_color_bgr: list,
+ paper_color_bgr: list,
+ font_family: str = "Sans-Serif",
+ size_multiplier: float = 0.85
+) -> np.ndarray:
+ """
+ Erases text in a bounding box and renders replacement text with matching style.
+
+ Returns:
+ OpenCV BGR image containing the replacement text.
+ """
+ x, y, w, h = bbox
+
+ # 1. Erase original text using smart character mask inpainting
+ inpainted_bgr = erase_text_in_bbox(img_bgr, bbox, paper_color_bgr)
+
+ # 2. Convert to PIL Image for high-quality antialiased text drawing
+ pil_img = cv_to_pil(inpainted_bgr)
+ draw = ImageDraw.Draw(pil_img)
+
+ # 3. Load matching font and scale based on bounding box height (with dynamic character-level font auto-resolver)
+ font, font_size = load_matching_font(h, font_family, size_multiplier, text=replacement_text)
+
+ # Convert BGR color to RGB for Pillow
+ ink_color_rgb = (int(ink_color_bgr[2]), int(ink_color_bgr[1]), int(ink_color_bgr[0]))
+
+ # 4. Measure replacement text to align it properly
+ # Using modern draw.textbbox or fallback font.getbbox
+ try:
+ left, top, right, bottom = draw.textbbox((0, 0), replacement_text, font=font)
+ text_w = right - left
+ text_h = bottom - top
+ except AttributeError:
+ # Fallback for older PIL versions
+ text_w, text_h = font.getsize(replacement_text) if hasattr(font, 'getsize') else (w, h)
+ left, top = 0, 0
+
+ # Calculate drawing coordinates
+ # Center text horizontally or left-align it
+ # If text fits inside bounding box, we can left-align or center it
+ x_draw = x
+ # Center vertically inside the bounding box
+ y_draw = y + (h - text_h) // 2 - top
+
+ # Draw replacement text
+ draw.text((x_draw, y_draw), replacement_text, fill=ink_color_rgb, font=font)
+
+ # 5. Convert back to OpenCV BGR
+ return pil_to_cv(pil_img)
diff --git a/backend/utilities.py b/backend/utilities.py
new file mode 100644
index 0000000000000000000000000000000000000000..0729f3bf2382b74f5be759426bf4c5b566fa8a62
--- /dev/null
+++ b/backend/utilities.py
@@ -0,0 +1,57 @@
+import cv2
+import numpy as np
+from PIL import Image
+
+def pil_to_cv(pil_image: Image.Image) -> np.ndarray:
+ """Convert a PIL Image to an OpenCV BGR/BGRA numpy array."""
+ if pil_image.mode == 'RGBA':
+ # RGBA -> BGRA
+ return cv2.cvtColor(np.array(pil_image), cv2.COLOR_RGBA2BGRA)
+ else:
+ # RGB -> BGR
+ return cv2.cvtColor(np.array(pil_image.convert('RGB')), cv2.COLOR_RGB2BGR)
+
+def cv_to_pil(cv_image: np.ndarray) -> Image.Image:
+ """Convert an OpenCV BGR/BGRA numpy array to a PIL Image."""
+ if len(cv_image.shape) == 3 and cv_image.shape[2] == 4:
+ # BGRA -> RGBA
+ return Image.fromarray(cv2.cvtColor(cv_image, cv2.COLOR_BGRA2RGBA))
+ else:
+ # BGR -> RGB
+ return Image.fromarray(cv2.cvtColor(cv_image, cv2.COLOR_BGR2RGB))
+
+def resize_for_processing(img: np.ndarray, max_dim: int = 800) -> np.ndarray:
+ """Resize image preserving aspect ratio if its dimensions exceed max_dim."""
+ h, w = img.shape[:2]
+ if max(h, w) <= max_dim:
+ return img
+
+ if h > w:
+ new_h = max_dim
+ new_w = int(w * (max_dim / h))
+ else:
+ new_w = max_dim
+ new_h = int(h * (max_dim / w))
+
+ return cv2.resize(img, (new_w, new_h), interpolation=cv2.INTER_AREA)
+
+def create_checkerboard(width: int, height: int, square_size: int = 16) -> np.ndarray:
+ """Create a checkerboard pattern image (RGBA) for transparent preview background."""
+ # Create a grid of indices
+ y, x = np.indices((height, width))
+ # Calculate cell indices
+ grid = (x // square_size) + (y // square_size)
+ # Binary pattern (0 or 1)
+ checkerboard = (grid % 2) * 255
+
+ # Map to off-white and gray for a premium look (not harsh black and white)
+ # Cell color 1: #F8F9FA (248, 249, 250), Cell color 2: #E9ECEF (233, 236, 239)
+ color1 = [248, 249, 250, 255]
+ color2 = [233, 236, 239, 255]
+
+ bg = np.zeros((height, width, 4), dtype=np.uint8)
+ mask = (grid % 2) == 0
+ bg[mask] = color1
+ bg[~mask] = color2
+
+ return bg
diff --git a/frontend/__init__.py b/frontend/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..9ec8e51e45cc2143ae1901533ab8e9dc03ee452d
--- /dev/null
+++ b/frontend/__init__.py
@@ -0,0 +1 @@
+# Frontend package
diff --git a/frontend/__pycache__/__init__.cpython-311.pyc b/frontend/__pycache__/__init__.cpython-311.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..08aac9f42f762e9852a12a3212b7107684eeb524
Binary files /dev/null and b/frontend/__pycache__/__init__.cpython-311.pyc differ
diff --git a/frontend/__pycache__/__init__.cpython-314.pyc b/frontend/__pycache__/__init__.cpython-314.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..6da37eeecd240556dc98cdf4d0fdc48ff18c83a9
Binary files /dev/null and b/frontend/__pycache__/__init__.cpython-314.pyc differ
diff --git a/frontend/__pycache__/bg_remover_ui.cpython-311.pyc b/frontend/__pycache__/bg_remover_ui.cpython-311.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..c4d821767826ae2b6431ee87a9d25ff50b7cfbf4
Binary files /dev/null and b/frontend/__pycache__/bg_remover_ui.cpython-311.pyc differ
diff --git a/frontend/__pycache__/download_ui.cpython-311.pyc b/frontend/__pycache__/download_ui.cpython-311.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..0ca262db5041515d2b6b4f0921c7c45025cc7af1
Binary files /dev/null and b/frontend/__pycache__/download_ui.cpython-311.pyc differ
diff --git a/frontend/__pycache__/dslr_blur_ui.cpython-311.pyc b/frontend/__pycache__/dslr_blur_ui.cpython-311.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..a6c08a8d88d495ab9661652d19c5b54a8434d301
Binary files /dev/null and b/frontend/__pycache__/dslr_blur_ui.cpython-311.pyc differ
diff --git a/frontend/__pycache__/home.cpython-311.pyc b/frontend/__pycache__/home.cpython-311.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..e51b4f8a3b92df9bd670d53893130be42283cd9d
Binary files /dev/null and b/frontend/__pycache__/home.cpython-311.pyc differ
diff --git a/frontend/__pycache__/home.cpython-314.pyc b/frontend/__pycache__/home.cpython-314.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..dd8b8e9e737a7665bc9093fb8de025f72f3f16fc
Binary files /dev/null and b/frontend/__pycache__/home.cpython-314.pyc differ
diff --git a/frontend/__pycache__/preview_ui.cpython-311.pyc b/frontend/__pycache__/preview_ui.cpython-311.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..f48c9893ce6568da10b5133db975420fcf604eda
Binary files /dev/null and b/frontend/__pycache__/preview_ui.cpython-311.pyc differ
diff --git a/frontend/__pycache__/preview_ui.cpython-314.pyc b/frontend/__pycache__/preview_ui.cpython-314.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..d45094b0639e7a8a95d93c68c4e84348e2b4d7ad
Binary files /dev/null and b/frontend/__pycache__/preview_ui.cpython-314.pyc differ
diff --git a/frontend/__pycache__/text_editor_ui.cpython-311.pyc b/frontend/__pycache__/text_editor_ui.cpython-311.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..8f0fdb189b0a5beeb2c90630accf5df45128cdc9
Binary files /dev/null and b/frontend/__pycache__/text_editor_ui.cpython-311.pyc differ
diff --git a/frontend/__pycache__/upload_ui.cpython-311.pyc b/frontend/__pycache__/upload_ui.cpython-311.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..1a41684fd84c2e1e98f5ad309045ba61628abd98
Binary files /dev/null and b/frontend/__pycache__/upload_ui.cpython-311.pyc differ
diff --git a/frontend/bg_remover_ui.py b/frontend/bg_remover_ui.py
new file mode 100644
index 0000000000000000000000000000000000000000..d446b604c2324bef309694967d0e8e6d7f6f772d
--- /dev/null
+++ b/frontend/bg_remover_ui.py
@@ -0,0 +1,293 @@
+import streamlit as st
+from PIL import Image
+from backend.bg_remover.image_processor import ImageProcessor
+from frontend.upload_ui import render_upload_ui
+from frontend.preview_ui import render_preview_ui
+from frontend.download_ui import render_download_ui
+
+def load_demo_image():
+ """Create a high-quality synthetic demo image (a solid red sphere with a smooth drop shadow on white background)."""
+ w, h = 600, 600
+ img = Image.new("RGB", (w, h), "#FFFFFF")
+ from PIL import ImageDraw
+ draw = ImageDraw.Draw(img)
+
+ cx, cy, r = 300, 300, 160
+ for i in range(r, 0, -1):
+ f = i / r
+ cr = int(255 * (1.0 - f * 0.3))
+ cg = int(0 * f)
+ cb = int(128 + f * 127)
+ draw.ellipse([cx - i, cy - i, cx + i, cy + i], fill=(cr, cg, cb))
+
+ st.session_state.original_image = img
+ st.session_state.filename = "demo_gradient_sphere.png"
+ st.session_state.processed_results = None # clear previous cache
+
+def render_bg_remover_ui():
+ """Renders the complete AI Background Remover workspace, including sidebar controls and visual output areas."""
+ st.markdown(
+ """
+
Extract foreground subjects, refine hair details, and generate dropshadows.
+No image ready? Test instantly with a pre-configured demo image:
", unsafe_allow_html=True) + col_demo_left, col_demo_center, col_demo_right = st.columns([1, 1, 1]) + with col_demo_center: + if st.button("Load Demo Sphere", use_container_width=True): + load_demo_image() + st.rerun() + else: + col_title, col_reset = st.columns([5, 1]) + with col_reset: + if st.button("Reset / Clear", use_container_width=True): + st.session_state.original_image = None + st.session_state.filename = "image" + st.session_state.processed_results = None + st.rerun() + + with st.spinner("Processing foreground extraction pipeline..."): + if ( + st.session_state.processed_results is None + or st.session_state.processed_results.get("param_hash") != param_hash + ): + max_dim = None if always_full_res else 800 + + results = ImageProcessor.process_image( + pil_image=st.session_state.original_image, + rect=manual_bbox, + margin_percentage=5.0, + iter_count=iter_count, + bg_seed_sensitivity=bg_seed_sensitivity, + closing_size=closing_size, + keep_largest_only=keep_largest, + feather_radius=feather_radius, + matting_enabled=matting_enabled, + matting_radius=matting_radius, + matting_eps=matting_eps, + shadow_enabled=shadow_enabled, + shadow_opacity=shadow_opacity, + shadow_blur=shadow_blur, + shadow_distance=shadow_distance, + shadow_angle=shadow_angle, + max_preview_dim=max_dim, + subject_mode=subject_mode + ) + results["param_hash"] = param_hash + st.session_state.processed_results = results + + results = st.session_state.processed_results + + if keep_largest: + st.info("💡 **Working with Signatures or Text?** If parts of your signature (like separate letters or strokes) are missing, **uncheck 'Remove Stray Speckles'** in the sidebar to prevent them from being filtered out.") + + render_preview_ui( + original_pil=results["original"], + processed_pil=results["shadow"] if shadow_enabled else results["transparent"], + bounding_box=results["rect"], + show_bounding_box=show_bb + ) + + # Compile full resolution downloads lazy + if not always_full_res: + st.info("ℹ️ Download files are generated at full high-resolution. Processing begins when clicking below.") + + if "full_res_results" not in st.session_state or st.session_state.get("full_res_hash") != param_hash: + with st.spinner("Compiling full high-resolution outputs..."): + full_res = ImageProcessor.process_image( + pil_image=st.session_state.original_image, + rect=manual_bbox, + margin_percentage=5.0, + iter_count=iter_count, + bg_seed_sensitivity=bg_seed_sensitivity, + closing_size=closing_size, + keep_largest_only=keep_largest, + feather_radius=feather_radius, + matting_enabled=matting_enabled, + matting_radius=matting_radius, + matting_eps=matting_eps, + shadow_enabled=shadow_enabled, + shadow_opacity=shadow_opacity, + shadow_blur=shadow_blur, + shadow_distance=shadow_distance, + shadow_angle=shadow_angle, + max_preview_dim=None, # Full resolution + subject_mode=subject_mode + ) + st.session_state.full_res_results = full_res + st.session_state.full_res_hash = param_hash + + dl_transparent = st.session_state.full_res_results["transparent"] + dl_shadow = st.session_state.full_res_results["shadow"] + else: + dl_transparent = results["transparent"] + dl_shadow = results["shadow"] + + render_download_ui( + transparent_pil=dl_transparent, + shadow_pil=dl_shadow, + filename=st.session_state.filename + ) diff --git a/frontend/download_ui.py b/frontend/download_ui.py new file mode 100644 index 0000000000000000000000000000000000000000..385d8810a1732036bb2436c8d2d863105bf3d468 --- /dev/null +++ b/frontend/download_ui.py @@ -0,0 +1,91 @@ +import streamlit as st +from PIL import Image +import io + +def convert_pil_to_bytes(pil_img: Image.Image) -> bytes: + """Helper to convert a PIL Image into a downloadable byte stream (PNG).""" + buf = io.BytesIO() + pil_img.save(buf, format="PNG", optimize=True) + return buf.getvalue() + +def render_download_ui(transparent_pil: Image.Image, shadow_pil: Image.Image, filename: str = "cutout"): + """ + Renders styled download buttons for the transparent cutout and shadow-composite images. + """ + st.markdown( + """ +Transparent PNG
+Best for composition, overlays, and design work.
+Realistic Shadow Composite
+Includes refined transparent subject + drop shadow.
++ 💡 Pro-Tip: All images are exported as lossless, high-fidelity RGBA PNG format to preserve the soft feathered edges and custom opacity shadows. +
+ """, + unsafe_allow_html=True + ) diff --git a/frontend/dslr_blur_ui.py b/frontend/dslr_blur_ui.py new file mode 100644 index 0000000000000000000000000000000000000000..f455f95bab15ab5c1e319fa52fe0f6576180705f --- /dev/null +++ b/frontend/dslr_blur_ui.py @@ -0,0 +1,451 @@ +import streamlit as st +from PIL import Image +import io +import numpy as np +from backend.bg_remover.image_processor import ImageProcessor +from backend.dslr_blur.depth_blur import DepthBlurEngine +from backend.enhancement.photo_enhancer import PhotoEnhancer +from frontend.upload_ui import render_upload_ui + +def load_demo_dslr_image(): + """Create a high-quality synthetic demo image representing a studio portrait for DSLR testing.""" + w, h = 600, 600 + img = Image.new("RGB", (w, h), "#E6ECEF") # soft grey studio background + from PIL import ImageDraw + draw = ImageDraw.Draw(img) + + # Draw some complex patterned background details to highlight the depth-blur falloff + for x in range(30, w, 60): + # Draw background window panes / grids + draw.line([x, 0, x, h], fill="#CBD5E1", width=3) + draw.line([0, x, w, x], fill="#CBD5E1", width=3) + + # Draw a mock horizon/floor divider (depth perspective) + draw.line([0, 420, w, 420], fill="#94A3B8", width=4) + + # Draw the main subject (a mock portrait outline representing a person) + # Head + draw.ellipse([220, 100, 380, 260], fill="#FDBA74", outline="#F97316", width=3) # head skin-tone + # Hair + draw.chord([210, 80, 390, 220], 180, 360, fill="#1E293B") # dark hair block + # Eyes + draw.ellipse([260, 160, 280, 180], fill="#0284C7") + draw.ellipse([320, 160, 340, 180], fill="#0284C7") + # Smile + draw.arc([270, 180, 330, 220], 0, 180, fill="#EF4444", width=3) + # Torso/Clothing + draw.ellipse([150, 260, 450, 560], fill="#3B82F6", outline="#1D4ED8", width=3) # blue jacket + + st.session_state.dslr_original_image = img + st.session_state.dslr_filename = "demo_studio_portrait.png" + st.session_state.dslr_stage_cache = None + st.session_state.dslr_preview_results = None + st.session_state.dslr_full_res_results = None + +def render_dslr_blur_ui(): + """Renders the comprehensive, production-grade AI DSLR Blur and Photo Enhancement workspace.""" + st.markdown( + """ +Transform ordinary mobile snapshots into magazine-cover DSLR portraits. Enhances skin textures, estimates physical depth layers, and simulates optical wide-aperture lenses.
+No files loaded? Test instantly with a pre-configured studio portrait:
", unsafe_allow_html=True) + col_demo_left, col_demo_center, col_demo_right = st.columns([1, 1, 1]) + with col_demo_center: + if st.button("Load Studio Demo", use_container_width=True): + load_demo_dslr_image() + st.rerun() + else: + col_title, col_reset = st.columns([5, 1]) + with col_reset: + if st.button("Reset / Clear", key="dslr_clear", use_container_width=True): + st.session_state.dslr_original_image = None + st.session_state.dslr_filename = "portrait" + st.session_state.dslr_stage_cache = None + st.session_state.dslr_preview_results = None + st.session_state.dslr_full_res_results = None + st.rerun() + + # ----------------- DUAL-HASH STAGE 1: AI PHOTO ENHANCEMENT, SEGMENTATION, & DEPTH ----------------- + with st.spinner("Executing neural segmentation, face restoration, and depth estimation..."): + + if ( + st.session_state.dslr_stage_cache is None + or st.session_state.dslr_stage_cache["hash"] != segment_hash + ): + max_dim = None if always_full_res else 800 + + # 1. Run AI Photo Enhancement & Facial restoration (Real-ESRGAN + GFPGAN) + enhanced_pil = PhotoEnhancer.process_enhancement( + pil_image=st.session_state.dslr_original_image, + mode=enhancement_mode, + face_restoration=face_restoration + ) + + # 2. Run high-fidelity subject segmentation (BiRefNet / U²-Net) + seg_res = ImageProcessor.process_image( + pil_image=enhanced_pil, + rect=None, + margin_percentage=5.0, + iter_count=5, + closing_size=5, + keep_largest_only=True, + feather_radius=3, + matting_enabled=True, + shadow_enabled=False, + max_preview_dim=max_dim, + subject_mode=subject_mode + ) + + # Store cached segments + st.session_state.dslr_stage_cache = { + "hash": segment_hash, + "enhanced": seg_res["original"], # Transposed & enhanced canvas + "mask": seg_res["mask"] # Grayscale subject mask + } + + # Invalidate subsequent blur preview composites + st.session_state.dslr_preview_results = None + st.session_state.dslr_full_res_results = None + + # Retrieve cached layers + cached_enhanced = st.session_state.dslr_stage_cache["enhanced"] + cached_mask = st.session_state.dslr_stage_cache["mask"] + + # ----------------- DUAL-HASH STAGE 2: SPATially-VARYING DEPTH BOKEH COMPOSITE ----------------- + with st.spinner("Synthesizing optical DSLR depth-of-field blur..."): + + if ( + st.session_state.dslr_preview_results is None + or st.session_state.dslr_preview_results["hash"] != blur_hash + ): + # Execute Spatially Varying Depth Blur Engine + blur_res = DepthBlurEngine.process_depth_blur( + pil_image=cached_enhanced, + mask_pil=cached_mask, + blur_mode=blur_mode, + blur_preset=blur_preset, + blur_strength=blur_strength, + edge_feathering=edge_feathering, + subject_protection=subject_protection, + background_smoothness=background_smoothness + ) + + st.session_state.dslr_preview_results = { + "hash": blur_hash, + "result": blur_res["result"], + "depth_map": blur_res["depth_map"] + } + + preview_img = st.session_state.dslr_preview_results["result"] + depth_img = st.session_state.dslr_preview_results["depth_map"] + + # ----------------- VISUAL STUDIO COMPARISONS ----------------- + st.markdown( + """ +ORIGINAL CANVASES
', unsafe_allow_html=True) + st.image(st.session_state.dslr_original_image, use_container_width=True) + with col_c: + st.markdown('ESTIMATED RELATIVE DEPTH
', unsafe_allow_html=True) + st.image(depth_img, use_container_width=True) + with col_r: + st.markdown('DSLR PORTRAIT COMPOSITE
', unsafe_allow_html=True) + st.image(preview_img, use_container_width=True) + else: + col_left, col_right = st.columns(2) + with col_left: + st.markdown('ORIGINAL SOURCE IMAGE
', unsafe_allow_html=True) + st.image(st.session_state.dslr_original_image, use_container_width=True) + with col_right: + st.markdown('PRO DSLR BOKEH COMPOSITE
', unsafe_allow_html=True) + st.image(preview_img, use_container_width=True) + + # ----------------- LAZY HIGH-RESOLUTION COMPILER ----------------- + if not always_full_res: + st.info("ℹ️ Export files are generated at full high-resolution. High-quality processing builds when clicking below.") + + # Setup full resolution hash keys + full_seg_hash = ( + st.session_state.dslr_original_image, + enhancement_mode, + face_restoration, + subject_mode, + True # Full resolution + ) + + if ( + st.session_state.dslr_full_res_results is None + or st.session_state.dslr_full_res_results["hash"] != (full_seg_hash, blur_hash) + ): + st.session_state.dslr_full_res_results = None # clean cache + + with st.spinner("Compiling full high-resolution DSLR portrait (Upscaling & Face Restoration)..."): + # 1. Full-Res Photo Enhancement + full_enhanced_pil = PhotoEnhancer.process_enhancement( + pil_image=st.session_state.dslr_original_image, + mode=enhancement_mode, + face_restoration=face_restoration + ) + + # 2. Full-Res Subject Segmentation + full_seg_res = ImageProcessor.process_image( + pil_image=full_enhanced_pil, + rect=None, + margin_percentage=5.0, + iter_count=5, + closing_size=5, + keep_largest_only=True, + feather_radius=3, + matting_enabled=True, + shadow_enabled=False, + max_preview_dim=None, # Full size + subject_mode=subject_mode + ) + + # 3. Full-Res Spatially Varying Depth Blur + full_blur_res = DepthBlurEngine.process_depth_blur( + pil_image=full_seg_res["original"], + mask_pil=full_seg_res["mask"], + blur_mode=blur_mode, + blur_preset=blur_preset, + blur_strength=blur_strength, + edge_feathering=edge_feathering, + subject_protection=subject_protection, + background_smoothness=background_smoothness + ) + + st.session_state.dslr_full_res_results = { + "hash": (full_seg_hash, blur_hash), + "result": full_blur_res["result"] + } + + dl_image = st.session_state.dslr_full_res_results["result"] + else: + dl_image = preview_img + + # ----------------- PRO PORTRAIT EXPORTS ----------------- + st.markdown( + """ +High Quality JPG
+Great for social sharing. Compression matches standard camera outputs.
+Lossless PNG
+Lossless quality containing perfect sub-pixel detail and transparency.
++ AI Image & Doc Workspace +
++ Powerhouse tools for automated high-fidelity background removal and smart document text replacement. +
++ Isolate subjects with pinpoint precision using U²-Net deep learning or general GrabCut extraction. + Refine edge feathering, capture fine hair fibers with Guided Filter matting, and compose realistic + drop shadows. +
++ Create beautiful portrait photographs with optical shallow depth-of-field. + Features standard Gaussian blurs or realistic circular lens bokeh apertures with edge inpainting to + completely eliminate colored halo bleeding. +
++ Erase and replace text in images or multi-page PDFs seamlessly. + Automatically scans fonts and text placement via EasyOCR, erases targeted ink using + Telea texture-preserving inpainting, and draws new text in matching fonts, sizes, and colors. +
++ ⚡ Powered by U²-Net Segmenter, EasyOCR Reader, Guided Filter Matting, OpenCV DSLR Blur & Inpainting. +
+ORIGINAL SOURCE IMAGE
', unsafe_allow_html=True) + st.image(display_original, use_container_width=True) + + with col_right: + st.markdown('EXTRACTED SUBJECT PREVIEW
', unsafe_allow_html=True) + st.image(composited_processed, use_container_width=True) diff --git a/frontend/text_editor_ui.py b/frontend/text_editor_ui.py new file mode 100644 index 0000000000000000000000000000000000000000..9e2427b44eea00cdfa1f9a74afeae820de06df98 --- /dev/null +++ b/frontend/text_editor_ui.py @@ -0,0 +1,558 @@ +import streamlit as st +from PIL import Image, ImageDraw +import os +import io + +from backend.text_editor.pdf_processor import get_pdf_page_count, pdf_page_to_pil, compile_images_to_pdf +from backend.text_editor.ocr_engine import EASYOCR_AVAILABLE +from backend.text_editor.orchestrator import TextEditorOrchestrator +from backend.text_editor.font_detector import load_matching_font + +def load_demo_invoice(): + """Create a beautiful synthetic demo invoice image to test instantly.""" + w, h = 700, 900 + img = Image.new("RGB", (w, h), "#FAFAFA") + draw = ImageDraw.Draw(img) + + draw.rectangle([20, 20, w-20, h-20], fill="#FFFFFF", outline="#E0E0E0", width=2) + + try: + from PIL import ImageFont + font_large = ImageFont.truetype("C:\\Windows\\Fonts\\arial.ttf", 36) + font_med = ImageFont.truetype("C:\\Windows\\Fonts\\arial.ttf", 16) + font_bold = ImageFont.truetype("C:\\Windows\\Fonts\\arialbd.ttf", 16) + except Exception: + font_large = ImageFont.load_default() + font_med = ImageFont.load_default() + font_bold = ImageFont.load_default() + + draw.text((50, 60), "INVOICE", fill="#1A1B35", font=font_large) + draw.text((50, 140), "Invoice Number: INV-2026-9042", fill="#555555", font=font_med) + draw.text((50, 170), "Date: May 29, 2026", fill="#555555", font=font_med) + draw.text((50, 200), "Billed To: Antigravity User", fill="#555555", font=font_med) + + draw.line([50, 240, w-50, 240], fill="#E0E0E0", width=1) + + draw.text((50, 260), "Description", fill="#1A1B35", font=font_bold) + draw.text((450, 260), "Quantity", fill="#1A1B35", font=font_bold) + draw.text((550, 260), "Amount", fill="#1A1B35", font=font_bold) + + draw.line([50, 290, w-50, 290], fill="#1A1B35", width=2) + + draw.text((50, 310), "AI Background Remover Software Licence", fill="#555555", font=font_med) + draw.text((450, 310), "1", fill="#555555", font=font_med) + draw.text((550, 310), "$149.00", fill="#555555", font=font_med) + + draw.text((50, 350), "Advanced Guided Filter Matting Module", fill="#555555", font=font_med) + draw.text((450, 350), "2", fill="#555555", font=font_med) + draw.text((550, 350), "$199.00", fill="#555555", font=font_med) + + draw.line([50, 420, w-50, 420], fill="#E0E0E0", width=1) + + draw.text((400, 450), "Subtotal:", fill="#555555", font=font_med) + draw.text((550, 450), "$348.00", fill="#555555", font=font_med) + + draw.text((400, 480), "Tax (10%):", fill="#555555", font=font_med) + draw.text((550, 480), "$34.80", fill="#555555", font=font_med) + + draw.text((400, 520), "Total Due:", fill="#1A1B35", font=font_bold) + draw.text((550, 520), "$382.80", fill="#1A1B35", font=font_bold) + + draw.rectangle([50, 780, w-50, 840], fill="#F0F4F8", outline="#D0DCE5") + draw.text((70, 795), "Thank you for your business! For queries contact support@antigravity.ai", fill="#4A5D6E", font=font_med) + + st.session_state.te_original_file = img + st.session_state.te_file_type = "image" + st.session_state.te_pdf_filename = "demo_invoice.png" + st.session_state.te_original_image = img + st.session_state.te_pdf_pages = [img] + st.session_state.te_edited_image = None + st.session_state.te_replacements = [] + st.session_state.te_ocr_results = None + +def draw_ocr_overlays_with_badges(pil_img: Image.Image, ocr_results: list) -> Image.Image: + """Overlays high-quality translucent bounding boxes and numbered circular badges over text areas.""" + overlay_img = pil_img.copy() + draw = ImageDraw.Draw(overlay_img, "RGBA") + + try: + from PIL import ImageFont + font_badge = ImageFont.truetype("C:\\Windows\\Fonts\\arial.ttf", 13) + except Exception: + font_badge = ImageFont.load_default() + + for idx, item in enumerate(ocr_results): + x, y, w, h = item["bbox"] + # Draw translucent cyan bounding box + draw.rectangle([x, y, x + w, y + h], fill=(0, 240, 255, 15), outline=(0, 240, 255, 160), width=1) + + # Draw small solid pink badge at top-left corner of bounding box + bx, by = x - 5, y - 5 + br = 9 # radius + draw.ellipse([bx - br, by - br, bx + br, by + br], fill=(255, 0, 127, 240)) + + # Center number in circle + num_str = str(idx + 1) + draw.text((bx - 4, by - 6), num_str, fill=(255, 255, 255, 255), font=font_badge) + + return overlay_img + +def generate_font_preview_pil(text: str, font_name: str) -> Image.Image: + """Generates a small visual PIL image showing the replacement text rendered in the Google Font.""" + w, h = 280, 50 + canvas = Image.new("RGB", (w, h), "#F8F9FA") + draw = ImageDraw.Draw(canvas) + + try: + font, font_size = load_matching_font(bbox_height=26, font_family=font_name, size_multiplier=1.0, text=text) + draw.text((10, 12), text, fill=(26, 27, 53), font=font) + except Exception: + draw.text((10, 15), text, fill=(26, 27, 53)) + + return canvas + +def render_text_editor_ui(): + """Renders the modular AI In-Image & PDF Document Text Editor workspace with interactive numbered badges.""" + st.markdown( + """ +Correct text directly using visual numbered badges. Search and preview the most matching Google Fonts.
++ Upload an Image or multi-page PDF Document. Coordinates and fonts will be parsed instantly. +
+No files ready? Test instantly with a pre-configured demo invoice:
", unsafe_allow_html=True) + col_demo_left, col_demo_center, col_demo_right = st.columns([1, 1, 1]) + with col_demo_center: + if st.button("Load Demo Invoice", use_container_width=True): + load_demo_invoice() + st.rerun() + + # Case 2: Document loaded + else: + col_nav, col_reset = st.columns([5, 1]) + + with col_reset: + if st.button("Reset / Clear", key="te_reset", use_container_width=True): + st.session_state.te_original_file = None + st.session_state.te_file_type = None + st.session_state.te_pdf_filename = "document" + st.session_state.te_original_image = None + st.session_state.te_pdf_pages = [] + st.session_state.te_ocr_results = None + st.session_state.te_edited_image = None + st.session_state.te_replacements = [] + st.rerun() + + with col_nav: + if st.session_state.te_file_type == "pdf": + pages_count = len(st.session_state.te_pdf_pages) + st.markdown( + f""" +1. ORIGINAL SOURCE DOCUMENT
', unsafe_allow_html=True) + + # Display image with circular number badges + if overlay_ocr and st.session_state.te_ocr_results: + original_badged = draw_ocr_overlays_with_badges(st.session_state.te_original_image, st.session_state.te_ocr_results) + st.image(original_badged, use_container_width=True) + else: + st.image(st.session_state.te_original_image, use_container_width=True) + + st.markdown('2. EDITED DOCUMENT PREVIEW
', unsafe_allow_html=True) + if st.session_state.te_edited_image is not None: + st.image(st.session_state.te_edited_image, use_container_width=True) + else: + st.markdown( + """ +
+ No edits made yet.
Modify a text block on the right panel to see updates.
+
3. CLICK & EDIT DOCUMENT TEXT FIELDS
', unsafe_allow_html=True) + + if not st.session_state.te_ocr_results: + st.warning("⚠️ No text detected in this page. Try uploading an image with higher contrast or clear characters.") + else: + # Present list of all detected blocks as visual numbered rows + st.markdown('4. MATCHING GOOGLE FONT PREVIEW GALLERY
', unsafe_allow_html=True) + st.info("💡 **Google Font Search**: Choose a text block on the left and see how it renders in these popular Google Fonts in real-time, then type the matching name into **Or Search Google Font** in the sidebar!") + + # Let user pick which replacement string to preview in the gallery + words_to_preview = [item["text"] for item in st.session_state.te_ocr_results] + selected_preview_word = st.selectbox("Select word to preview fonts:", words_to_preview) + + # Font Showcase grid + st.markdown('Complete Edited PDF
+Re-compiles all pages into a single PDF document.
+Active Page Image (PNG)
+Downloads the current page as a single high-DPI image.
+Edited Image Output
+Downloads the text-corrected image as high-fidelity PNG.
++ Drag and drop your image or browse files. Supports high-resolution JPG, PNG, and WEBP. +
+