Spaces:
Running on Zero
Running on Zero
File size: 8,473 Bytes
540600d | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 | import os
try:
# Ensure local .env values (OLLAMA_*) are loaded when this module is imported
from dotenv import load_dotenv
load_dotenv()
except Exception:
pass
import uuid
import re
import base64
from typing import Dict, Any, Optional
def _mock_result(received: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
out = {"doc_id": str(uuid.uuid4()), "text": "(mock OCR output)", "metadata": {"pages": 1, "confidence": 0.0}}
if received:
out["received_args"] = received
return out
def clean_ocr_text(raw: Optional[str]) -> str:
"""Lightweight post-processing for OCR output to improve readability.
- turns literal "\\n" sequences into real newlines
- unescapes common unicode/HTML escapes when safe
- collapses repeated spaces and blank lines
- trims and merges lines that were broken mid-sentence
"""
if not raw:
return ""
s = raw
try:
# Convert literal backslash-n to actual newline
s = s.replace('\\n', '\n')
except Exception:
pass
# Try to interpret common escape sequences (conservative)
try:
s_decoded = s.encode('utf-8', errors='surrogatepass').decode('unicode_escape')
# Accept decoded result if it reduces backslash artifacts
if '\\n' not in s_decoded:
s = s_decoded
except Exception:
pass
# Normalize line endings and whitespace
s = s.replace('\r\n', '\n').replace('\r', '\n')
s = re.sub(r'[ \t]{2,}', ' ', s)
# Collapse 3+ newlines into max 2
s = re.sub(r'\n{3,}', '\n\n', s)
# Trim whitespace on each line
lines = [ln.strip() for ln in s.splitlines()]
# Merge lines that likely were broken mid-sentence: if a line doesn't end with
# sentence punctuation and the next line begins with a lowercase letter or digit,
# join them with a space.
merged = []
i = 0
while i < len(lines):
line = lines[i]
if i < len(lines) - 1:
nxt = lines[i + 1]
if line and nxt and not re.search(r'[\.\!\?\:;]$', line) and re.match(r'^[a-z0-9]', nxt):
line = line + ' ' + nxt
i += 2
# Continue merging subsequent similar lines
while i < len(lines) and lines[i] and not re.search(r'[\.\!\?\:;]$', line) and re.match(r'^[a-z0-9]', lines[i]):
line = line + ' ' + lines[i]
i += 1
merged.append(line)
continue
merged.append(line)
i += 1
s = '\n'.join([ln for ln in merged if ln])
# Remove long runs of underscores or pipes
s = re.sub(r'[_\|]{2,}', '', s)
# Strip control chars except common whitespace
s = ''.join(ch for ch in s if ord(ch) >= 9 and ord(ch) != 11 and ord(ch) != 12)
try:
import html
s = html.unescape(s)
except Exception:
pass
# Decode literal unicode escapes like \u0027 -> ' and \xA4 -> currency symbol
try:
def _u_decode(m):
code = m.group(1)
try:
return chr(int(code, 16))
except Exception:
return m.group(0)
s = re.sub(r'\\u([0-9A-Fa-f]{4})', _u_decode, s)
s = re.sub(r'\\x([0-9A-Fa-f]{2})', lambda m: chr(int(m.group(1), 16)), s)
except Exception:
pass
# Remove stray leading 'n' characters that appear before uppercase words (common OCR/newline artifact)
try:
s = re.sub(r'(?<=\s|^)[nN](?=[A-Z])', '', s)
except Exception:
pass
# Ensure space after commas when missing
try:
s = re.sub(r',(?=[^\s])', ', ', s)
except Exception:
pass
# Remove spaces before punctuation
try:
s = re.sub(r'\s+([,\.\:\;\%])', r'\1', s)
except Exception:
pass
# Attempt to fix common mojibake (double-encoded UTF-8) conservatively.
# Only apply if there are visible 'Ã' sequences suggesting encoding issues.
try:
if 'Ã' in s or 'Â' in s:
try:
candidate = s.encode('latin-1').decode('utf-8')
# Accept candidate if it reduces suspicious sequences
if candidate.count('Ã') < s.count('Ã'):
s = candidate
except Exception:
pass
except Exception:
pass
return s.strip()
def process_image(image_url: Optional[str] = None, image_bytes: Optional[bytes] = None, options: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""Process an image using local OCR first (pytesseract), with a light transformers fallback.
Accepts either `image_url` or `image_bytes` (bytes or base64 string). Returns dict: {doc_id, text, metadata}
"""
# Lightweight imports that may be missing in minimal environments
try:
from PIL import Image
from io import BytesIO
import requests
except Exception:
# Missing imaging/network deps -> return mock with echo of args
return _mock_result(received={"image_url": image_url, "has_bytes": bool(image_bytes)})
# Normalize image_bytes if base64 string was passed
if isinstance(image_bytes, str):
try:
image_bytes = base64.b64decode(image_bytes)
except Exception:
image_bytes = None
# Acquire PIL image
img = None
try:
if image_bytes:
img = Image.open(BytesIO(image_bytes)).convert("RGB")
elif image_url:
resp = requests.get(image_url, timeout=15)
resp.raise_for_status()
img = Image.open(BytesIO(resp.content)).convert("RGB")
else:
return _mock_result(received={"image_url": image_url, "has_bytes": False})
except Exception:
return _mock_result(received={"image_url": image_url, "has_bytes": bool(image_bytes)})
# PRIORITY: Try local Tesseract (pytesseract) first — lightweight and deterministic on Windows
try:
import pytesseract
# Robustly find tesseract executable: prefer PATH, then common Windows install locations
try:
import shutil
found = shutil.which('tesseract')
except Exception:
found = None
if not found:
# common install locations on Windows
for candidate in (r"C:\Program Files\Tesseract-OCR\tesseract.exe", r"C:\Program Files (x86)\Tesseract-OCR\tesseract.exe"):
try:
if os.path.exists(candidate):
found = candidate
break
except Exception:
continue
try:
if found:
pytesseract.pytesseract.tesseract_cmd = found
print(f"[document_scanner] using tesseract executable: {found}")
else:
# leave default; pytesseract will raise a helpful error which we catch below
print("[document_scanner] tesseract executable not found in PATH or common locations")
except Exception:
# ignore assignment errors and let pytesseract raise on use
pass
try:
if img is not None:
print("[document_scanner] attempting PRIMARY pytesseract OCR")
ttxt = pytesseract.image_to_string(img)
if ttxt and ttxt.strip():
print(f"[document_scanner] pytesseract returned text (len={len(ttxt.strip())})")
return {"doc_id": str(uuid.uuid4()), "text": clean_ocr_text(ttxt), "metadata": {"pages": 1, "confidence": None}}
else:
print("[document_scanner] pytesseract returned no text; falling through to model-based fallbacks")
except Exception as e_pyt_first:
print(f"[document_scanner] primary pytesseract attempt failed: {e_pyt_first}")
except Exception:
# pytesseract not installed in environment — continue to available backends
pass
# Last resort: return a safe mock that echoes inputs
# (Tesseract failed or is not installed — Q&A will still work via Granite Vision if image is saved)
return _mock_result(received={"image_url": image_url, "has_bytes": bool(image_bytes)})
|