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( + """ +
+

🖼️ AI Background Remover

+

Extract foreground subjects, refine hair details, and generate dropshadows.

+
+ """, + unsafe_allow_html=True + ) + + # ----------------- SESSION STATE INITIALIZATION ----------------- + if "original_image" not in st.session_state: + st.session_state.original_image = None + if "filename" not in st.session_state: + st.session_state.filename = "image" + if "processed_results" not in st.session_state: + st.session_state.processed_results = None + + # ----------------- SIDEBAR CONTROLS ----------------- + st.sidebar.markdown("### ⚙️ BG REMOVER CONTROLS") + + subject_mode = st.sidebar.selectbox( + "Subject Extraction Mode", + ["AI BiRefNet (SOTA General)", "AI U²-Net (Legacy Neural)", "General Subject (GrabCut)", "Signature & Text (Ink)"], + index=0, + help="AI BiRefNet uses state-of-the-art bilateral reference models for ultra-sharp edge matting. U²-Net is legacy neural. General uses GrabCut margins. Signature extracts ink." + ) + + if subject_mode == "General Subject (GrabCut)": + st.sidebar.markdown("#### 🟥 Bounding Box Configuration") + bb_mode = st.sidebar.radio( + "Initialization Mode", + ["Automated Bounding Box (Recommended)", "Manual Coordinate Sliders"], + help="Automated finds the subject using edge density. Manual lets you control the crop boundary." + ) + + manual_bbox = None + if bb_mode == "Manual Coordinate Sliders" and st.session_state.original_image is not None: + img_w, img_h = st.session_state.original_image.size + st.sidebar.info("Adjust margins in pixels to frame the subject:") + bb_x = st.sidebar.slider("Left Coordinate (X)", 0, img_w - 20, int(img_w * 0.05)) + bb_y = st.sidebar.slider("Top Coordinate (Y)", 0, img_h - 20, int(img_h * 0.05)) + bb_w = st.sidebar.slider("Width (W)", 20, img_w - bb_x, int(img_w * 0.9)) + bb_h = st.sidebar.slider("Height (H)", 20, img_h - bb_y, int(img_h * 0.9)) + manual_bbox = (bb_x, bb_y, bb_w, bb_h) + + iter_count = st.sidebar.slider( + "GrabCut Iterations", + min_value=1, + max_value=10, + value=5, + help="Higher values refine the classification but process slower." + ) + + bg_seed_sensitivity = st.sidebar.slider( + "Background Color Seeding", + min_value=0.0, + max_value=100.0, + value=35.0, + step=1.0, + help="Assists GrabCut by matching background colors from image corners. Set to 0.0 to disable seeding." + ) + show_bb = (bb_mode == "Manual Coordinate Sliders") + else: + manual_bbox = None + iter_count = 5 + bg_seed_sensitivity = 35.0 + show_bb = False + bb_mode = "Automated Bounding Box (Recommended)" + + st.sidebar.markdown("#### ⚡ Edge Refinement & Softening") + closing_size = st.sidebar.slider( + "Morphological Closing Size", + min_value=1, + max_value=21, + value=5, + step=2, + help="Fills holes and smooths the interior contours of the cutout." + ) + feather_radius = st.sidebar.slider( + "Edge Feathering Radius", + min_value=0, + max_value=15, + value=3, + help="Creates smooth anti-aliased edge blends. Prevents pixelation." + ) + keep_largest = st.sidebar.checkbox( + "Remove Stray Speckles", + value=True, + help="Keeps only the largest foreground object, filtering out background noise." + ) + + matting_enabled = st.sidebar.checkbox( + "Enable Hair & Detail Matting", + value=True, + help="Uses Guided Filter to align cutout edges perfectly to fine hair strands." + ) + + if matting_enabled: + matting_radius = st.sidebar.slider( + "Matting Search Radius", + min_value=1, + max_value=30, + value=10, + help="Search window radius for hair and fine details." + ) + matting_eps = st.sidebar.slider( + "Matting Smoothness (Eps)", + min_value=0.0001, + max_value=0.01, + value=0.001, + step=0.0005, + format="%.4f", + help="Controls details alignment. Lower values capture sharper hair details." + ) + else: + matting_radius = 10 + matting_eps = 0.001 + + st.sidebar.markdown("#### 🌗 Realistic Shadow Engine") + shadow_enabled = st.sidebar.checkbox("Enable Drop Shadow", value=False) + + if shadow_enabled: + shadow_opacity = st.sidebar.slider("Shadow Opacity", 0.0, 1.0, 0.4, 0.05) + shadow_blur = st.sidebar.slider("Shadow Softness (Blur)", 0, 50, 15, 1) + shadow_distance = st.sidebar.slider("Shadow Distance Offset", 0, 100, 20, 1) + shadow_angle = st.sidebar.slider("Shadow Direction (Angle)", 0, 360, 45, 5, format="%d°") + else: + shadow_opacity, shadow_blur, shadow_distance, shadow_angle = 0.4, 15, 20, 45 + + st.sidebar.markdown("#### 🚀 Performance Options") + resolution_mode = st.sidebar.radio( + "Processing Resolution", + ["High-Speed Preview (Max 800px)", "Full Resolution (High Quality)"], + help="High-Speed Preview keeps UI sliders highly responsive. When downloaded, images can run at full size." + ) + always_full_res = (resolution_mode == "Full Resolution (High Quality)") + + # Hash check + param_hash = ( + manual_bbox, + iter_count, + bg_seed_sensitivity, + closing_size, + feather_radius, + keep_largest, + matting_enabled, + matting_radius, + matting_eps, + shadow_enabled, + shadow_opacity, + shadow_blur, + shadow_distance, + shadow_angle, + always_full_res, + subject_mode + ) + + # ----------------- MAIN CONTENT AREA ----------------- + if st.session_state.original_image is None: + col_l, col_c, col_r = st.columns([1, 3, 1]) + with col_c: + uploaded_img = render_upload_ui() + if uploaded_img is not None: + st.session_state.original_image = uploaded_img + st.session_state.processed_results = None + st.rerun() + + st.markdown("

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( + """ +
+

+ 💾 EXPORT RESULT +

+
+ """, + unsafe_allow_html=True + ) + + # Pre-render images to bytes + with st.spinner("Preparing high-quality files for export..."): + transparent_bytes = convert_pil_to_bytes(transparent_pil) + + # Check if shadow is enabled (i.e. different from transparent_pil) + # In our case, we can check if they are separate or just present both + shadow_bytes = convert_pil_to_bytes(shadow_pil) + + st.markdown('
', unsafe_allow_html=True) + + col_dl1, col_dl2 = st.columns(2) + + # Strip extension from filename for the download prefix + base_name = filename.rsplit('.', 1)[0] if '.' in filename else filename + + with col_dl1: + st.markdown( + """ +
+ +

