Claude commited on
Commit
2a9e166
·
unverified ·
0 Parent(s):

Add OCR notification/data extractor Gradio app for Hugging Face Spaces

Browse files

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

Files changed (7) hide show
  1. .gitignore +6 -0
  2. README.md +53 -0
  3. app.py +155 -0
  4. extractors.py +123 -0
  5. ocr_engine.py +73 -0
  6. packages.txt +1 -0
  7. 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