muddasser commited on
Commit
383e728
·
verified ·
1 Parent(s): 35b4c35

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +75 -41
app.py CHANGED
@@ -4,6 +4,9 @@ import glob
4
  import io
5
  import os
6
  import re
 
 
 
7
  import unicodedata
8
  from collections import defaultdict
9
 
@@ -14,7 +17,7 @@ import pandas as pd
14
  import streamlit as st
15
  from docx import Document
16
  from langchain_groq import ChatGroq
17
- from PIL import Image, ImageFilter
18
  from pptx import Presentation
19
  from rank_bm25 import BM25Okapi
20
  from sentence_transformers import SentenceTransformer
@@ -26,7 +29,9 @@ CHUNK_WORDS = 80 # stays under the embedding model's 128-token limit
26
  CHUNK_OVERLAP = 15
27
  TABLE_CHUNK_WORDS = 60 # table/spreadsheet rows are token-heavy, so smaller groups
28
  RRF_K = 60
29
- OCR_DPI = 400 # higher than before: small typewriter/fax text needs it. Lower (e.g. 300) for speed.
 
 
30
  OCR_LANGS = "ara+eng"
31
  OCR_FALLBACK_LANGS = "eng" # tried first (Arabic data can hurt English-only pages); also the fallback if Arabic data is missing
32
  OCR_CONTENT_THRESHOLD = 200 # gray level below which a pixel counts as "content" (0-255)
@@ -99,22 +104,26 @@ def _content_clip(page):
99
  except Exception:
100
  return page.rect
101
 
102
- def _prep_variants(page, clip):
103
- """Render the page, flatten uneven background, stretch contrast, and also return an
104
- inverted copy (for white-on-dark text such as banners). Returns [normal, inverted]."""
105
  pix = page.get_pixmap(dpi=OCR_DPI, clip=clip, colorspace=fitz.csGRAY)