Transparent PNG

+

Best for composition, overlays, and design work.

+
+ """, + unsafe_allow_html=True + ) + st.download_button( + label="Download Transparent Cutout", + data=transparent_bytes, + file_name=f"{base_name}_cutout.png", + mime="image/png", + key="dl_transparent", + use_container_width=True + ) + + with col_dl2: + st.markdown( + """ +
+ 🌗 +

Realistic Shadow Composite

+

Includes refined transparent subject + drop shadow.

+
+ """, + unsafe_allow_html=True + ) + st.download_button( + label="Download Shadow Composite", + data=shadow_bytes, + file_name=f"{base_name}_shadow.png", + mime="image/png", + key="dl_shadow", + use_container_width=True + ) + + st.markdown('
', unsafe_allow_html=True) + + # Optional tip info + st.markdown( + """ +

+ 💡 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( + """ +
+

📸 Professional AI DSLR Blur & Enhancement

+

Transform ordinary mobile snapshots into magazine-cover DSLR portraits. Enhances skin textures, estimates physical depth layers, and simulates optical wide-aperture lenses.

+
+ """, + unsafe_allow_html=True + ) + + # ----------------- SESSION STATE INITIALIZATION ----------------- + if "dslr_original_image" not in st.session_state: + st.session_state.dslr_original_image = None + if "dslr_filename" not in st.session_state: + st.session_state.dslr_filename = "portrait" + if "dslr_stage_cache" not in st.session_state: + st.session_state.dslr_stage_cache = None + if "dslr_preview_results" not in st.session_state: + st.session_state.dslr_preview_results = None + if "dslr_full_res_results" not in st.session_state: + st.session_state.dslr_full_res_results = None + + # ----------------- SIDEBAR CONTROLS ----------------- + st.sidebar.markdown("### ⚙️ PHOTO STUDIO WORKSHOP") + + # 1. Image Enhancement Mode + enhancement_mode = st.sidebar.selectbox( + "AI Enhancement Mode", + ["Professional DSLR", "High Quality (Neural)", "Standard"], + index=0, + help="Pro DSLR runs Real-ESRGAN super-resolution + GFPGAN face restoration + CLAHE color grading. High Quality utilizes neural upscale. Standard is high-speed OpenCV." + ) + + face_restoration = st.sidebar.checkbox( + "Face Enhancement (GFPGAN)", + value=True, + disabled=(enhancement_mode == "Standard"), + help="Natively restores eyes, teeth, skin textures, and dynamic facial range without creating artificial overprocessing." + ) + + # 2. Subject Extraction Engine + subject_mode = st.sidebar.selectbox( + "Subject Extraction Mode", + ["AI BiRefNet (SOTA General)", "AI U²-Net (Legacy Neural)", "General Subject (GrabCut)"], + index=0, + help="AI BiRefNet provides state-of-the-art salient detection capturing fine hairs. GrabCut uses automated bounding boxes." + ) + + st.sidebar.markdown("#### 🌗 DSLR Aperture Blurring") + + # 3. Blur Simulation Engine + blur_mode = st.sidebar.selectbox( + "Optical Lens Simulation", + ["Lens Blur / Circular Bokeh (Realistic DSLR)", "Gaussian Blur (Soft & Smooth)"], + index=0, + help="Lens Blur convolves the background to expand specular highlights into circular bokeh diaphragms. Gaussian is standard smooth blur." + ) + + # 4. Blur Preset Dropdown + blur_preset = st.sidebar.selectbox( + "DSLR Blur Preset", + ["Portrait", "DSLR 50mm", "DSLR 85mm", "Studio", "Cinematic", "Custom Settings"], + index=2, + help="Select a standard DSLR lens preset to configure max aperture blur automatically, or choose Custom Settings." + ) + + # Render custom blur strength slider if Custom Settings selected + is_custom = (blur_preset == "Custom Settings") + blur_strength = st.sidebar.slider( + "Max Blur Aperture Strength", + min_value=1, + max_value=100, + value=45, + disabled=not is_custom, + help="Enabled under Custom Settings. Adjusts the maximum lens circle of confusion diameter." + ) + + edge_feathering = st.sidebar.slider( + "Edge Feathering Radius", + min_value=0, + max_value=30, + value=5, + help="Softens the boundaries of the subject to prevent an artificial sharp cutout outline." + ) + + subject_protection = st.sidebar.slider( + "Subject Protection Strength", + min_value=0, + max_value=100, + value=85, + help="Controls the sharpness threshold inside the subject mask to keep high-frequency details perfectly sharp." + ) + + background_smoothness = st.sidebar.slider( + "Background Smoothness", + min_value=0, + max_value=100, + value=30, + help="Applies a bilateral filter to suppress sensor noise in the background, making it ultra-creamy." + ) + + show_depth_map = st.sidebar.checkbox( + "Preview Relative Depth Map", + value=False, + help="Displays the relative depth map estimated by Depth Anything V2." + ) + + # 5. Performance Speed config + resolution_mode = st.sidebar.radio( + "Processing Resolution", + ["High-Speed Preview (Max 800px)", "Full Resolution (High Quality)"], + key="dslr_res_mode", + help="High-Speed Preview keeps sliders fast. Downloads will still compile at full resolution." + ) + always_full_res = (resolution_mode == "Full Resolution (High Quality)") + + # Hash check configurations (Dual-Hash caching) + # segment_hash: parameters affecting U²-Net/BiRefNet, Depth Anything V2, and Real-ESRGAN/GFPGAN enhancements + segment_hash = ( + st.session_state.dslr_original_image, + enhancement_mode, + face_restoration, + subject_mode, + always_full_res + ) + + # blur_hash: parameters affecting spatially-varying DoF blending compositing + blur_hash = ( + blur_mode, + blur_preset, + blur_strength, + edge_feathering, + subject_protection, + background_smoothness + ) + + # ----------------- MAIN STUDIO INTERFACE ----------------- + if st.session_state.dslr_original_image is None: + col_l, col_c, col_r = st.columns([1, 3, 1]) + with col_c: + uploaded_img = render_upload_ui() + if uploaded_img is not None: + from PIL import ImageOps + st.session_state.dslr_original_image = ImageOps.exif_transpose(uploaded_img) + 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() + + st.markdown("

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( + """ +
+

+ 🖼️ VISUAL COMPARISON +

+
+ """, + unsafe_allow_html=True + ) + + # Render a 3-column comparative view if depth map is previewed, otherwise 2 columns + if show_depth_map: + col_l, col_c, col_r = st.columns(3) + with col_l: + 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( + """ +
+

