Spaces:
Sleeping
Sleeping
imporvemnt paths
Browse files- README.md +99 -94
- app.py +35 -6
- src/__pycache__/embedder.cpython-310.pyc +0 -0
- src/__pycache__/extractor.cpython-310.pyc +0 -0
- src/__pycache__/ocr_engine.cpython-310.pyc +0 -0
- src/__pycache__/preprocessor.cpython-310.pyc +0 -0
- src/__pycache__/visualizer.cpython-310.pyc +0 -0
- src/extractor.py +14 -10
- src/ocr_engine.py +138 -63
- src/preprocessor.py +120 -46
README.md
CHANGED
|
@@ -1,112 +1,117 @@
|
|
| 1 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
#
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
#
|
| 15 |
-
|
| 16 |
-
-
|
| 17 |
-
|
| 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 |
-
|
| 44 |
|
| 45 |
-
|
| 46 |
-
Open your terminal or command prompt and run:
|
| 47 |
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
```
|
| 52 |
|
| 53 |
-
|
| 54 |
-
|
| 55 |
```bash
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
source venv/bin/activate # On Windows: venv\\Scripts\\activate
|
| 59 |
```
|
| 60 |
|
| 61 |
-
|
| 62 |
-
|
| 63 |
```bash
|
| 64 |
-
|
| 65 |
```
|
| 66 |
|
| 67 |
-
|
| 68 |
-
|
| 69 |
```bash
|
| 70 |
streamlit run app.py
|
| 71 |
```
|
|
|
|
| 72 |
|
| 73 |
-
|
| 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 |
-

|
| 105 |
-
|
| 106 |
-

|
| 107 |
-
|
| 108 |
-
## Image Uploaded:
|
| 109 |
-

|
| 110 |
|
|
|
|
|
|
|
|
|
|
| 111 |
|
|
|
|
| 112 |
|
|
|
|
|
|
|
|
|
| 1 |
+
# 🧠 DocIQ — Advanced Intelligent Document Processing (IDP) Platform
|
| 2 |
+
|
| 3 |
+
[](https://www.python.org/)
|
| 4 |
+
[](https://streamlit.io/)
|
| 5 |
+
[](https://github.com/JaidedAI/EasyOCR)
|
| 6 |
+
[](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 |
-
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 561 |
st.markdown(f"""<div class="doc-card">
|
| 562 |
-
<div style="font-weight:600;color:#e2e8f0;margin-bottom:
|
|
|
|
|
|
|
|
|
|
| 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
|
| 47 |
-
"gstin": r"(?i)(?:GSTIN|GST
|
| 48 |
-
"due_date": r"(?i)(?:due|payment
|
| 49 |
-
"total": r"(?i)(?:total|
|
| 50 |
-
"subtotal": r"(?i)(?:subtotal|sub-total|taxable
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 —
|
| 3 |
Features:
|
| 4 |
- GPU-accelerated inference (CUDA auto-detect)
|
| 5 |
-
- @st.cache_resource singleton
|
| 6 |
-
-
|
| 7 |
-
-
|
|
|
|
| 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
|
| 18 |
-
def get_reader(languages:
|
| 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
|
| 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.
|
|
|
|
| 37 |
) -> List[Dict[str, Any]]:
|
| 38 |
"""
|
| 39 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
|
| 59 |
structured = []
|
| 60 |
for (bbox, text, prob) in raw_results:
|
|
|
|
| 61 |
if prob < confidence_threshold:
|
| 62 |
continue
|
| 63 |
-
if not
|
| 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":
|
| 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
|
| 92 |
-
|
| 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
|
| 98 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
if not results:
|
| 100 |
-
return
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
|
| 106 |
-
for
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
|
| 118 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
|
| 120 |
|
| 121 |
def get_statistics(results: List[Dict]) -> Dict[str, Any]:
|
| 122 |
-
"""
|
| 123 |
-
|
| 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 —
|
| 3 |
Features:
|
|
|
|
| 4 |
- Auto-deskew via Hough line transform
|
| 5 |
-
-
|
| 6 |
-
-
|
| 7 |
-
-
|
| 8 |
-
-
|
|
|
|
|
|
|
| 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.
|
| 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)
|
| 64 |
lab = cv2.cvtColor(img, cv2.COLOR_BGR2LAB)
|
| 65 |
l, a, b = cv2.split(lab)
|
| 66 |
-
clahe = cv2.createCLAHE(clipLimit=
|
| 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=
|
| 75 |
|
| 76 |
|
| 77 |
def binarize(img: np.ndarray) -> np.ndarray:
|
| 78 |
-
"""Adaptive
|
| 79 |
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
| 80 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 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 |
-
|
| 104 |
-
break
|
| 105 |
|
| 106 |
-
return
|
| 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 |
-
|
| 150 |
-
|
| 151 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
"""
|
| 153 |
-
|
| 154 |
-
|
|
|
|
| 155 |
|
| 156 |
if mode == "raw":
|
| 157 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
|
| 159 |
-
# Step
|
| 160 |
deskewed = auto_deskew(cv2_img)
|
| 161 |
if not np.array_equal(deskewed, cv2_img):
|
| 162 |
-
steps_applied.append("
|
| 163 |
cv2_img = deskewed
|
| 164 |
|
| 165 |
-
# Step
|
| 166 |
-
|
| 167 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
|
| 169 |
-
# Step
|
|
|
|
|
|
|
|
|
|
|
|
|
| 170 |
cv2_img = denoise(cv2_img)
|
| 171 |
-
steps_applied.append("
|
| 172 |
|
|
|
|
| 173 |
if mode == "binarize":
|
| 174 |
cv2_img = binarize(cv2_img)
|
| 175 |
-
steps_applied.append("
|
| 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}
|