106
- img = Image.frombytes("L", (pix.width, pix.height), pix.samples)
107
- bg = img.filter(ImageFilter.GaussianBlur(radius=max(15, OCR_DPI // 8)))
108
- a = np.asarray(img, dtype=np.float32)
109
- b = np.asarray(bg, dtype=np.float32) + 1.0
110
- flat = np.clip(a / b * 255.0, 0, 255) # divides out the uneven gray background
111
- lo, hi = np.percentile(flat, (2, 98))
112
- flat = np.clip((flat - lo) / max(hi - lo, 1) * 255.0, 0, 255).astype(np.uint8)
113
- return [flat, 255 - flat]
114
-
115
- def _ocr_array(arr, lang):
116
- h, w = arr.shape
117
- pix = fitz.Pixmap(fitz.csGRAY, w, h, np.ascontiguousarray(arr).tobytes(), False)
 
 
 
 
 
 
118
  pix.set_dpi(OCR_DPI, OCR_DPI)
119
  ocr_doc = fitz.open("pdf", pix.pdfocr_tobytes(language=lang))
120
  try:
@@ -124,39 +133,43 @@ def _ocr_array(arr, lang):
124
 
125
  def ocr_page(page):
126
  """OCR a scanned page. Returns (text, error).
127
- Runs English-first and Arabic+English, each on a cleaned and an inverted image,
128
- and merges the unique lines. error is None unless every attempt raised, so
129
- 'OCR ran and found nothing' can be told apart from 'OCR could not run'."""
130
  _set_tessdata()
131
  clip = _content_clip(page)
132
  if clip is None: # truly blank page: nothing to OCR, and not an error
133
- return "", None
134
- errors, lines, seen = [], [], set()
135
  try:
136
- variants = _prep_variants(page, clip)
137
  except Exception as e:
138
- return "", f"image preprocessing failed: {e}"
 
 
 
139
  for lang in dict.fromkeys((OCR_FALLBACK_LANGS, OCR_LANGS)): # de-duplicated, order kept
140
- for arr in variants:
141
- try:
142
- txt = _ocr_array(arr, lang)
143
- except Exception as e:
144
- errors.append(f"[{lang}] {e}")
145
- continue
146
- for ln in txt.splitlines():
147
- key = re.sub(r"\W+", "", ln.lower())
148
- if len(key) >= 3 and key not in seen: # merge unique lines from all passes
149
- seen.add(key)
150
- lines.append(ln.strip())
 
 
151
  if lines:
152
- return "\n".join(lines), None
153
- return "", ("; ".join(errors) if errors else None)
154
 
155
  def extract_pdf(name, data):
156
  blocks = []
157
  stats = {"pages": 0, "tables": 0, "notes": []}
158
  doc = fitz.open(stream=data, filetype="pdf")
159
- empty_pages, ocr_pages, ocr_errors = [], [], []
160
  for i, page in enumerate(doc, 1):
161
  stats["pages"] += 1
162
  loc = f"p.{i}"
@@ -165,7 +178,8 @@ def extract_pdf(name, data):
165
  blocks.append(make_block(tbl, name, loc, "table"))
166
  stats["tables"] += 1
167
  if len(text) < 50:
168
- ocr_text, ocr_err = ocr_page(page)
 
169
  if ocr_err:
170
  ocr_errors.append((i, ocr_err))
171
  if len(ocr_text) > len(text):
@@ -186,6 +200,9 @@ def extract_pdf(name, data):
186
  "Check that Tesseract and its language data (eng, ara) are installed."
187
  )
188
  if empty_pages:
 
 
 
189
  shown = ", ".join(map(str, empty_pages[:15])) + ("..." if len(empty_pages) > 15 else "")
190
  stats["notes"].append(
191
  f"Little or no readable text found on page(s) {shown}"
@@ -271,7 +288,7 @@ def extract_text_file(name, data):
271
  return blocks, {"pages": 1, "tables": 0, "notes": []}
272
 
273
  @st.cache_data(show_spinner=False)
274
- def extract_file(name, data):
275
  lower = name.lower()
276
  if lower.endswith(".pdf"):
277
  return extract_pdf(name, data)
@@ -386,6 +403,23 @@ with st.sidebar:
386
  min_rel = st.slider("Minimum relevance to answer", 0.0, 0.8, 0.25, 0.05,
387
  help="If the best semantic match is below this, the app says 'not found' instead of guessing.")
388
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
389
  embedder = load_embedder()
390
 
391
  groq_key = None
@@ -413,7 +447,7 @@ if uploaded_files:
413
  with st.spinner("Reading documents (scanned pages are OCR'd and may take a while)..."):
414
  for f in uploaded_files:
415
  try:
416
- blocks, stats = extract_file(f.name, f.getvalue())
417
  except Exception as e:
418
  blocks, stats = [], {"pages": 0, "tables": 0, "notes": [f"Could not read file: {e}"]}
419
  all_blocks.extend(blocks)
 
4
  import io
5
  import os
6
  import re
7
+ import shutil
8
+ import subprocess
9
+ import tempfile
10
  import unicodedata
11
  from collections import defaultdict
12
 
 
17
  import streamlit as st
18
  from docx import Document
19
  from langchain_groq import ChatGroq
20
+ from PIL import Image, ImageOps
21
  from pptx import Presentation
22
  from rank_bm25 import BM25Okapi
23
  from sentence_transformers import SentenceTransformer
 
29
  CHUNK_OVERLAP = 15
30
  TABLE_CHUNK_WORDS = 60 # table/spreadsheet rows are token-heavy, so smaller groups
31
  RRF_K = 60
32
+ OCR_DPI = 200 # Tesseract works best when text is ~30px tall; 400 hurt large poster text. Raise for tiny fax text.
33
+ OCR_PSMS = (3, 11) # 3 = auto page layout, 11 = sparse text (finds scattered words on posters)
34
+ EXTRACT_VERSION = "ocr-cli-v3" # bump to invalidate st.cache_data (it does not track helper functions)
35
  OCR_LANGS = "ara+eng"
36
  OCR_FALLBACK_LANGS = "eng" # tried first (Arabic data can hurt English-only pages); also the fallback if Arabic data is missing
37
  OCR_CONTENT_THRESHOLD = 200 # gray level below which a pixel counts as "content" (0-255)
 
104
  except Exception:
105
  return page.rect
106
 
107
+ def _render_gray(page, clip):
 
 
108
  pix = page.get_pixmap(dpi=OCR_DPI, clip=clip, colorspace=fitz.csGRAY)
109
+ return Image.frombytes("L", (pix.width, pix.height), pix.samples)
110
+
111
+ def _tesseract_cli(img, lang, psm):
112
+ """Run the tesseract binary directly on a PIL image. Raises if it is missing or fails."""
113
+ with tempfile.TemporaryDirectory() as tmp:
114
+ path = os.path.join(tmp, "page.png")
115
+ img.save(path)
116
+ env = dict(os.environ)
117
+ r = subprocess.run(["tesseract", path, "stdout", "-l", lang, "--psm", str(psm)],
118
+ capture_output=True, text=True, timeout=180, env=env)
119
+ if r.returncode != 0:
120
+ raise RuntimeError((r.stderr or "tesseract failed").strip()[:300])
121
+ return unicodedata.normalize("NFKC", r.stdout).strip()
122
+
123
+ def _tesseract_pymupdf(img, lang):
124
+ """Fallback when the tesseract binary is not on PATH: PyMuPDF's built-in OCR."""
125
+ w, h = img.size
126
+ pix = fitz.Pixmap(fitz.csGRAY, w, h, img.tobytes(), False)
127
  pix.set_dpi(OCR_DPI, OCR_DPI)
128
  ocr_doc = fitz.open("pdf", pix.pdfocr_tobytes(language=lang))
129
  try:
 
133
 
134
  def ocr_page(page):
135
  """OCR a scanned page. Returns (text, error).
136
+ Tries English first, then Arabic+English, each on the raw render and a contrast-stretched
137
+ inverted copy (for white-on-dark banners), with two page-segmentation modes, and merges the
138
+ unique lines. error is None unless every attempt raised."""
139
  _set_tessdata()
140
  clip = _content_clip(page)
141
  if clip is None: # truly blank page: nothing to OCR, and not an error
142
+ return "", None, "page looked blank (no pixels darker than the content threshold)"
 
143
  try:
144
+ raw = _render_gray(page, clip)
145
  except Exception as e:
146
+ return "", f"could not render page: {e}", ""
147
+ inv = ImageOps.invert(ImageOps.autocontrast(raw, cutoff=2))
148
+ use_cli = shutil.which("tesseract") is not None
149
+ errors, lines, seen = [], [], set()
150
  for lang in dict.fromkeys((OCR_FALLBACK_LANGS, OCR_LANGS)): # de-duplicated, order kept
151
+ for img in (raw, inv):
152
+ for psm in (OCR_PSMS if use_cli else (None,)):
153
+ try:
154
+ txt = _tesseract_cli(img, lang, psm) if use_cli else _tesseract_pymupdf(img, lang)
155
+ except Exception as e:
156
+ errors.append(f"[{lang}] {e}")
157
+ continue
158
+ for ln in txt.splitlines():
159
+ key = re.sub(r"\W+", "", ln.lower())
160
+ if len(key) >= 3 and key not in seen:
161
+ seen.add(key)
162
+ lines.append(ln.strip())
163
+ info = f"engine={'tesseract CLI' if use_cli else 'PyMuPDF OCR'}, image={raw.size[0]}x{raw.size[1]}px, unique lines={len(lines)}"
164
  if lines:
165
+ return "\n".join(lines), None, info
166
+ return "", ("; ".join(errors) if errors else None), info
167
 
168
  def extract_pdf(name, data):
169
  blocks = []
170
  stats = {"pages": 0, "tables": 0, "notes": []}
171
  doc = fitz.open(stream=data, filetype="pdf")
172
+ empty_pages, ocr_pages, ocr_errors, ocr_infos = [], [], [], []
173
  for i, page in enumerate(doc, 1):
174
  stats["pages"] += 1
175
  loc = f"p.{i}"
 
178
  blocks.append(make_block(tbl, name, loc, "table"))
179
  stats["tables"] += 1
180
  if len(text) < 50:
181
+ ocr_text, ocr_err, ocr_info = ocr_page(page)
182
+ ocr_infos.append((i, len(ocr_text), ocr_info))
183
  if ocr_err:
184
  ocr_errors.append((i, ocr_err))
185
  if len(ocr_text) > len(text):
 
200
  "Check that Tesseract and its language data (eng, ara) are installed."
201
  )
202
  if empty_pages:
203
+ dbg = "; ".join(f"p.{p}: {n} chars, {inf}" for p, n, inf in ocr_infos if p in empty_pages[:3])
204
+ if dbg:
205
+ stats["notes"].append(f"OCR details: {dbg}")
206
  shown = ", ".join(map(str, empty_pages[:15])) + ("..." if len(empty_pages) > 15 else "")
207
  stats["notes"].append(
208
  f"Little or no readable text found on page(s) {shown}"
 
288
  return blocks, {"pages": 1, "tables": 0, "notes": []}
289
 
290
  @st.cache_data(show_spinner=False)
291
+ def extract_file(name, data, version=EXTRACT_VERSION):
292
  lower = name.lower()
293
  if lower.endswith(".pdf"):
294
  return extract_pdf(name, data)
 
403
  min_rel = st.slider("Minimum relevance to answer", 0.0, 0.8, 0.25, 0.05,
404
  help="If the best semantic match is below this, the app says 'not found' instead of guessing.")
405
 
406
+ with st.expander("OCR diagnostics"):
407
+ tbin = shutil.which("tesseract")
408
+ st.write(f"tesseract binary: `{tbin or 'NOT FOUND (using PyMuPDF OCR fallback)'}`")
409
+ if tbin:
410
+ try:
411
+ _set_tessdata()
412
+ out = subprocess.run(["tesseract", "--list-langs"], capture_output=True, text=True, timeout=20)
413
+ st.code((out.stdout or out.stderr).strip())
414
+ except Exception as e:
415
+ st.write(f"Could not query Tesseract: {e}")
416
+ st.write(f"TESSDATA_PREFIX: `{os.environ.get('TESSDATA_PREFIX', 'unset')}`")
417
+ st.caption(f"Extraction version: {EXTRACT_VERSION}")
418
+ if st.button("Clear extraction cache"):
419
+ st.cache_data.clear()
420
+ st.session_state.pop("signature", None)
421
+ st.rerun()
422
+
423
  embedder = load_embedder()
424
 
425
  groq_key = None
 
447
  with st.spinner("Reading documents (scanned pages are OCR'd and may take a while)..."):
448
  for f in uploaded_files:
449
  try:
450
+ blocks, stats = extract_file(f.name, f.getvalue(), EXTRACT_VERSION)
451
  except Exception as e:
452
  blocks, stats = [], {"pages": 0, "tables": 0, "notes": [f"Could not read file: {e}"]}
453
  all_blocks.extend(blocks)