+ 💾 EXPORT PORTRAIT +

+
+ """, + unsafe_allow_html=True + ) + + st.markdown('
', unsafe_allow_html=True) + col_dl_jpg, col_dl_png = st.columns(2) + + filename_base = st.session_state.dslr_filename.rsplit('.', 1)[0] + + with col_dl_jpg: + jpg_buf = io.BytesIO() + dl_image.convert("RGB").save(jpg_buf, format="JPEG", quality=95) + jpg_bytes = jpg_buf.getvalue() + + st.markdown( + """ +
+ 📸 +

High Quality JPG

+

Great for social sharing. Compression matches standard camera outputs.

+
+ """, + unsafe_allow_html=True + ) + + st.download_button( + label="Download DSLR Portrait JPG", + data=jpg_bytes, + file_name=f"{filename_base}_dslr_portrait.jpg", + mime="image/jpeg", + key="dl_dslr_jpg", + use_container_width=True + ) + + with col_dl_png: + png_buf = io.BytesIO() + dl_image.save(png_buf, format="PNG") + png_bytes = png_buf.getvalue() + + st.markdown( + """ +
+ +

Lossless PNG

+

Lossless quality containing perfect sub-pixel detail and transparency.

+
+ """, + unsafe_allow_html=True + ) + + st.download_button( + label="Download DSLR Portrait PNG", + data=png_bytes, + file_name=f"{filename_base}_dslr_portrait.png", + mime="image/png", + key="dl_dslr_png", + use_container_width=True + ) + + st.markdown('
', unsafe_allow_html=True) diff --git a/frontend/home.py b/frontend/home.py new file mode 100644 index 0000000000000000000000000000000000000000..06b691d5391444e8cdc75ee36b7e664354e40fee --- /dev/null +++ b/frontend/home.py @@ -0,0 +1,108 @@ +import streamlit as st + +def render_sidebar_brand(): + """Renders a beautiful brand header in the sidebar.""" + st.sidebar.markdown( + """ +
+

