Goutam112 commited on
Commit
f99e19b
·
1 Parent(s): 8ec3ae7

imporvemnt paths

Browse files
README.md CHANGED
@@ -1,112 +1,117 @@
1
- # Text Extracted - OCR Web Application
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
- ## Overview
4
-
5
- Text Extracted is a web-based application designed to extract text from images using Optical Character Recognition (OCR). The application utilizes `pytesseract` for OCR processing and provides an intuitive interface using `Streamlit`. Additionally, the app features keyword extraction from the OCR-processed text, making it useful for data extraction, analysis, or research purposes.
6
-
7
- ## Features
8
-
9
- - **Image Upload**: Users can upload images containing text.
10
- - **Text Extraction**: The app extracts text from uploaded images using `pytesseract`.
11
- - **Keyword Extraction**: Allows users to extract important keywords from the extracted text.
12
- - **User-Friendly Interface**: Simple web interface for easy interaction.
13
-
14
- ## Tech Stack
15
-
16
- - **Frontend**: Streamlit
17
- - **Backend**: Python
18
- - **OCR Engine**: pytesseract (Tesseract-OCR)
19
- - **Libraries**:
20
- - `streamlit`: For creating the web interface.
21
- - `pytesseract`: For performing OCR on images.
22
- - `Pillow`: For handling image files.
23
- - `regex`: For text manipulation and keyword extraction.
24
-
25
- ## Prerequisites
26
-
27
- Before running the application, ensure that you have the following installed:
28
-
29
- 1. **Python 3.7 or higher**: You can download Python [here](https://www.python.org/downloads/).
30
- 2. **Tesseract-OCR**: The OCR engine required by `pytesseract`. You can install it as follows:
31
- - **macOS**:
32
- ```bash
33
- brew install tesseract
34
- ```
35
- - **Ubuntu**:
36
- ```bash
37
- sudo apt-get install tesseract-ocr
38
- ```
39
- - **Windows**: Download the installer from [here](https://github.com/tesseract-ocr/tesseract/wiki) and follow the instructions.
40
-
41
- ## Installation
42
 
43
- ### Follow the steps below to set up the project locally:
44
 
45
- 1. **Clone the Repository**:
46
- Open your terminal or command prompt and run:
47
 
48
- ```bash
49
- git clone https://github.com/Goutam16-Withcode/Text_Extracted.git
50
- cd Text_Extracted-main
51
- ```
52
 
53
- 2. **Set Up a Virtual Environment (Optional but recommended)**: Create and activate a virtual environment to manage project dependencies.
54
-
55
  ```bash
56
- python -m venv venv
57
- # Activate the virtual environment
58
- source venv/bin/activate # On Windows: venv\\Scripts\\activate
59
  ```
60
 
61
- 3. **Install Dependencies:** Use the `requirements.txt` file to install the necessary Python packages:
62
-
63
  ```bash
64
- pip install -r requirements.txt
65
  ```
66
 
67
- 4. **Run the Application:** Start the Streamlit application by running the command below:
68
-
69
  ```bash
70
  streamlit run app.py
71
  ```
 
72
 
73
- 5. **Open the Application:** Once the application starts, Streamlit will automatically open your default web browser. If not, you can manually visit http://localhost:8501.
74
-
75
- ## File Structure
76
-
77
- `**app.py:**` The main application file that handles the Streamlit interface and coordinates image uploads, OCR processing, and keyword extraction.
78
-
79
- `**ocr.py:**` Contains the logic for processing images using `pytesseract` and extracting keywords from the extracted text.
80
- **requirements.txt:** Lists all the Python dependencies needed to run the application.
81
-
82
- ## Upload an Image: On the application page, you will see an option to upload an image file. Click the "Browse" button and select an image that contains text.
83
- **Extract Text:** Once the image is uploaded, the application will process the image and display the extracted text.
84
- Extract Keywords (Optional): If required, you can click the button to extract keywords from the extracted text.
85
-
86
- ## Example
87
- **Input**
88
- An image containing text such as a scanned document, a screenshot, or any image with visible words.
89
-
90
- **Output**
91
- The raw text extracted from the image.
92
- A list of keywords derived from the extracted text.
93
- **Dependencies**
94
- The project uses the following Python libraries:
95
-
96
- 1. `streamlit`
97
- 2. `pytesseract`
98
- 3. `Pillow`
99
- 4. `regex`
100
- These dependencies are listed in the `requirements.txt` file and can be installed using `pip install -r requirements.txt`.
101
-
102
-
103
- ## Screen Shots:
104
- ![1](https://github.com/user-attachments/assets/9c3a5779-769f-4367-9ea9-e501a25b89b8)![2](https://github.com/user-attachments/assets/e83695e4-1ba8-48e9-b820-add607abdbf3)
105
-
106
- ![3](https://github.com/user-attachments/assets/b0116d65-6193-44dd-a3ba-c639638d80d2)![4](https://github.com/user-attachments/assets/12b6a0bd-8f8b-435a-9e79-c2f7272eb0ef)
107
-
108
- ## Image Uploaded:
109
- ![6](https://github.com/user-attachments/assets/91204189-0396-4bb5-a05c-2876b4c0425f)
110
 
 
 
 
111
 
 
112
 
 
 
 
1
+ # 🧠 DocIQ — Advanced Intelligent Document Processing (IDP) Platform
2
+
3
+ [![Python](https://img.shields.io/badge/Python-3.10%2B-blue.svg)](https://www.python.org/)
4
+ [![Streamlit](https://img.shields.io/badge/Streamlit-1.40%2B-FF4B4B.svg)](https://streamlit.io/)
5
+ [![EasyOCR](https://img.shields.io/badge/EasyOCR-PyTorch%20GPU-green.svg)](https://github.com/JaidedAI/EasyOCR)
6
+ [![License](https://img.shields.io/badge/License-MIT-purple.svg)](LICENSE)
7
+
8
+ **DocIQ** is an Intelligent Document Processing (IDP) platform built with Streamlit, EasyOCR, OpenCV, Sentence-Transformers, and PyMuPDF. It transforms raw document images, receipts, invoices, and scans into clean, structured, searchable, and semantically indexed data.
9
+
10
+ ---
11
+
12
+ ## 🚀 Key Features
13
+
14
+ ### 1. 🖼️ Computer Vision Preprocessing Pipeline (`src/preprocessor.py`)
15
+ - **Auto-Deskew**: Automatically detects document tilt using Hough line transform and corrects orientation.
16
+ - **Adaptive Contrast (CLAHE)**: Enhances faded or low-contrast document scans.
17
+ - **Bilateral Denoising**: Smooths scanner grain while preserving crisp character edges.
18
+ - **Multiple Modes**:
19
+ - `auto`: Deskew + CLAHE + Denoise
20
+ - `binarize`: High-contrast black & white document thresholding (Otsu adaptive)
21
+ - `warp`: Perspective transformation for flattened documents
22
+ - `raw`: Process image as uploaded
23
+
24
+ ### 2. ⚡ Accelerated OCR Inference (`src/ocr_engine.py`)
25
+ - Powered by **EasyOCR** with PyTorch CUDA GPU acceleration.
26
+ - Built-in multi-lingual support: **English (`en`)**, **Hindi (`hi`)**, French, German, Spanish, Chinese, and Arabic.
27
+ - Caching with `@st.cache_resource` for zero-redundancy model loading across sessions.
28
+ - Detailed bounding box coordinates `[x_min, y_min, x_max, y_max]`, confidence scores, and reading order.
29
+
30
+ ### 3. 🎨 Visual Canvas & Spatial Inspection (`src/visualizer.py`)
31
+ - **Confidence Bounding Boxes**: Visual color cues (Green ≥80%, Yellow 50–80%, Red <50%).
32
+ - **Confidence Heatmap**: Continuous thermal density overlay showing spatial OCR certainty.
33
+ - **Reading Order Vector Flow**: Numbered direction arrows indicating extracted line-by-line reading flow.
34
+ - **Interactive Visual Keyword Search**: Search any keyword to immediately highlight its spatial bounding box in gold.
35
+
36
+ ### 4. 🗂️ Structured Entity & Invoice Extraction (`src/extractor.py`)
37
+ - **Automated Document Classification**: Categorizes document as *Invoice / Receipt*, *Identity Document*, *Medical / Prescription*, *Legal / Contract*, or *Academic*.
38
+ - **Regex & Context Parsing**:
39
+ - Dates (multiple international formats)
40
+ - Monetary amounts & currencies (`$`, `₹`, `€`, `£`)
41
+ - Contact information (emails, phones, URLs)
42
+ - Invoice & purchase order numbers, subtotals, tax, discounts, and due dates
43
+ - **PII Detection & Redaction**: Detects sensitive credentials (Aadhaar, PAN, Credit Cards, SSN) with optional 1-click `████` redaction.
44
+
45
+ ### 5. 🧬 Semantic Embedding Vector Space (`src/embedder.py`)
46
+ - Embeds extracted words or sentences using `sentence-transformers/all-MiniLM-L6-v2`.
47
+ - Projects 384-dimensional dense vectors down to 2D via **PCA**, **t-SNE**, or **UMAP**.
48
+ - Clusters related concepts using **K-Means** with interactive Plotly scatter plots.
49
+ - Computes pairwise **Cosine Similarity Heatmaps** to discover semantic connections in document content.
50
+
51
+ ### 6. 📤 Multi-Format Export Engine (`src/exporter.py`)
52
+ - **Searchable PDF**: Embedded invisible text layer enabling native `Ctrl+F` search across standard PDF viewers.
53
+ - **Annotated Audit PDF**: Complete executive report with bounding box visual overview, confidence metrics, and entity tables.
54
+ - **Structured JSON**: Full word coordinates, confidence scores, and detected metadata for downstream NLP pipelines.
55
+ - **CSV / Excel**: Tabular word list with positions and confidence.
56
+ - **Plain Text**: Formatted clean text with optional PII redaction.
57
+ - **All-In-One ZIP Archive**: 1-click download of all formats bundled together.
58
+
59
+ ### 7. ✨ Preset Samples & Camera Scanner
60
+ - **Preset Test Documents**: Built-in Commercial Invoice and Retail Receipt generators (`src/sample_docs.py`) for instant testing without needing local image files.
61
+ - **Live Camera Scanner**: Snap physical documents in real-time via camera/webcam.
62
+
63
+ ---
64
+
65
+ ## 📁 Project Architecture
66
 
67
+ ```
68
+ Text_Extracted/
69
+ ├── app.py # Streamlit UI, visual canvas, search, embeddings, and tabs
70
+ ├── requirements.txt # Project dependencies
71
+ ├── README.md # Project documentation
72
+ └── src/
73
+ ├── __init__.py # Package initialization
74
+ ├── preprocessor.py # OpenCV deskew, contrast CLAHE, binarization, perspective warp
75
+ ├── ocr_engine.py # EasyOCR GPU inference engine and result normalizer
76
+ ├── visualizer.py # Bounding box overlays, heatmaps, search highlighter
77
+ ├── extractor.py # Entity extraction (dates, amounts, contacts, invoice fields, PII)
78
+ ├── embedder.py # Sentence-transformers embedding & 2D dimensionality reduction
79
+ ├── exporter.py # Searchable PDF, Annotated PDF, JSON, CSV, TXT, and ZIP bundle
80
+ └── sample_docs.py # Synthetic document generator for 1-click demo testing
81
+ ```
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
 
83
+ ---
84
 
85
+ ## ⚙️ Installation & Setup
 
86
 
87
+ ### Prerequisites
88
+ - Python 3.10+
89
+ - (Optional) NVIDIA GPU with CUDA for accelerated neural OCR inference
 
90
 
91
+ ### 1. Clone the Repository
 
92
  ```bash
93
+ git clone https://github.com/Goutam16-Withcode/Text_Extracted.git
94
+ cd Text_Extracted
 
95
  ```
96
 
97
+ ### 2. Install Dependencies
 
98
  ```bash
99
+ pip install -r requirements.txt
100
  ```
101
 
102
+ ### 3. Launch the Application
 
103
  ```bash
104
  streamlit run app.py
105
  ```
106
+ Open **http://localhost:8501** in your browser to begin.
107
 
108
+ ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
 
110
+ ## 🛡️ Privacy & Compliance
111
+ - **Local Execution**: All OCR inference, image preprocessing, and embedding calculations happen locally on your machine.
112
+ - **PII Redaction**: Sensitive IDs and financial numbers can be stripped or masked before exporting.
113
 
114
+ ---
115
 
116
+ ## 📄 License
117
+ This project is open-source and available under the [MIT License](LICENSE).
app.py CHANGED
@@ -552,16 +552,45 @@ with tab_entities:
552
  with col_b:
553
  # Invoice fields
554
  inv = entities.get("invoice_fields", {})
 
 
 
 
 
 
 
 
 
 
 
555
  if inv:
556
- inv_html = "".join(f"""
557
- <div style="display:flex;justify-content:space-between;padding:6px 0;border-bottom:1px solid rgba(99,102,241,0.1);">
558
- <span style="color:#64748b;font-size:0.82rem;">{k.replace('_',' ').title()}</span>
559
- <span style="color:#e2e8f0;font-size:0.82rem;font-weight:500;">{v}</span>
560
- </div>""" for k, v in inv.items())
 
 
 
 
 
 
 
 
 
 
561
  st.markdown(f"""<div class="doc-card">
562
- <div style="font-weight:600;color:#e2e8f0;margin-bottom:8px;">🧾 Invoice / Receipt Fields</div>
 
 
 
563
  {inv_html}
564
  </div>""", unsafe_allow_html=True)
 
 
 
 
 
565
 
566
  # PII
567
  pii = entities.get("pii", {})
 
552
  with col_b:
553
  # Invoice fields
554
  inv = entities.get("invoice_fields", {})
555
+ FIELD_ICONS = {
556
+ "invoice_number": "🧾",
557
+ "po_number": "📋",
558
+ "total": "💰",
559
+ "subtotal": "💵",
560
+ "tax": "🏛️",
561
+ "discount": "🏷️",
562
+ "gstin": "🏢",
563
+ "due_date": "⏳",
564
+ "vendor": "🏬",
565
+ }
566
  if inv:
567
+ inv_rows = []
568
+ for k, v in inv.items():
569
+ icon = FIELD_ICONS.get(k, "📌")
570
+ title = k.replace('_', ' ').title()
571
+ inv_rows.append(f"""
572
+ <div style="display:flex; justify-content:space-between; align-items:center; padding:9px 12px; margin-bottom:6px; background:rgba(15,23,42,0.6); border:1px solid rgba(99,102,241,0.18); border-radius:10px;">
573
+ <span style="color:#94a3b8; font-size:0.84rem; display:flex; align-items:center; gap:6px;">
574
+ <span>{icon}</span>
575
+ <strong style="color:#cbd5e1; font-weight:500;">{title}</strong>
576
+ </span>
577
+ <span style="color:#38bdf8; font-size:0.86rem; font-weight:600; font-family:monospace; background:rgba(56,189,248,0.1); padding:2px 8px; border-radius:6px; border:1px solid rgba(56,189,248,0.25);">
578
+ {v}
579
+ </span>
580
+ </div>""")
581
+ inv_html = "".join(inv_rows)
582
  st.markdown(f"""<div class="doc-card">
583
+ <div style="font-weight:600; color:#e2e8f0; margin-bottom:12px; display:flex; align-items:center; justify-content:space-between;">
584
+ <span>🧾 Key Document Fields</span>
585
+ <span class="badge badge-cyan">{len(inv)} detected</span>
586
+ </div>
587
  {inv_html}
588
  </div>""", unsafe_allow_html=True)
589
+ else:
590
+ st.markdown("""<div class="doc-card">
591
+ <div style="font-weight:600; color:#e2e8f0; margin-bottom:6px;">🧾 Key Document Fields</div>
592
+ <div style="color:#64748b; font-size:0.82rem;">No structured invoice or PO fields detected in this document.</div>
593
+ </div>""", unsafe_allow_html=True)
594
 
595
  # PII
596
  pii = entities.get("pii", {})
src/__pycache__/embedder.cpython-310.pyc ADDED
Binary file (6.06 kB). View file
 
src/__pycache__/extractor.cpython-310.pyc ADDED
Binary file (6.17 kB). View file
 
src/__pycache__/ocr_engine.cpython-310.pyc ADDED
Binary file (4.75 kB). View file
 
src/__pycache__/preprocessor.cpython-310.pyc ADDED
Binary file (5.44 kB). View file
 
src/__pycache__/visualizer.cpython-310.pyc ADDED
Binary file (6.42 kB). View file
 
src/extractor.py CHANGED
@@ -42,15 +42,14 @@ PASSPORT_PATTERN = r"\b[A-Z][1-9][0-9]{7}\b"
42
 
43
  # Invoice / Receipt fields
44
  INVOICE_FIELDS = {
45
- "invoice_number": r"(?i)(?:invoice|inv|bill|order)[\s#:\.]*([A-Z0-9\-/]+)",
46
- "po_number": r"(?i)(?:p\.?o\.?|purchase order)[\s#:\.]*([A-Z0-9\-/]+)",
47
- "gstin": r"(?i)(?:GSTIN|GST No\.?)[\s:\.]*([0-9]{2}[A-Z]{5}[0-9]{4}[A-Z][0-9A-Z]Z[0-9A-Z])",
48
- "due_date": r"(?i)(?:due|payment due|pay by)[\s:]*(\d{1,2}[/-]\d{1,2}[/-]\d{2,4})",
49
- "total": r"(?i)(?:total|grand total|amount due|net payable)[\s:₹$]*([0-9,]+(?:\.[0-9]{2})?)",
50
- "subtotal": r"(?i)(?:subtotal|sub-total|taxable amount)[\s:₹$]*([0-9,]+(?:\.[0-9]{2})?)",
51
- "tax": r"(?i)(?:tax|gst|vat|igst|cgst|sgst)[\s:₹$%]*([0-9,]+(?:\.[0-9]{2})?)",
52
- "discount": r"(?i)(?:discount|offer)[\s:₹$%-]*([0-9,]+(?:\.[0-9]{2})?)",
53
- "vendor": r"(?i)(?:from|vendor|billed by|company)[\s:]*([A-Za-z][A-Za-z\s&.,]{2,40})",
54
  }
55
 
56
  DOCUMENT_TYPE_KEYWORDS = {
@@ -130,10 +129,15 @@ def redact_pii(text: str, redact_char: str = "█") -> tuple[str, int]:
130
  def extract_invoice_fields(text: str) -> Dict[str, str]:
131
  """Extract structured key-value invoice fields."""
132
  fields = {}
 
133
  for field_name, pattern in INVOICE_FIELDS.items():
134
  match = re.search(pattern, text)
135
  if match:
136
- fields[field_name] = match.group(1).strip() if match.lastindex else match.group(0).strip()
 
 
 
 
137
  return fields
138
 
139
 
 
42
 
43
  # Invoice / Receipt fields
44
  INVOICE_FIELDS = {
45
+ "invoice_number": r"(?i)\b(?:invoice|inv|bill|order)[\s#:\.-]*([A-Z0-9\-_/]{3,30})",
46
+ "po_number": r"(?i)\b(?:p\.?o\.?\s*(?:#|num(?:ber)?)?|purchase\s+order(?:\s*#)?)[\s#:\.-]*([A-Z0-9\-_/]{3,30})",
47
+ "gstin": r"(?i)\b(?:GSTIN|GST\s*No\.?)[\s:\.]*([0-9]{2}[A-Z]{5}[0-9]{4}[A-Z][0-9A-Z]Z[0-9A-Z])",
48
+ "due_date": r"(?i)\b(?:due\s+date|payment\s+due|pay\s+by)[\s:]*(\d{1,2}[/-]\d{1,2}[/-]\d{2,4})",
49
+ "total": r"(?i)\b(?:grand\s+total|total\s+due|total\s+amount|amount\s+due|net\s+payable|total)[\s:₹$]*([0-9,]+(?:\.[0-9]{2})?)",
50
+ "subtotal": r"(?i)\b(?:subtotal|sub-total|taxable\s+amount)[\s:₹$]*([0-9,]+(?:\.[0-9]{2})?)",
51
+ "tax": r"(?i)\b(?:tax(?:\s*\(\d+%\))?|gst|vat|igst|cgst|sgst)[\s:₹$%]*([0-9,]+(?:\.[0-9]{2})?)",
52
+ "discount": r"(?i)\b(?:discount|special\s+offer)[\s:₹$%-]*([0-9,]+(?:\.[0-9]{2})?)",
 
53
  }
54
 
55
  DOCUMENT_TYPE_KEYWORDS = {
 
129
  def extract_invoice_fields(text: str) -> Dict[str, str]:
130
  """Extract structured key-value invoice fields."""
131
  fields = {}
132
+ stopwords = {"se", "no", "is", "of", "to", "in", "on", "at", "by", "or", "and", "the", "for"}
133
  for field_name, pattern in INVOICE_FIELDS.items():
134
  match = re.search(pattern, text)
135
  if match:
136
+ val = match.group(1).strip() if match.lastindex else match.group(0).strip()
137
+ # Eliminate trivial noise and punctuation-only values
138
+ clean_val = val.strip(" :#-.")
139
+ if len(clean_val) >= 2 and clean_val.lower() not in stopwords:
140
+ fields[field_name] = clean_val
141
  return fields
142
 
143
 
src/ocr_engine.py CHANGED
@@ -1,52 +1,44 @@
1
  """
2
- ocr_engine.py — Cached EasyOCR inference engine.
3
  Features:
4
  - GPU-accelerated inference (CUDA auto-detect)
5
- - @st.cache_resource singleton (loads weights once)
6
- - Returns rich structured results: bbox, text, confidence
7
- - Supports multi-language (English + Hindi)
 
8
  """
9
 
10
  import streamlit as st
11
  import easyocr
12
  import numpy as np
 
13
  from PIL import Image
14
- from typing import List, Dict, Any
15
 
16
 
17
- @st.cache_resource(show_spinner="🧠 Loading OCR engine (one-time)...")
18
- def get_reader(languages: list = None) -> easyocr.Reader:
19
  """
20
  Cached singleton OCR reader. Loaded once per session.
21
  Auto-detects CUDA GPU for acceleration.
22
  """
23
- if languages is None:
24
- languages = ["en", "hi"]
25
  try:
26
  import torch
27
  gpu = torch.cuda.is_available()
28
- except ImportError:
29
  gpu = False
30
- return easyocr.Reader(languages, gpu=gpu)
31
 
32
 
33
  def run_ocr(
34
  pil_img: Image.Image,
35
  languages: list = None,
36
- confidence_threshold: float = 0.3
 
37
  ) -> List[Dict[str, Any]]:
38
  """
39
- Run OCR on a PIL image.
40
-
41
- Returns list of dicts:
42
- {
43
- "bbox": [[x1,y1],[x2,y1],[x2,y2],[x1,y2]],
44
- "text": "...",
45
- "confidence": 0.97,
46
- "center": (cx, cy),
47
- "width": w,
48
- "height": h
49
- }
50
  """
51
  if languages is None:
52
  languages = ["en", "hi"]
@@ -54,16 +46,42 @@ def run_ocr(
54
  reader = get_reader(tuple(languages))
55
  img_array = np.array(pil_img.convert("RGB"))
56
 
57
- raw_results = reader.readtext(img_array)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
 
59
  structured = []
60
  for (bbox, text, prob) in raw_results:
 
61
  if prob < confidence_threshold:
62
  continue
63
- if not text.strip():
64
  continue
65
 
66
- # bbox is [[x1,y1],[x2,y1],[x2,y2],[x1,y2]]
67
  xs = [p[0] for p in bbox]
68
  ys = [p[1] for p in bbox]
69
  cx = int(np.mean(xs))
@@ -73,7 +91,7 @@ def run_ocr(
73
 
74
  structured.append({
75
  "bbox": bbox,
76
- "text": text.strip(),
77
  "confidence": float(prob),
78
  "center": (cx, cy),
79
  "width": w,
@@ -88,48 +106,105 @@ def run_ocr(
88
 
89
 
90
  def get_full_text(results: List[Dict]) -> str:
91
- """Reconstruct full plain text from OCR results, preserving reading order."""
92
- # Sort by y then x (reading order)
93
- sorted_results = sorted(results, key=lambda r: (r["y_min"], r["x_min"]))
94
  return " ".join(r["text"] for r in sorted_results)
95
 
96
 
97
- def get_lines(results: List[Dict], line_gap_threshold: int = 15) -> List[List[Dict]]:
98
- """Group OCR results into lines based on vertical proximity."""
 
 
 
 
 
 
 
 
 
 
 
99
  if not results:
100
- return []
101
-
102
- sorted_results = sorted(results, key=lambda r: r["y_min"])
103
- lines = []
104
- current_line = [sorted_results[0]]
 
 
 
 
 
 
 
 
 
 
 
105
 
106
- for result in sorted_results[1:]:
107
- prev_y = current_line[-1]["center"][1]
108
- curr_y = result["center"][1]
109
- if abs(curr_y - prev_y) <= line_gap_threshold:
110
- current_line.append(result)
111
- else:
112
- lines.append(sorted(current_line, key=lambda r: r["x_min"]))
113
- current_line = [result]
114
-
115
- if current_line:
116
- lines.append(sorted(current_line, key=lambda r: r["x_min"]))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
 
118
- return lines
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
119
 
120
 
121
  def get_statistics(results: List[Dict]) -> Dict[str, Any]:
122
- """Compute statistics about the OCR results."""
123
- if not results:
124
- return {}
125
-
126
- confidences = [r["confidence"] for r in results]
127
- return {
128
- "total_words": len(results),
129
- "avg_confidence": float(np.mean(confidences)),
130
- "min_confidence": float(np.min(confidences)),
131
- "max_confidence": float(np.max(confidences)),
132
- "high_conf_count": sum(1 for c in confidences if c >= 0.8),
133
- "medium_conf_count": sum(1 for c in confidences if 0.5 <= c < 0.8),
134
- "low_conf_count": sum(1 for c in confidences if c < 0.5),
135
- }
 
1
  """
2
+ ocr_engine.py — Advanced EasyOCR inference engine with accuracy measurement.
3
  Features:
4
  - GPU-accelerated inference (CUDA auto-detect)
5
+ - @st.cache_resource singleton reader
6
+ - High-sensitivity parameters tuned for live camera & scanned photos
7
+ - Fallback multi-pass OCR for difficult lighting / low-contrast text
8
+ - Comprehensive OCR accuracy and quality metrics calculation
9
  """
10
 
11
  import streamlit as st
12
  import easyocr
13
  import numpy as np
14
+ import re
15
  from PIL import Image
16
+ from typing import List, Dict, Any, Tuple
17
 
18
 
19
+ @st.cache_resource(show_spinner="🧠 Loading EasyOCR engine (one-time initialization)...")
20
+ def get_reader(languages: Tuple[str, ...]) -> easyocr.Reader:
21
  """
22
  Cached singleton OCR reader. Loaded once per session.
23
  Auto-detects CUDA GPU for acceleration.
24
  """
 
 
25
  try:
26
  import torch
27
  gpu = torch.cuda.is_available()
28
+ except Exception:
29
  gpu = False
30
+ return easyocr.Reader(list(languages), gpu=gpu)
31
 
32
 
33
  def run_ocr(
34
  pil_img: Image.Image,
35
  languages: list = None,
36
+ confidence_threshold: float = 0.25,
37
+ camera_mode: bool = False,
38
  ) -> List[Dict[str, Any]]:
39
  """
40
+ Execute neural OCR with parameters tuned for maximum detection recall and precision.
41
+ Supports camera mode with multi-pass recognition for challenging live captures.
 
 
 
 
 
 
 
 
 
42
  """
43
  if languages is None:
44
  languages = ["en", "hi"]
 
46
  reader = get_reader(tuple(languages))
47
  img_array = np.array(pil_img.convert("RGB"))
48
 
49
+ # Primary pass with optimized thresholds
50
+ text_th = 0.35 if camera_mode else 0.40
51
+ low_th = 0.20 if camera_mode else 0.25
52
+ contrast_th = 0.05 if camera_mode else 0.10
53
+
54
+ raw_results = reader.readtext(
55
+ img_array,
56
+ text_threshold=text_th,
57
+ low_text=low_th,
58
+ contrast_ths=contrast_th,
59
+ adjust_contrast=0.7 if camera_mode else 0.5,
60
+ link_threshold=0.35,
61
+ decoder="greedy",
62
+ )
63
+
64
+ # If very few words detected on a live capture, attempt secondary high-sensitivity pass
65
+ if len(raw_results) < 4:
66
+ raw_results_fallback = reader.readtext(
67
+ img_array,
68
+ text_threshold=0.25,
69
+ low_text=0.15,
70
+ contrast_ths=0.03,
71
+ adjust_contrast=0.9,
72
+ link_threshold=0.25,
73
+ )
74
+ if len(raw_results_fallback) > len(raw_results):
75
+ raw_results = raw_results_fallback
76
 
77
  structured = []
78
  for (bbox, text, prob) in raw_results:
79
+ clean_text = text.strip()
80
  if prob < confidence_threshold:
81
  continue
82
+ if not clean_text:
83
  continue
84
 
 
85
  xs = [p[0] for p in bbox]
86
  ys = [p[1] for p in bbox]
87
  cx = int(np.mean(xs))
 
91
 
92
  structured.append({
93
  "bbox": bbox,
94
+ "text": clean_text,
95
  "confidence": float(prob),
96
  "center": (cx, cy),
97
  "width": w,
 
106
 
107
 
108
  def get_full_text(results: List[Dict]) -> str:
109
+ """Reconstruct full plain text from OCR results preserving natural reading order."""
110
+ sorted_results = sorted(results, key=lambda r: (r["y_min"] // 20, r["x_min"]))
 
111
  return " ".join(r["text"] for r in sorted_results)
112
 
113
 
114
+ def compute_accuracy_metrics(results: List[Dict], full_text: str = "") -> Dict[str, Any]:
115
+ """
116
+ Measure comprehensive OCR accuracy, character recognition quality, and text coherence.
117
+ Returns:
118
+ - overall_accuracy: 0-100%
119
+ - quality_grade: 'A+' | 'A' | 'B' | 'C' | 'D'
120
+ - avg_confidence: float (0-1)
121
+ - high_conf_pct: % words with conf >= 80%
122
+ - medium_conf_pct: % words with conf 50-79%
123
+ - low_conf_pct: % words with conf < 50%
124
+ - lexical_validity: % of recognized tokens with standard alphanumeric structure
125
+ - readability_status: Description of reliability
126
+ """
127
  if not results:
128
+ return {
129
+ "overall_accuracy": 0.0,
130
+ "quality_grade": "N/A",
131
+ "avg_confidence": 0.0,
132
+ "high_conf_count": 0,
133
+ "medium_conf_count": 0,
134
+ "low_conf_count": 0,
135
+ "high_conf_pct": 0.0,
136
+ "medium_conf_pct": 0.0,
137
+ "low_conf_pct": 0.0,
138
+ "total_words": 0,
139
+ "total_characters": 0,
140
+ "lexical_validity": 0.0,
141
+ "reliability_label": "No Text Detected",
142
+ "reliability_color": "#94a3b8",
143
+ }
144
 
145
+ confidences = [r["confidence"] for r in results]
146
+ total_words = len(results)
147
+ avg_conf = float(np.mean(confidences))
148
+
149
+ high_c = sum(1 for c in confidences if c >= 0.80)
150
+ med_c = sum(1 for c in confidences if 0.50 <= c < 0.80)
151
+ low_c = sum(1 for c in confidences if c < 0.50)
152
+
153
+ # Lexical validity: check if word contains intelligible alphanumeric patterns
154
+ valid_tokens = 0
155
+ total_chars = 0
156
+ for r in results:
157
+ t = r["text"]
158
+ total_chars += len(t)
159
+ # Token has letters or numbers (not pure punctuation noise)
160
+ if re.search(r"[A-Za-z0-9\u0900-\u097F]", t):
161
+ valid_tokens += 1
162
+
163
+ lexical_validity = (valid_tokens / total_words) if total_words > 0 else 0.0
164
+
165
+ # Composite OCR Accuracy Index (weighted: 70% confidence + 30% lexical validity)
166
+ overall_accuracy = (avg_conf * 0.70 + lexical_validity * 0.30) * 100.0
167
+
168
+ # Determine Quality Grade
169
+ if overall_accuracy >= 90.0:
170
+ grade = "A+"
171
+ label = "Exceptional Accuracy"
172
+ color = "#16a34a" # Green
173
+ elif overall_accuracy >= 80.0:
174
+ grade = "A"
175
+ label = "High Accuracy (Production Ready)"
176
+ color = "#22c55e"
177
+ elif overall_accuracy >= 68.0:
178
+ grade = "B"
179
+ label = "Good / Readable"
180
+ color = "#eab308" # Yellow
181
+ elif overall_accuracy >= 50.0:
182
+ grade = "C"
183
+ label = "Moderate (Review Recommended)"
184
+ color = "#f97316" # Orange
185
+ else:
186
+ grade = "D"
187
+ label = "Low (Retake photo with better lighting)"
188
+ color = "#ef4444" # Red
189
 
190
+ return {
191
+ "overall_accuracy": round(overall_accuracy, 1),
192
+ "quality_grade": grade,
193
+ "avg_confidence": round(avg_conf, 3),
194
+ "high_conf_count": high_c,
195
+ "medium_conf_count": med_c,
196
+ "low_conf_count": low_c,
197
+ "high_conf_pct": round((high_c / total_words) * 100.0, 1),
198
+ "medium_conf_pct": round((med_c / total_words) * 100.0, 1),
199
+ "low_conf_pct": round((low_c / total_words) * 100.0, 1),
200
+ "total_words": total_words,
201
+ "total_characters": total_chars,
202
+ "lexical_validity": round(lexical_validity * 100.0, 1),
203
+ "reliability_label": label,
204
+ "reliability_color": color,
205
+ }
206
 
207
 
208
  def get_statistics(results: List[Dict]) -> Dict[str, Any]:
209
+ """Compatibility wrapper that delegates to compute_accuracy_metrics."""
210
+ return compute_accuracy_metrics(results)
 
 
 
 
 
 
 
 
 
 
 
 
src/preprocessor.py CHANGED
@@ -1,19 +1,30 @@
1
  """
2
- preprocessor.py — OpenCV Computer Vision preprocessing pipeline.
3
  Features:
 
4
  - Auto-deskew via Hough line transform
5
- - CLAHE contrast enhancement
6
- - Bilateral denoising
7
- - Perspective warp (4-corner flattening)
8
- - Adaptive binarization
 
 
9
  """
10
 
11
  import cv2
12
  import numpy as np
13
- from PIL import Image
14
  import math
15
 
16
 
 
 
 
 
 
 
 
 
17
  def pil_to_cv2(pil_img: Image.Image) -> np.ndarray:
18
  """Convert PIL Image → OpenCV BGR ndarray."""
19
  arr = np.array(pil_img.convert("RGB"))
@@ -26,6 +37,37 @@ def cv2_to_pil(cv2_img: np.ndarray) -> Image.Image:
26
  return Image.fromarray(rgb)
27
 
28
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  def auto_deskew(img: np.ndarray) -> np.ndarray:
30
  """Detect text tilt and rotate image to align text horizontally."""
31
  gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
@@ -44,8 +86,8 @@ def auto_deskew(img: np.ndarray) -> np.ndarray:
44
  else:
45
  angle = -angle
46
 
47
- # Only correct if tilt is significant
48
- if abs(angle) < 0.5 or abs(angle) > 45:
49
  return img
50
 
51
  h, w = img.shape[:2]
@@ -59,51 +101,54 @@ def auto_deskew(img: np.ndarray) -> np.ndarray:
59
  return rotated
60
 
61
 
62
- def enhance_contrast(img: np.ndarray) -> np.ndarray:
63
- """Apply CLAHE (Contrast Limited Adaptive Histogram Equalization) per channel."""
64
  lab = cv2.cvtColor(img, cv2.COLOR_BGR2LAB)
65
  l, a, b = cv2.split(lab)
66
- clahe = cv2.createCLAHE(clipLimit=3.0, tileGridSize=(8, 8))
67
  l_enhanced = clahe.apply(l)
68
  merged = cv2.merge([l_enhanced, a, b])
69
  return cv2.cvtColor(merged, cv2.COLOR_LAB2BGR)
70
 
71
 
72
  def denoise(img: np.ndarray) -> np.ndarray:
73
- """Bilateral filter to remove noise while preserving text edges."""
74
- return cv2.bilateralFilter(img, d=9, sigmaColor=75, sigmaSpace=75)
75
 
76
 
77
  def binarize(img: np.ndarray) -> np.ndarray:
78
- """Adaptive Otsu binarization for high-contrast black/white output."""
79
  gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
80
- _, binary = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU)
 
 
 
 
 
 
 
 
81
  return cv2.cvtColor(binary, cv2.COLOR_GRAY2BGR)
82
 
83
 
84
  def detect_document_corners(img: np.ndarray):
85
- """
86
- Detect the 4 corners of a document for perspective warp.
87
- Returns corners array or None if not detected.
88
- """
89
  gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
90
  blurred = cv2.GaussianBlur(gray, (5, 5), 0)
91
- edged = cv2.Canny(blurred, 75, 200)
92
 
93
  contours, _ = cv2.findContours(edged.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
94
  if not contours:
95
  return None
96
 
97
  contours = sorted(contours, key=cv2.contourArea, reverse=True)[:5]
98
- screen_cnt = None
99
  for c in contours:
100
  peri = cv2.arcLength(c, True)
101
  approx = cv2.approxPolyDP(c, 0.02 * peri, True)
102
- if len(approx) == 4:
103
- screen_cnt = approx
104
- break
105
 
106
- return screen_cnt
107
 
108
 
109
  def order_points(pts):
@@ -132,6 +177,9 @@ def perspective_warp(img: np.ndarray, corners) -> np.ndarray:
132
  height_b = np.linalg.norm(tl - bl)
133
  max_height = max(int(height_a), int(height_b))
134
 
 
 
 
135
  dst = np.array([
136
  [0, 0],
137
  [max_width - 1, 0],
@@ -146,39 +194,65 @@ def perspective_warp(img: np.ndarray, corners) -> np.ndarray:
146
 
147
  def full_preprocess(pil_img: Image.Image, mode: str = "auto") -> tuple[Image.Image, dict]:
148
  """
149
- Full preprocessing pipeline.
150
- mode: 'raw' | 'auto' | 'binarize' | 'warp'
151
- Returns (processed_pil_image, metadata_dict)
 
 
 
 
152
  """
153
- cv2_img = pil_to_cv2(pil_img)
154
- steps_applied = []
 
155
 
156
  if mode == "raw":
157
- return pil_img, {"steps": ["raw"]}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
158
 
159
- # Step 1: Auto deskew
160
  deskewed = auto_deskew(cv2_img)
161
  if not np.array_equal(deskewed, cv2_img):
162
- steps_applied.append("deskew")
163
  cv2_img = deskewed
164
 
165
- # Step 2: Contrast enhancement
166
- cv2_img = enhance_contrast(cv2_img)
167
- steps_applied.append("CLAHE contrast")
 
 
 
 
 
168
 
169
- # Step 3: Denoise
 
 
 
 
170
  cv2_img = denoise(cv2_img)
171
- steps_applied.append("bilateral denoise")
172
 
 
173
  if mode == "binarize":
174
  cv2_img = binarize(cv2_img)
175
- steps_applied.append("Otsu binarize")
176
- elif mode == "warp":
177
- corners = detect_document_corners(cv2_img)
178
- if corners is not None:
179
- cv2_img = perspective_warp(cv2_img, corners)
180
- steps_applied.append("perspective warp")
181
- else:
182
- steps_applied.append("warp not detected")
183
 
184
  return cv2_to_pil(cv2_img), {"steps": steps_applied}
 
1
  """
2
+ preprocessor.py — Advanced Computer Vision preprocessing pipeline for documents and live camera photos.
3
  Features:
4
+ - EXIF rotation normalization (vital for mobile/camera photos)
5
  - Auto-deskew via Hough line transform
6
+ - Background shadow & illumination normalization
7
+ - Unsharp masking for camera blur correction
8
+ - CLAHE adaptive contrast enhancement
9
+ - Bilateral edge-preserving denoising
10
+ - Adaptive Otsu binarization
11
+ - Perspective warp for angled captures
12
  """
13
 
14
  import cv2
15
  import numpy as np
16
+ from PIL import Image, ImageOps
17
  import math
18
 
19
 
20
+ def fix_exif_orientation(pil_img: Image.Image) -> Image.Image:
21
+ """Normalize image orientation using EXIF data (crucial for phone & webcam photos)."""
22
+ try:
23
+ return ImageOps.exif_transpose(pil_img)
24
+ except Exception:
25
+ return pil_img
26
+
27
+
28
  def pil_to_cv2(pil_img: Image.Image) -> np.ndarray:
29
  """Convert PIL Image → OpenCV BGR ndarray."""
30
  arr = np.array(pil_img.convert("RGB"))
 
37
  return Image.fromarray(rgb)
38
 
39
 
40
+ def remove_shadows_and_normalize(cv2_img: np.ndarray) -> np.ndarray:
41
+ """
42
+ Remove uneven background shadows and normalize illumination.
43
+ Particularly effective for phone photos with cast shadows or glare.
44
+ """
45
+ # Split into color planes
46
+ rgb_planes = cv2.split(cv2_img)
47
+ result_planes = []
48
+
49
+ for plane in rgb_planes:
50
+ # Dilate to approximate background
51
+ dilated_img = cv2.dilate(plane, np.ones((7, 7), np.uint8))
52
+ bg_img = cv2.medianBlur(dilated_img, 21)
53
+ # Difference between plane and estimated background
54
+ diff_img = 255 - cv2.absdiff(plane, bg_img)
55
+ # Normalize to full dynamic range
56
+ norm_img = cv2.normalize(
57
+ diff_img, None, alpha=0, beta=255, norm_type=cv2.NORM_MINMAX, dtype=cv2.CV_8UC1
58
+ )
59
+ result_planes.append(norm_img)
60
+
61
+ return cv2.merge(result_planes)
62
+
63
+
64
+ def sharpen_image(cv2_img: np.ndarray, strength: float = 1.3) -> np.ndarray:
65
+ """Apply unsharp mask filter to sharpen slightly blurry camera captures."""
66
+ gaussian = cv2.GaussianBlur(cv2_img, (0, 0), 2.0)
67
+ sharpened = cv2.addWeighted(cv2_img, 1.0 + strength, gaussian, -strength, 0)
68
+ return sharpened
69
+
70
+
71
  def auto_deskew(img: np.ndarray) -> np.ndarray:
72
  """Detect text tilt and rotate image to align text horizontally."""
73
  gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
 
86
  else:
87
  angle = -angle
88
 
89
+ # Only correct if tilt is moderate and significant
90
+ if abs(angle) < 0.6 or abs(angle) > 40:
91
  return img
92
 
93
  h, w = img.shape[:2]
 
101
  return rotated
102
 
103
 
104
+ def enhance_contrast(img: np.ndarray, clip_limit: float = 2.5) -> np.ndarray:
105
+ """Apply CLAHE (Contrast Limited Adaptive Histogram Equalization)."""
106
  lab = cv2.cvtColor(img, cv2.COLOR_BGR2LAB)
107
  l, a, b = cv2.split(lab)
108
+ clahe = cv2.createCLAHE(clipLimit=clip_limit, tileGridSize=(8, 8))
109
  l_enhanced = clahe.apply(l)
110
  merged = cv2.merge([l_enhanced, a, b])
111
  return cv2.cvtColor(merged, cv2.COLOR_LAB2BGR)
112
 
113
 
114
  def denoise(img: np.ndarray) -> np.ndarray:
115
+ """Bilateral filter to remove camera noise while preserving text edges."""
116
+ return cv2.bilateralFilter(img, d=7, sigmaColor=50, sigmaSpace=50)
117
 
118
 
119
  def binarize(img: np.ndarray) -> np.ndarray:
120
+ """Adaptive Gaussian thresholding for crisp black/white document text."""
121
  gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
122
+ # Background shadow removal prior to binarization
123
+ dilated = cv2.dilate(gray, np.ones((7, 7), np.uint8))
124
+ bg = cv2.medianBlur(dilated, 21)
125
+ diff = 255 - cv2.absdiff(gray, bg)
126
+ norm = cv2.normalize(diff, None, alpha=0, beta=255, norm_type=cv2.NORM_MINMAX, dtype=cv2.CV_8UC1)
127
+
128
+ binary = cv2.adaptiveThreshold(
129
+ norm, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 21, 10
130
+ )
131
  return cv2.cvtColor(binary, cv2.COLOR_GRAY2BGR)
132
 
133
 
134
  def detect_document_corners(img: np.ndarray):
135
+ """Detect the 4 corners of a document for perspective warp."""
 
 
 
136
  gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
137
  blurred = cv2.GaussianBlur(gray, (5, 5), 0)
138
+ edged = cv2.Canny(blurred, 50, 150)
139
 
140
  contours, _ = cv2.findContours(edged.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
141
  if not contours:
142
  return None
143
 
144
  contours = sorted(contours, key=cv2.contourArea, reverse=True)[:5]
 
145
  for c in contours:
146
  peri = cv2.arcLength(c, True)
147
  approx = cv2.approxPolyDP(c, 0.02 * peri, True)
148
+ if len(approx) == 4 and cv2.contourArea(c) > (img.shape[0] * img.shape[1] * 0.15):
149
+ return approx
 
150
 
151
+ return None
152
 
153
 
154
  def order_points(pts):
 
177
  height_b = np.linalg.norm(tl - bl)
178
  max_height = max(int(height_a), int(height_b))
179
 
180
+ if max_width < 100 or max_height < 100:
181
+ return img
182
+
183
  dst = np.array([
184
  [0, 0],
185
  [max_width - 1, 0],
 
194
 
195
  def full_preprocess(pil_img: Image.Image, mode: str = "auto") -> tuple[Image.Image, dict]:
196
  """
197
+ Comprehensive document preprocessing pipeline.
198
+ Modes:
199
+ - 'auto': EXIF fix + deskew + shadow removal + CLAHE + sharpening + gentle denoise
200
+ - 'camera_enhance': Specialized for live camera / phone photos (shadows, glare, blur compensation)
201
+ - 'binarize': High contrast B&W text thresholding
202
+ - 'warp': 4-corner perspective flattening
203
+ - 'raw': Passthrough with EXIF rotation fixed
204
  """
205
+ # Always normalize EXIF orientation first
206
+ fixed_pil = fix_exif_orientation(pil_img)
207
+ steps_applied = ["exif_orientation_fixed"]
208
 
209
  if mode == "raw":
210
+ return fixed_pil, {"steps": ["raw_passthrough"]}
211
+
212
+ cv2_img = pil_to_cv2(fixed_pil)
213
+
214
+ # Resolution scaling check: if too small (e.g. low-res webcam), upscale with bicubic
215
+ h, w = cv2_img.shape[:2]
216
+ if min(h, w) < 700:
217
+ scale = 800.0 / min(h, w)
218
+ cv2_img = cv2.resize(cv2_img, (int(w * scale), int(h * scale)), interpolation=cv2.INTER_CUBIC)
219
+ steps_applied.append("rescale_upscale")
220
+
221
+ # Step 1: Perspective Warp if explicitly selected
222
+ if mode == "warp":
223
+ corners = detect_document_corners(cv2_img)
224
+ if corners is not None:
225
+ cv2_img = perspective_warp(cv2_img, corners)
226
+ steps_applied.append("perspective_warp")
227
+ else:
228
+ steps_applied.append("warp_not_detected")
229
 
230
+ # Step 2: Auto Deskew
231
  deskewed = auto_deskew(cv2_img)
232
  if not np.array_equal(deskewed, cv2_img):
233
+ steps_applied.append("auto_deskew")
234
  cv2_img = deskewed
235
 
236
+ # Step 3: Camera photo shadow removal & illumination flattening
237
+ if mode in ("camera_enhance", "auto"):
238
+ cv2_img = remove_shadows_and_normalize(cv2_img)
239
+ steps_applied.append("shadow_illumination_norm")
240
+
241
+ # Step 4: CLAHE Contrast Enhancement
242
+ cv2_img = enhance_contrast(cv2_img, clip_limit=3.0 if mode == "camera_enhance" else 2.2)
243
+ steps_applied.append("clahe_contrast")
244
 
245
+ # Step 5: Unsharp masking to sharpen characters
246
+ cv2_img = sharpen_image(cv2_img, strength=1.2 if mode == "camera_enhance" else 0.8)
247
+ steps_applied.append("unsharp_mask_sharpen")
248
+
249
+ # Step 6: Denoise
250
  cv2_img = denoise(cv2_img)
251
+ steps_applied.append("bilateral_denoise")
252
 
253
+ # Step 7: Binarization if requested
254
  if mode == "binarize":
255
  cv2_img = binarize(cv2_img)
256
+ steps_applied.append("adaptive_binarize")
 
 
 
 
 
 
 
257
 
258
  return cv2_to_pil(cv2_img), {"steps": steps_applied}