Spaces:
Sleeping
Sleeping
Claude commited on
Commit ·
2a9e166
unverified ·
0
Parent(s):
Add OCR notification/data extractor Gradio app for Hugging Face Spaces
Browse filesTakes PDF or image uploads, OCRs pages (native PDF text or Tesseract for
scans), flags domain notification keywords (construction, compliance,
finance), extracts structured fields (PO/SAP/invoice numbers, dates,
amounts, cert codes) via regex, and exports results to CSV.
- .gitignore +6 -0
- README.md +53 -0
- app.py +155 -0
- extractors.py +123 -0
- ocr_engine.py +73 -0
- packages.txt +1 -0
- requirements.txt +5 -0
.gitignore
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.pyc
|
| 3 |
+
.venv/
|
| 4 |
+
*.csv
|
| 5 |
+
!sample_data/*.csv
|
| 6 |
+
.DS_Store
|
README.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: OCR Notification & Data Extractor
|
| 3 |
+
emoji: 🧾
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: indigo
|
| 6 |
+
sdk: gradio
|
| 7 |
+
sdk_version: 4.44.0
|
| 8 |
+
app_file: app.py
|
| 9 |
+
pinned: false
|
| 10 |
+
license: mit
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
# OCR Notification & Data Extractor
|
| 14 |
+
|
| 15 |
+
Upload scanned PDFs or images and turn them into structured, exportable
|
| 16 |
+
data. Built for teams that need to pull actionable "notification" events
|
| 17 |
+
and key fields out of paper-heavy workflows:
|
| 18 |
+
|
| 19 |
+
- **Construction / SAP data support** — change orders, delay notices,
|
| 20 |
+
punch list items, RFIs, PO numbers, SAP document numbers, quantities.
|
| 21 |
+
- **Compliance** — non-conformances, violations, expirations, corrective
|
| 22 |
+
actions, certification codes (ISO, OSHA, ASTM, ANSI).
|
| 23 |
+
- **Business finance** — past-due invoices, payment rejections, credit
|
| 24 |
+
holds, invoice numbers, dollar amounts, due dates.
|
| 25 |
+
|
| 26 |
+
## How it works
|
| 27 |
+
|
| 28 |
+
1. Upload one or more PDFs or images.
|
| 29 |
+
2. Each page's text is pulled directly from the PDF when it has a text
|
| 30 |
+
layer; scanned/image-only pages are rendered and run through
|
| 31 |
+
Tesseract OCR.
|
| 32 |
+
3. The text is scanned against keyword sets for the categories you pick
|
| 33 |
+
(or your own custom keywords), and structured fields (PO/SAP/invoice
|
| 34 |
+
numbers, dates, dollar amounts, percentages, certification codes) are
|
| 35 |
+
pulled out with regex.
|
| 36 |
+
4. Results are shown in a table and exported as a CSV, one row per page
|
| 37 |
+
that has a hit (or every page, if you turn that filter off).
|
| 38 |
+
|
| 39 |
+
## Local development
|
| 40 |
+
|
| 41 |
+
```bash
|
| 42 |
+
pip install -r requirements.txt
|
| 43 |
+
# Tesseract must also be installed on the system, e.g.:
|
| 44 |
+
# apt-get install tesseract-ocr
|
| 45 |
+
python app.py
|
| 46 |
+
```
|
| 47 |
+
|
| 48 |
+
## Files
|
| 49 |
+
|
| 50 |
+
- `app.py` — Gradio UI and orchestration.
|
| 51 |
+
- `ocr_engine.py` — PDF/image loading and OCR (PyMuPDF + Tesseract).
|
| 52 |
+
- `extractors.py` — keyword notification scanning and structured field
|
| 53 |
+
extraction (regex-based, easy to extend per domain).
|
app.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Gradio app: OCR documents into structured notification/data records.
|
| 2 |
+
|
| 3 |
+
Upload scanned PDFs or images (construction reports, compliance
|
| 4 |
+
certificates, invoices, etc.), scan the extracted text for domain
|
| 5 |
+
"notification" keywords, pull out structured fields (PO/SAP/invoice
|
| 6 |
+
numbers, dates, amounts, cert codes), and export everything to CSV.
|
| 7 |
+
"""
|
| 8 |
+
import os
|
| 9 |
+
import tempfile
|
| 10 |
+
from datetime import datetime
|
| 11 |
+
|
| 12 |
+
import gradio as gr
|
| 13 |
+
import pandas as pd
|
| 14 |
+
|
| 15 |
+
import extractors
|
| 16 |
+
import ocr_engine
|
| 17 |
+
|
| 18 |
+
CATEGORY_CHOICES = ["construction", "compliance", "finance", "general"]
|
| 19 |
+
|
| 20 |
+
COLUMNS = [
|
| 21 |
+
"file_name", "page", "text_source", "ocr_confidence",
|
| 22 |
+
"notification_flags", "notification_context",
|
| 23 |
+
"po_numbers", "sap_doc_numbers", "invoice_numbers", "cert_codes",
|
| 24 |
+
"dates", "amounts", "percentages",
|
| 25 |
+
]
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _file_path(f):
|
| 29 |
+
return f if isinstance(f, str) else f.name
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def process_files(files, categories, custom_keywords_str, only_matches):
|
| 33 |
+
if not files:
|
| 34 |
+
return "Upload at least one PDF or image file.", pd.DataFrame(columns=COLUMNS), None, ""
|
| 35 |
+
|
| 36 |
+
custom_keywords = None
|
| 37 |
+
if custom_keywords_str and custom_keywords_str.strip():
|
| 38 |
+
custom_keywords = [k.strip() for k in custom_keywords_str.split(",") if k.strip()]
|
| 39 |
+
|
| 40 |
+
rows = []
|
| 41 |
+
full_text_log = []
|
| 42 |
+
warnings = []
|
| 43 |
+
total_pages = 0
|
| 44 |
+
|
| 45 |
+
for f in files:
|
| 46 |
+
path = _file_path(f)
|
| 47 |
+
file_name = os.path.basename(path)
|
| 48 |
+
try:
|
| 49 |
+
pages = ocr_engine.extract_pages(path)
|
| 50 |
+
except Exception as exc:
|
| 51 |
+
warnings.append(f"{file_name}: {exc}")
|
| 52 |
+
continue
|
| 53 |
+
|
| 54 |
+
for page_data in pages:
|
| 55 |
+
total_pages += 1
|
| 56 |
+
text = page_data["text"]
|
| 57 |
+
notifications = extractors.find_notifications(text, categories, custom_keywords)
|
| 58 |
+
fields = extractors.extract_fields(text)
|
| 59 |
+
has_content = bool(notifications) or any(fields.values())
|
| 60 |
+
|
| 61 |
+
full_text_log.append(
|
| 62 |
+
f"--- {file_name} | page {page_data['page']} | {page_data['source']} ---\n{text}\n"
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
if only_matches and not has_content:
|
| 66 |
+
continue
|
| 67 |
+
|
| 68 |
+
contexts = []
|
| 69 |
+
for n in notifications:
|
| 70 |
+
if n["context"] not in contexts:
|
| 71 |
+
contexts.append(n["context"])
|
| 72 |
+
|
| 73 |
+
rows.append({
|
| 74 |
+
"file_name": file_name,
|
| 75 |
+
"page": page_data["page"],
|
| 76 |
+
"text_source": page_data["source"],
|
| 77 |
+
"ocr_confidence": page_data["ocr_confidence"],
|
| 78 |
+
"notification_flags": "; ".join(
|
| 79 |
+
sorted({f"[{n['category']}] {n['keyword']}" for n in notifications})
|
| 80 |
+
),
|
| 81 |
+
"notification_context": " | ".join(contexts[:5]),
|
| 82 |
+
"po_numbers": "; ".join(fields["po_numbers"]),
|
| 83 |
+
"sap_doc_numbers": "; ".join(fields["sap_doc_numbers"]),
|
| 84 |
+
"invoice_numbers": "; ".join(fields["invoice_numbers"]),
|
| 85 |
+
"cert_codes": "; ".join(fields["cert_codes"]),
|
| 86 |
+
"dates": "; ".join(fields["dates"]),
|
| 87 |
+
"amounts": "; ".join(fields["amounts"]),
|
| 88 |
+
"percentages": "; ".join(fields["percentages"]),
|
| 89 |
+
})
|
| 90 |
+
|
| 91 |
+
df = pd.DataFrame(rows, columns=COLUMNS)
|
| 92 |
+
|
| 93 |
+
csv_path = None
|
| 94 |
+
if not df.empty:
|
| 95 |
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 96 |
+
csv_path = os.path.join(tempfile.gettempdir(), f"ocr_extract_{timestamp}.csv")
|
| 97 |
+
df.to_csv(csv_path, index=False)
|
| 98 |
+
|
| 99 |
+
status_lines = [
|
| 100 |
+
f"Processed {len(files)} file(s), {total_pages} page(s) total.",
|
| 101 |
+
f"{len(df)} row(s) exported" + (" (matches only)." if only_matches else "."),
|
| 102 |
+
]
|
| 103 |
+
if warnings:
|
| 104 |
+
status_lines.append("Warnings: " + "; ".join(warnings))
|
| 105 |
+
status = "\n".join(status_lines)
|
| 106 |
+
|
| 107 |
+
return status, df, csv_path, "\n".join(full_text_log)
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
with gr.Blocks(title="OCR Notification & Data Extractor") as demo:
|
| 111 |
+
gr.Markdown(
|
| 112 |
+
"# OCR Notification & Data Extractor\n"
|
| 113 |
+
"Upload scanned PDFs or images (construction reports, compliance "
|
| 114 |
+
"certificates, invoices, SAP printouts, etc.). The app OCRs each "
|
| 115 |
+
"page, flags notification-worthy keywords per domain, extracts "
|
| 116 |
+
"structured fields (PO/SAP/invoice numbers, dates, amounts, cert "
|
| 117 |
+
"codes), and exports everything to CSV."
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
with gr.Row():
|
| 121 |
+
with gr.Column(scale=1):
|
| 122 |
+
files_input = gr.Files(
|
| 123 |
+
label="PDF or image files",
|
| 124 |
+
file_types=[".pdf", ".png", ".jpg", ".jpeg", ".tif", ".tiff", ".bmp"],
|
| 125 |
+
)
|
| 126 |
+
categories_input = gr.CheckboxGroup(
|
| 127 |
+
choices=CATEGORY_CHOICES,
|
| 128 |
+
value=CATEGORY_CHOICES,
|
| 129 |
+
label="Notification categories",
|
| 130 |
+
)
|
| 131 |
+
custom_keywords_input = gr.Textbox(
|
| 132 |
+
label="Custom keywords (comma-separated, optional)",
|
| 133 |
+
placeholder="e.g. re-inspection, warranty claim, hold payment",
|
| 134 |
+
)
|
| 135 |
+
only_matches_input = gr.Checkbox(
|
| 136 |
+
value=True,
|
| 137 |
+
label="Only export pages with a notification hit or extracted field",
|
| 138 |
+
)
|
| 139 |
+
run_button = gr.Button("Run OCR & Extract", variant="primary")
|
| 140 |
+
|
| 141 |
+
with gr.Column(scale=2):
|
| 142 |
+
status_output = gr.Textbox(label="Status", lines=3, interactive=False)
|
| 143 |
+
table_output = gr.Dataframe(label="Extracted records", headers=COLUMNS, wrap=True)
|
| 144 |
+
csv_output = gr.File(label="Download CSV")
|
| 145 |
+
with gr.Accordion("Full OCR text (debug)", open=False):
|
| 146 |
+
text_output = gr.Textbox(label="Raw extracted text", lines=20)
|
| 147 |
+
|
| 148 |
+
run_button.click(
|
| 149 |
+
fn=process_files,
|
| 150 |
+
inputs=[files_input, categories_input, custom_keywords_input, only_matches_input],
|
| 151 |
+
outputs=[status_output, table_output, csv_output, text_output],
|
| 152 |
+
)
|
| 153 |
+
|
| 154 |
+
if __name__ == "__main__":
|
| 155 |
+
demo.launch()
|
extractors.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Keyword and structured-field extraction for OCR'd document text.
|
| 2 |
+
|
| 3 |
+
Domain keyword sets flag "notification" events (things a reviewer would want
|
| 4 |
+
alerted on), and field patterns pull structured values (IDs, dates, amounts)
|
| 5 |
+
out of the surrounding text so results can be dropped straight into a CSV.
|
| 6 |
+
"""
|
| 7 |
+
import re
|
| 8 |
+
|
| 9 |
+
KEYWORD_SETS = {
|
| 10 |
+
"construction": [
|
| 11 |
+
"change order", "delay notice", "punch list", "non-conformance",
|
| 12 |
+
"rfi", "submittal rejected", "back charge", "backcharge",
|
| 13 |
+
"stop work", "safety violation", "material shortage",
|
| 14 |
+
"schedule slip", "cost overrun", "field order", "notice to proceed",
|
| 15 |
+
],
|
| 16 |
+
"compliance": [
|
| 17 |
+
"non-compliant", "noncompliance", "violation", "deficiency",
|
| 18 |
+
"corrective action", "audit finding", "expired", "expiration",
|
| 19 |
+
"recall", "citation", "penalty", "out of compliance",
|
| 20 |
+
"failed inspection", "not approved",
|
| 21 |
+
],
|
| 22 |
+
"finance": [
|
| 23 |
+
"past due", "overdue", "payment rejected", "invoice disputed",
|
| 24 |
+
"credit hold", "chargeback", "insufficient funds", "write-off",
|
| 25 |
+
"collections", "declined", "late fee", "short paid",
|
| 26 |
+
],
|
| 27 |
+
"general": [
|
| 28 |
+
"urgent", "immediate action required", "cancelled", "rejected",
|
| 29 |
+
"approved", "pending approval", "revised", "on hold",
|
| 30 |
+
],
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
FIELD_PATTERNS = {
|
| 34 |
+
"dates": re.compile(
|
| 35 |
+
r"\b(?:\d{1,2}[/-]\d{1,2}[/-]\d{2,4}|\d{4}-\d{2}-\d{2}|"
|
| 36 |
+
r"(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\.?\s+"
|
| 37 |
+
r"\d{1,2},?\s+\d{4})\b",
|
| 38 |
+
re.IGNORECASE,
|
| 39 |
+
),
|
| 40 |
+
"amounts": re.compile(
|
| 41 |
+
r"\$\s?\d[\d,]*\.?\d{0,2}\b|\bUSD\s?\d[\d,]*\.?\d{0,2}\b",
|
| 42 |
+
re.IGNORECASE,
|
| 43 |
+
),
|
| 44 |
+
"percentages": re.compile(r"\b\d{1,3}(?:\.\d+)?\s?%"),
|
| 45 |
+
"po_numbers": re.compile(
|
| 46 |
+
r"\bP\.?\s?O\.?[ \t]*#?[ \t]*[:\-]?[ \t]*((?=[A-Z0-9-]*\d)[A-Z0-9\-]{3,15})\b",
|
| 47 |
+
re.IGNORECASE,
|
| 48 |
+
),
|
| 49 |
+
"sap_doc_numbers": re.compile(
|
| 50 |
+
r"\bSAP[ \t]*(?:Doc(?:ument)?\.?|#)?[ \t]*[:\-]?[ \t]*(\d{8,10})\b|\b(4[5-8]\d{8})\b",
|
| 51 |
+
re.IGNORECASE,
|
| 52 |
+
),
|
| 53 |
+
"invoice_numbers": re.compile(
|
| 54 |
+
r"\bINV(?:OICE)?\.?[ \t]*#?[ \t]*[:\-]?[ \t]*((?=[A-Z0-9-]*\d)[A-Z0-9\-]{3,15})\b",
|
| 55 |
+
re.IGNORECASE,
|
| 56 |
+
),
|
| 57 |
+
"cert_codes": re.compile(
|
| 58 |
+
r"\b(ISO\s?\d{3,5}(?:-\d+)?|OSHA\s?\d{3,4}(?:\.\d+)?|"
|
| 59 |
+
r"ASTM\s?[A-Z]?\d{2,4}(?:-\d{2})?|ANSI\s?[A-Z]?\d+(?:\.\d+)?)\b",
|
| 60 |
+
re.IGNORECASE,
|
| 61 |
+
),
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
CONTEXT_WINDOW = 60
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def _dedupe(values):
|
| 68 |
+
seen = []
|
| 69 |
+
for v in values:
|
| 70 |
+
v = v.strip()
|
| 71 |
+
if v and v not in seen:
|
| 72 |
+
seen.append(v)
|
| 73 |
+
return seen
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def extract_fields(text):
|
| 77 |
+
"""Pull structured fields out of a page/block of text."""
|
| 78 |
+
fields = {}
|
| 79 |
+
for name, pattern in FIELD_PATTERNS.items():
|
| 80 |
+
matches = pattern.findall(text)
|
| 81 |
+
flat = []
|
| 82 |
+
for m in matches:
|
| 83 |
+
if isinstance(m, tuple):
|
| 84 |
+
flat.extend(g for g in m if g)
|
| 85 |
+
else:
|
| 86 |
+
flat.append(m)
|
| 87 |
+
fields[name] = _dedupe(flat)
|
| 88 |
+
return fields
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def find_notifications(text, categories, custom_keywords=None):
|
| 92 |
+
"""Scan text for keyword hits across the given categories.
|
| 93 |
+
|
| 94 |
+
Returns a list of dicts: {category, keyword, context}.
|
| 95 |
+
"""
|
| 96 |
+
hits = []
|
| 97 |
+
keywords_by_category = {}
|
| 98 |
+
for cat in categories:
|
| 99 |
+
keywords_by_category[cat] = list(KEYWORD_SETS.get(cat, []))
|
| 100 |
+
if custom_keywords:
|
| 101 |
+
keywords_by_category["custom"] = list(custom_keywords)
|
| 102 |
+
|
| 103 |
+
lower_text = text.lower()
|
| 104 |
+
for category, keywords in keywords_by_category.items():
|
| 105 |
+
for kw in keywords:
|
| 106 |
+
kw_clean = kw.strip().lower()
|
| 107 |
+
if not kw_clean:
|
| 108 |
+
continue
|
| 109 |
+
start = 0
|
| 110 |
+
while True:
|
| 111 |
+
idx = lower_text.find(kw_clean, start)
|
| 112 |
+
if idx == -1:
|
| 113 |
+
break
|
| 114 |
+
ctx_start = max(0, idx - CONTEXT_WINDOW)
|
| 115 |
+
ctx_end = min(len(text), idx + len(kw_clean) + CONTEXT_WINDOW)
|
| 116 |
+
context = text[ctx_start:ctx_end].replace("\n", " ").strip()
|
| 117 |
+
hits.append({
|
| 118 |
+
"category": category,
|
| 119 |
+
"keyword": kw.strip(),
|
| 120 |
+
"context": context,
|
| 121 |
+
})
|
| 122 |
+
start = idx + len(kw_clean)
|
| 123 |
+
return hits
|
ocr_engine.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""PDF/image loading and OCR.
|
| 2 |
+
|
| 3 |
+
Native (embedded) text is pulled straight out of the PDF when present;
|
| 4 |
+
pages without a usable text layer (scanned documents) are rendered to an
|
| 5 |
+
image and run through Tesseract instead.
|
| 6 |
+
"""
|
| 7 |
+
import os
|
| 8 |
+
|
| 9 |
+
import fitz # PyMuPDF
|
| 10 |
+
import pytesseract
|
| 11 |
+
from PIL import Image
|
| 12 |
+
|
| 13 |
+
MIN_EMBEDDED_TEXT_LEN = 20
|
| 14 |
+
RENDER_DPI = 300
|
| 15 |
+
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".tif", ".tiff", ".bmp"}
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def _ocr_image(img):
|
| 19 |
+
data = pytesseract.image_to_data(img, output_type=pytesseract.Output.DICT)
|
| 20 |
+
confidences = [int(c) for c in data.get("conf", []) if str(c).isdigit() and int(c) >= 0]
|
| 21 |
+
mean_conf = round(sum(confidences) / len(confidences), 1) if confidences else None
|
| 22 |
+
text = pytesseract.image_to_string(img)
|
| 23 |
+
return text, mean_conf
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _process_pdf(file_path):
|
| 27 |
+
pages = []
|
| 28 |
+
doc = fitz.open(file_path)
|
| 29 |
+
try:
|
| 30 |
+
for page_num, page in enumerate(doc, start=1):
|
| 31 |
+
embedded_text = page.get_text().strip()
|
| 32 |
+
if len(embedded_text) >= MIN_EMBEDDED_TEXT_LEN:
|
| 33 |
+
pages.append({
|
| 34 |
+
"page": page_num,
|
| 35 |
+
"text": embedded_text,
|
| 36 |
+
"source": "embedded",
|
| 37 |
+
"ocr_confidence": None,
|
| 38 |
+
})
|
| 39 |
+
continue
|
| 40 |
+
|
| 41 |
+
pix = page.get_pixmap(dpi=RENDER_DPI)
|
| 42 |
+
img = Image.frombytes("RGB", [pix.width, pix.height], pix.samples)
|
| 43 |
+
ocr_text, mean_conf = _ocr_image(img)
|
| 44 |
+
pages.append({
|
| 45 |
+
"page": page_num,
|
| 46 |
+
"text": ocr_text.strip(),
|
| 47 |
+
"source": "ocr",
|
| 48 |
+
"ocr_confidence": mean_conf,
|
| 49 |
+
})
|
| 50 |
+
finally:
|
| 51 |
+
doc.close()
|
| 52 |
+
return pages
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _process_image(file_path):
|
| 56 |
+
img = Image.open(file_path).convert("RGB")
|
| 57 |
+
text, mean_conf = _ocr_image(img)
|
| 58 |
+
return [{
|
| 59 |
+
"page": 1,
|
| 60 |
+
"text": text.strip(),
|
| 61 |
+
"source": "ocr",
|
| 62 |
+
"ocr_confidence": mean_conf,
|
| 63 |
+
}]
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def extract_pages(file_path):
|
| 67 |
+
"""Return a list of {page, text, source, ocr_confidence} dicts for a file."""
|
| 68 |
+
ext = os.path.splitext(file_path)[1].lower()
|
| 69 |
+
if ext == ".pdf":
|
| 70 |
+
return _process_pdf(file_path)
|
| 71 |
+
if ext in IMAGE_EXTENSIONS:
|
| 72 |
+
return _process_image(file_path)
|
| 73 |
+
raise ValueError(f"Unsupported file type: {ext}")
|
packages.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
tesseract-ocr
|
requirements.txt
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
gradio>=4.44.0
|
| 2 |
+
pymupdf>=1.24.0
|
| 3 |
+
pytesseract>=0.3.10
|
| 4 |
+
pillow>=10.0.0
|
| 5 |
+
pandas>=2.0.0
|