+ ⚡ ANTIGRAVITY STUDIO +

+

+ AI Image & Doc Workspace +

+
+ """, + unsafe_allow_html=True + ) + +def render_home_dashboard(): + """Renders a stunning Glassmorphic landing dashboard listing both AI workspaces.""" + st.markdown( + """ +
+

CREATIVE AI STUDIO

+

+ Powerhouse tools for automated high-fidelity background removal and smart document text replacement. +

+
+ """, + unsafe_allow_html=True + ) + + col1, col2, col3 = st.columns(3) + + with col1: + st.markdown( + """ +
+
+ 🖼️ +

AI Background Remover

+

+ 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. +

+
+
+ """, + unsafe_allow_html=True + ) + if st.button("Launch Background Remover", key="launch_bg", use_container_width=True): + st.session_state.active_workspace = "AI Background Remover" + st.rerun() + + with col2: + st.markdown( + """ +
+
+ 📸 +

AI DSLR Portrait Blur

+

+ 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. +

+
+
+ """, + unsafe_allow_html=True + ) + if st.button("Launch DSLR Portrait Blur", key="launch_blur", use_container_width=True): + st.session_state.active_workspace = "AI DSLR Background Blur" + st.rerun() + + with col3: + st.markdown( + """ +
+
+ 📝 +

AI In-Image Text Editor

+

+ 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. +

+
+
+ """, + unsafe_allow_html=True + ) + if st.button("Launch Document Text Editor", key="launch_te", use_container_width=True): + st.session_state.active_workspace = "AI In-Image Text Editor" + st.rerun() + + # Premium bottom status panel + st.markdown( + """ +
+

+ ⚡ Powered by U²-Net Segmenter, EasyOCR Reader, Guided Filter Matting, OpenCV DSLR Blur & Inpainting. +

+
+ """, + unsafe_allow_html=True + ) diff --git a/frontend/preview_ui.py b/frontend/preview_ui.py new file mode 100644 index 0000000000000000000000000000000000000000..111d78a89d793c156c7a4b54c4088d251e09e3f4 --- /dev/null +++ b/frontend/preview_ui.py @@ -0,0 +1,189 @@ +import streamlit as st +import numpy as np +from PIL import Image, ImageDraw +import cv2 +import io +from backend.utilities import pil_to_cv, cv_to_pil, create_checkerboard +from backend.bg_remover.shadow_generator import blend_rgba + +def generate_gradient_background(w: int, h: int, style: str) -> np.ndarray: + """Generate a custom studio gradient background (RGBA).""" + bg = np.zeros((h, w, 4), dtype=np.uint8) + bg[:, :, 3] = 255 # fully opaque + + if style == "Midnight Glow": + # Radial gradient from dark indigo (#1A1B35) to black (#0B0C10) + cx, cy = w // 2, h // 2 + max_dist = np.sqrt(cx**2 + cy**2) + y, x = np.indices((h, w)) + dist = np.sqrt((x - cx)**2 + (y - cy)**2) + factor = np.clip(dist / max_dist, 0.0, 1.0) + + # Color 1: 26, 27, 53 | Color 2: 11, 12, 16 + for c in range(3): + c1, c2 = [26, 27, 53][c], [11, 12, 16][c] + bg[:, :, c] = (c1 * (1.0 - factor) + c2 * factor).astype(np.uint8) + + elif style == "Sunset Studio": + # Linear vertical gradient from soft orange (#FF7E5F) to deep purple (#FEB47B) + y, _ = np.indices((h, w)) + factor = (y / h).astype(float) + + # Color 1: 255, 126, 95 | Color 2: 254, 180, 123 + for c in range(3): + c1, c2 = [255, 126, 95][c], [254, 180, 123][c] + bg[:, :, c] = (c1 * (1.0 - factor) + c2 * factor).astype(np.uint8) + + elif style == "Clean Studio": + # Vertical gradient from light gray (#F5F7FA) to slate gray (#B3C0CD) + y, _ = np.indices((h, w)) + factor = (y / h).astype(float) + + # Color 1: 245, 247, 250 | Color 2: 179, 192, 205 + for c in range(3): + c1, c2 = [245, 247, 250][c], [179, 192, 205][c] + bg[:, :, c] = (c1 * (1.0 - factor) + c2 * factor).astype(np.uint8) + + elif style == "Neon Cyber": + # Diagonal gradient from hot magenta (#FF007F) to cyber cyan (#00F0FF) + y, x = np.indices((h, w)) + # Normalized coordinates summing for diagonal + factor = np.clip((x / w + y / h) / 2.0, 0.0, 1.0) + + # Color 1: 255, 0, 127 | Color 2: 0, 240, 255 + for c in range(3): + c1, c2 = [255, 0, 127][c], [0, 240, 255][c] + bg[:, :, c] = (c1 * (1.0 - factor) + c2 * factor).astype(np.uint8) + + return bg + +def composite_cutout_on_bg(cutout_pil: Image.Image, bg_type: str, custom_color: str = "#FFFFFF", custom_bg_file = None) -> Image.Image: + """Composite the transparent cutout over a selected background style.""" + w, h = cutout_pil.size + cutout_cv = pil_to_cv(cutout_pil) # RGBA BGR/BGRA + + # Ensure BG is shape (H, W, 4) + bg_rgba = np.zeros((h, w, 4), dtype=np.uint8) + bg_rgba[:, :, 3] = 255 # solid background by default + + if bg_type == "Transparent (Checkerboard)": + bg_rgba = create_checkerboard(w, h, square_size=16) + + elif bg_type == "Solid White": + bg_rgba[:, :, :3] = 255 + + elif bg_type == "Solid Black": + bg_rgba[:, :, :3] = 0 + + elif bg_type == "Custom Solid Color": + # Convert hex custom_color "#RRGGBB" to BGR + hex_color = custom_color.lstrip('#') + r, g, b = tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4)) + bg_rgba[:, :, 0] = b + bg_rgba[:, :, 1] = g + bg_rgba[:, :, 2] = r + + elif bg_type in ["Midnight Glow", "Sunset Studio", "Clean Studio", "Neon Cyber"]: + bg_rgba = generate_gradient_background(w, h, bg_type) + + elif bg_type == "Custom Upload Image" and custom_bg_file is not None: + try: + # Load and resize custom background to match the cutout dimensions + custom_bg_pil = Image.open(custom_bg_file).convert("RGBA") + custom_bg_resized = custom_bg_pil.resize((w, h), Image.Resampling.LANCZOS) + bg_rgba = pil_to_cv(custom_bg_resized) + except Exception as e: + # Fallback to white if error + bg_rgba[:, :, :3] = 255 + st.error(f"Error loading custom background: {str(e)}") + + # Perform alpha blending + blended_cv = blend_rgba(top=cutout_cv, bottom=bg_rgba) + return cv_to_pil(blended_cv) + +def render_preview_ui( + original_pil: Image.Image, + processed_pil: Image.Image, + bounding_box: tuple = None, + show_bounding_box: bool = False +): + """ + Renders the beautiful side-by-side image previews with interactive controls. + """ + st.markdown( + """ +
+

+ 🖼️ VISUAL COMPARISON +

+
+ """, + unsafe_allow_html=True + ) + + # 1. Bounding Box visual overlay helper + display_original = original_pil.copy() + if show_bounding_box and bounding_box is not None: + x, y, w, h = bounding_box + draw = ImageDraw.Draw(display_original) + # Draw elegant bounding box with thick line and glowing color (#00F0FF) + draw.rectangle([x, y, x + w, y + h], outline="#00F0FF", width=3) + # Optional: Add small label + draw.text((x + 5, y + 5), "GrabCut Bounding Box", fill="#00F0FF") + + # 2. Render background selector in preview options + st.markdown('
', unsafe_allow_html=True) + col_bg, col_color, col_custom_upload = st.columns([2, 1, 2]) + + with col_bg: + bg_preview_type = st.selectbox( + "Background Preview Mode", + [ + "Transparent (Checkerboard)", + "Solid White", + "Solid Black", + "Custom Solid Color", + "Clean Studio", + "Midnight Glow", + "Sunset Studio", + "Neon Cyber", + "Custom Upload Image" + ], + index=0, + key="bg_preview_select" + ) + + with col_color: + custom_color = "#FFFFFF" + if bg_preview_type == "Custom Solid Color": + custom_color = st.color_picker("Pick Color", "#6C63FF", key="custom_color_picker") + else: + st.write("") # placeholder + + with col_custom_upload: + custom_bg_file = None + if bg_preview_type == "Custom Upload Image": + custom_bg_file = st.file_uploader("Upload Backdrop Image", type=["jpg", "jpeg", "png", "webp"], key="custom_bg_uploader") + else: + st.write("") # placeholder + + st.markdown('
', unsafe_allow_html=True) + + # 3. Composite output cutout on selected background + composited_processed = composite_cutout_on_bg( + processed_pil, + bg_preview_type, + custom_color=custom_color, + custom_bg_file=custom_bg_file + ) + + # 4. Display the columns + col_left, col_right = st.columns(2) + + with col_left: + st.markdown('

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( + """ +
+

📝 AI Document & PDF Text Editor

+

Correct text directly using visual numbered badges. Search and preview the most matching Google Fonts.

+
+ """, + unsafe_allow_html=True + ) + + # ----------------- SESSION STATE INITIALIZATION ----------------- + if "te_original_file" not in st.session_state: + st.session_state.te_original_file = None + if "te_file_type" not in st.session_state: + st.session_state.te_file_type = None + if "te_pdf_filename" not in st.session_state: + st.session_state.te_pdf_filename = "document" + if "te_original_image" not in st.session_state: + st.session_state.te_original_image = None + if "te_pdf_pages" not in st.session_state: + st.session_state.te_pdf_pages = [] + if "te_active_page" not in st.session_state: + st.session_state.te_active_page = 0 + if "te_ocr_results" not in st.session_state: + st.session_state.te_ocr_results = None + if "te_edited_image" not in st.session_state: + st.session_state.te_edited_image = None + if "te_replacements" not in st.session_state: + st.session_state.te_replacements = [] + + # ----------------- SIDEBAR CONTROLS ----------------- + st.sidebar.markdown("### ⚙️ TEXT EDITOR CONTROLS") + + font_family = st.sidebar.selectbox( + "Default Font Family", + ["Sans-Serif", "Serif", "Monospace"], + index=0, + help="Selects the nearest matching system TTF font style for text rendering." + ) + + custom_font = st.sidebar.text_input( + "⚡ Or Search Google Font", + value="", + placeholder="e.g. Rozha One, Kalam, Hind, Poppins", + help="Type any Google Font family name. The engine will dynamically download, extract, and cache the full font set!" + ) + + if custom_font.strip(): + font_family = custom_font.strip() + + size_multiplier = st.sidebar.slider( + "Font Size Scale Adjust", + min_value=0.5, + max_value=1.5, + value=0.90, + step=0.05, + help="Scale multiplier to perfectly size the drawn characters to the surrounding bounding box." + ) + + overlay_ocr = st.sidebar.checkbox( + "Overlay Numbered Badges", + value=True, + help="Draws glowing pink circular badges and cyan boxes over text blocks to make editing easy." + ) + + # ----------------- MAIN WORKFLOW ----------------- + + # Case 1: No file loaded yet + if st.session_state.te_original_file is None: + col_l, col_c, col_r = st.columns([1, 3, 1]) + with col_c: + st.markdown( + """ +
+

+ ⚡ UPLOAD SOURCE DOCUMENT +

+

+ Upload an Image or multi-page PDF Document. Coordinates and fonts will be parsed instantly. +

+
+ """, + unsafe_allow_html=True + ) + + uploaded_file = st.file_uploader( + "Choose document or image...", + type=["pdf", "jpg", "jpeg", "png", "webp"], + label_visibility="collapsed", + key="doc_uploader" + ) + + if uploaded_file is not None: + try: + file_bytes = uploaded_file.read() + filename = uploaded_file.name + st.session_state.te_pdf_filename = filename + + if filename.lower().endswith(".pdf"): + st.session_state.te_original_file = file_bytes + st.session_state.te_file_type = "pdf" + page_count = get_pdf_page_count(file_bytes) + + with st.spinner(f"Extracting {page_count} PDF pages at high quality (150 DPI)..."): + pdf_pages = [] + for idx in range(page_count): + pdf_pages.append(pdf_page_to_pil(file_bytes, idx, dpi=150)) + st.session_state.te_pdf_pages = pdf_pages + st.session_state.te_active_page = 0 + st.session_state.te_original_image = pdf_pages[0] + else: + from PIL import ImageOps + pil_img = Image.open(io.BytesIO(file_bytes)).convert("RGB") + pil_img = ImageOps.exif_transpose(pil_img) + st.session_state.te_original_file = file_bytes + st.session_state.te_file_type = "image" + st.session_state.te_pdf_pages = [pil_img] + st.session_state.te_active_page = 0 + st.session_state.te_original_image = pil_img + + st.session_state.te_edited_image = None + st.session_state.te_replacements = [] + st.session_state.te_ocr_results = None + st.rerun() + except Exception as e: + st.error(f"Error loading document: {str(e)}") + + st.markdown("

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""" +
+ 📄 File: {st.session_state.te_pdf_filename} | Total Pages: {pages_count} +
+ """, + unsafe_allow_html=True + ) + if pages_count > 1: + active_pg = st.slider("Navigate Pages", 1, pages_count, st.session_state.te_active_page + 1) - 1 + if active_pg != st.session_state.te_active_page: + st.session_state.te_active_page = active_pg + st.session_state.te_original_image = st.session_state.te_pdf_pages[active_pg] + st.session_state.te_ocr_results = None + st.session_state.te_edited_image = None + st.session_state.te_replacements = [] + st.rerun() + else: + st.markdown( + f""" +
+ 🖼️ File: {st.session_state.te_pdf_filename} ({st.session_state.te_original_image.width}x{st.session_state.te_original_image.height}px) +
+ """, + unsafe_allow_html=True + ) + + # ----------------- OCR BACKGROUND TASK ----------------- + if EASYOCR_AVAILABLE and st.session_state.te_ocr_results is None: + with st.spinner("Analyzing image text layout using OCR engine..."): + ocr_res = TextEditorOrchestrator.run_ocr(st.session_state.te_original_image) + st.session_state.te_ocr_results = ocr_res + + # ----------------- DUAL COLUMN LAYOUT ----------------- + col_preview, col_editor_panel = st.columns([1, 1]) + + # Left Column: Image Previews (Before/After) + with col_preview: + st.markdown('

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. +

+
+ """, + unsafe_allow_html=True + ) + + # Right Column: Visual In-Place Editor Panel + with col_editor_panel: + st.markdown('

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('
', unsafe_allow_html=True) + + # Dictionary to store user's changed inputs + user_replacements = {} + + for idx, item in enumerate(st.session_state.te_ocr_results): + # Bounding Box description row + orig_text = item["text"] + bbox = item["bbox"] + + st.markdown( + f""" +
+ + {idx+1} + + Original: + "{orig_text}" +
+ """, + unsafe_allow_html=True + ) + + # Edit input box + rep_input = st.text_input( + "Replacement Value", + value=orig_text, + key=f"rep_val_{idx}", + label_visibility="collapsed" + ) + + # Store if the user actually edited it + if rep_input != orig_text: + user_replacements[idx] = { + "bbox": bbox, + "original_text": orig_text, + "replacement_text": rep_input + } + + st.markdown('
', unsafe_allow_html=True) + + # Apply Corrections Button + col_btn_l, col_btn_c = st.columns([3, 1]) + with col_btn_c: + apply_batch = st.button("Apply Corrections", use_container_width=True) + + # ----------------- DYNAMIC GOOGLE FONT MATCHING GALLERY ----------------- + 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('
', unsafe_allow_html=True) + + hindi_showcase_fonts = { + "Rozha One": "Thick Display Serifs (Perfect for headers)", + "Noto Sans Devanagari": "Sleek Modern Sans-Serif", + "Yatra One": "Classic Indian Calligraphy", + "Kalam": "Handwritten/Artistic Calligraphy", + "Hind": "Clean corporate Sans-Serif", + "Martel": "Formal Document Serif", + "Teko": "Condensed Bold Display" + } + + # Display 3 popular fonts previews side-by-side + col_f1, col_f2 = st.columns(2) + + font_list = list(hindi_showcase_fonts.keys()) + for i, font_name in enumerate(font_list): + col_target = col_f1 if i % 2 == 0 else col_f2 + with col_target: + st.markdown(f"**{font_name}** (*{hindi_showcase_fonts[font_name]}*)") + preview_pil = generate_font_preview_pil(selected_preview_word, font_name) + st.image(preview_pil, use_container_width=True) + st.markdown("
", unsafe_allow_html=True) + + st.markdown('
', unsafe_allow_html=True) + + # ----------------- RUN CORRECTIONS ----------------- + if apply_batch: + if not user_replacements: + st.warning("⚠️ No changes were made in any text field inputs. Modify a text box first!") + else: + with st.spinner("Applying all visual text corrections..."): + canvas = st.session_state.te_original_image.copy() + + # Apply each replacement in order + for idx in sorted(user_replacements.keys()): + item = user_replacements[idx] + bbox = item["bbox"] + new_text = item["replacement_text"] + + rep_instruction = { + "bbox": bbox, + "replacement_text": new_text + } + + canvas = TextEditorOrchestrator.apply_replacements( + pil_image=canvas, + replacements=[rep_instruction], + font_family=font_family, + size_multiplier=size_multiplier + ) + + st.session_state.te_replacements.append({ + "original": item["original_text"], + "replacement": new_text, + "bbox": bbox + }) + + st.session_state.te_edited_image = canvas + st.session_state.te_pdf_pages[st.session_state.te_active_page] = canvas + st.toast("Success! All selected text blocks updated.", icon="✨") + st.rerun() + + # ----------------- DOWNLOAD COMPLETED DOCUMENT ----------------- + if st.session_state.te_edited_image is not None or len(st.session_state.te_replacements) > 0: + st.markdown( + """ +
+

+ 💾 EXPORT DOCUMENT +

+
+ """, + unsafe_allow_html=True + ) + + st.markdown('
', unsafe_allow_html=True) + + filename_base = st.session_state.te_pdf_filename.rsplit('.', 1)[0] + + if st.session_state.te_file_type == "pdf": + col_dl_pdf, col_dl_cur = st.columns(2) + + with col_dl_pdf: + with st.spinner("Compiling all multi-page PDF pages..."): + compiled_pdf_bytes = compile_images_to_pdf(st.session_state.te_pdf_pages) + + st.markdown( + """ +
+ 📄 +

Complete Edited PDF

+

Re-compiles all pages into a single PDF document.

+
+ """, + unsafe_allow_html=True + ) + + st.download_button( + label="Download Full Document PDF", + data=compiled_pdf_bytes, + file_name=f"{filename_base}_edited.pdf", + mime="application/pdf", + key="dl_edited_pdf", + use_container_width=True + ) + + with col_dl_cur: + pg_buf = io.BytesIO() + st.session_state.te_edited_image.save(pg_buf, format="PNG") + pg_bytes = pg_buf.getvalue() + + st.markdown( + """ +
+ 🖼️ +

Active Page Image (PNG)

+

Downloads the current page as a single high-DPI image.

+
+ """, + unsafe_allow_html=True + ) + + st.download_button( + label=f"Download Page {st.session_state.te_active_page+1} PNG", + data=pg_bytes, + file_name=f"{filename_base}_page_{st.session_state.te_active_page+1}_edited.png", + mime="image/png", + key="dl_edited_page_png", + use_container_width=True + ) + else: + img_buf = io.BytesIO() + st.session_state.te_edited_image.save(img_buf, format="PNG") + img_bytes = img_buf.getvalue() + + col_dl_img_l, col_dl_img_c, col_dl_img_r = st.columns([1, 2, 1]) + with col_dl_img_c: + st.markdown( + """ +
+ +

Edited Image Output

+

Downloads the text-corrected image as high-fidelity PNG.

+
+ """, + unsafe_allow_html=True + ) + + st.download_button( + label="Download Edited Image PNG", + data=img_bytes, + file_name=f"{filename_base}_edited.png", + mime="image/png", + key="dl_edited_img_png", + use_container_width=True + ) + + st.markdown('
', unsafe_allow_html=True) + + st.markdown("#### 📜 REPLACEMENT LOGS") + log_content = "" + for i, rep in enumerate(st.session_state.te_replacements): + log_content += f"{i+1}. Erased **'{rep['original']}'** and wrote **'{rep['replacement']}'** at bounding box `{rep['bbox']}`.\n" + st.markdown(log_content) diff --git a/frontend/upload_ui.py b/frontend/upload_ui.py new file mode 100644 index 0000000000000000000000000000000000000000..8c4350e519661f276afd9876c784271eaa3087d9 --- /dev/null +++ b/frontend/upload_ui.py @@ -0,0 +1,58 @@ +import streamlit as st +from PIL import Image +import io + +def render_upload_ui() -> Image.Image: + """ + Renders a premium, glassmorphism-styled drag-and-drop file uploader. + Validates files and returns a PIL Image if successful. + """ + # Custom styled card for the upload area + st.markdown( + """ +
+

+ ⚡ UPLOAD SOURCE IMAGE +

+

+ Drag and drop your image or browse files. Supports high-resolution JPG, PNG, and WEBP. +

+
+ """, + unsafe_allow_html=True + ) + + # Upload widget + uploaded_file = st.file_uploader( + "Choose an image file...", + type=["jpg", "jpeg", "png", "webp"], + label_visibility="collapsed", + key="image_uploader" + ) + + if uploaded_file is not None: + try: + # Read file bytes + file_bytes = uploaded_file.read() + # Convert to PIL Image + pil_img = Image.open(io.BytesIO(file_bytes)) + + # Automatically normalize EXIF camera orientation tags immediately on upload + from PIL import ImageOps + pil_img = ImageOps.exif_transpose(pil_img) + + # Display success message and metadata in an elegant micro-card + st.markdown( + f""" +
+ Image loaded successfully: {uploaded_file.name} ({pil_img.width}x{pil_img.height}px | {pil_img.mode}) +
+ """, + unsafe_allow_html=True + ) + return pil_img + except Exception as e: + st.error(f"Error loading image: {str(e)}. Please try another file.") + return None + + return None diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..40242175753a5661baed14727ac85da265dfbfc6 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +streamlit>=1.30.0 +opencv-python-headless>=4.8.0 +numpy>=1.24.0 +Pillow>=10.0.0 +rembg>=2.0.50 +onnxruntime>=1.16.0 +easyocr>=1.7.0 +pymupdf>=1.23.0