Spaces:
Running
Running
File size: 8,407 Bytes
06bd65a 9156b57 06bd65a 9156b57 06bd65a | 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 | """
ocr_engine.py
βββββββββββββ
PaddleOCR inference service with:
- Angle classifier enabled (handles rotated/upside-down text lines)
- Confidence-aware result passing (raw boxes + scores to the formatter)
- Tesseract fallback for low-confidence pages
"""
import os
import asyncio
from typing import Dict, List, Optional, Tuple
import numpy as np
# Disable GPU and CPU MKL-DNN optimizations to prevent container segmentation faults
os.environ["CUDA_VISIBLE_DEVICES"] = "-1"
os.environ["FLAGS_use_mkldnn"] = "0"
os.environ["FLAGS_use_xdnn"] = "0"
os.environ["OMP_NUM_THREADS"] = "1"
from paddleocr import PaddleOCR
from src.config import settings
# Threshold: if average page confidence falls below this, run Tesseract fallback
TESS_FALLBACK_THRESHOLD = 0.65
class OCREngineService:
def __init__(self):
# In-memory cache of instantiated PaddleOCR engines keyed by language
self._engines: Dict[str, PaddleOCR] = {}
# Mutex lock to serialize CPU inference and avoid resource thrashing
self._lock = asyncio.Lock()
def get_engine(self, lang: str) -> PaddleOCR:
"""Retrieves or initialises a PaddleOCR engine for the given language."""
lang = lang.lower().strip()
if lang not in settings.SUPPORTED_LANGUAGES:
lang = settings.DEFAULT_LANGUAGE
if lang not in self._engines:
self._engines[lang] = PaddleOCR(
use_angle_cls=True, # Detects 0Β°/180Β° text orientation (fixes upside-down lines)
lang=lang,
show_log=False,
enable_mkldnn=False,
det_limit_side_len=3000, # Preserve high-res details for tiny text (default 960)
det_db_box_thresh=0.4, # Lower threshold β detect faint/thin boxes (was 0.5)
det_db_score_mode="slow", # Accurate boundary evaluation (default 'fast')
rec_db_score_mode="slow",
use_space_char=True, # Recognise spaces between words
cls_thresh=0.9, # High bar for angle flip β only flip when very confident
)
return self._engines[lang]
async def _detect_language(self, img_np: np.ndarray, loop) -> str:
"""
Uses Tesseract OSD to detect the writing system/script, and maps it to a supported language.
"""
try:
import pytesseract
import cv2
import re
# Tesseract works best on grayscale
gray = cv2.cvtColor(img_np, cv2.COLOR_RGB2GRAY)
osd_data = await loop.run_in_executor(
None,
lambda: pytesseract.image_to_osd(gray, config="--psm 0")
)
# Parse Script Name (e.g. 'Script: Latin', 'Script: Arabic', etc.)
match = re.search(r"Script:\s+(\w+)", osd_data)
if match:
script = match.group(1).lower().strip()
# Map script to PaddleOCR language code
script_map = {
"latin": "en", # Latin alphabet covers English, Spanish, Portuguese, French, German, Italian
"devanagari": "hi", # Hindi
"arabic": "ar", # Arabic
"han": "ch", # Chinese (Simplified)
"cyrillic": "ru", # Russian
"telugu": "te", # Telugu
"tamil": "ta", # Tamil
"japanese": "japan", # Japanese
"korean": "korean" # Korean
}
detected = script_map.get(script, "en")
print(f"OSD detected script '{script}', mapped to language code '{detected}'")
return detected
except Exception as e:
print(f"OSD language detection failed: {e}. Falling back to default 'en'.")
return "en"
async def run_ocr_raw(self, img_np: np.ndarray, lang: str):
"""
Runs PaddleOCR and returns the raw result list (bounding boxes + text + confidence).
The caller (routes.py) passes this to the markdown formatter.
"""
async with self._lock:
loop = asyncio.get_running_loop()
# Resolve auto language detection if requested
if lang.lower().strip() in ("auto", "detect"):
resolved_lang = await self._detect_language(img_np, loop)
else:
resolved_lang = lang
ocr = self.get_engine(resolved_lang)
raw = await loop.run_in_executor(
None,
lambda: ocr.ocr(img_np, cls=True)
)
# raw is [[result_page1], ...] β we only handle single-page images
page = raw[0] if raw else []
# Optionally run Tesseract fallback if average confidence is low
if _should_run_tesseract(page):
tess_lines = await _run_tesseract_fallback(img_np, loop)
page = _merge_tesseract_lines(page, tess_lines)
return page
# βββββββββββββββββββββββββββββββββββββββββββββ
# Tesseract fallback helpers
# βββββββββββββββββββββββββββββββββββββββββββββ
def _should_run_tesseract(page_result) -> bool:
"""
Returns True if the average PaddleOCR confidence score for the page
is below TESS_FALLBACK_THRESHOLD, indicating the page may have zones
of text that PaddleOCR struggled with.
"""
if not page_result:
return True # Empty result β definitely try fallback
scores = [item[1][1] for item in page_result if item and item[1]]
if not scores:
return True
avg_conf = sum(scores) / len(scores)
return avg_conf < TESS_FALLBACK_THRESHOLD
async def _run_tesseract_fallback(img_np: np.ndarray, loop) -> List[str]:
"""
Runs pytesseract in sparse-text mode as a fallback for difficult pages.
psm 11 = Sparse text. Find as much text as possible in no particular order.
oem 1 = LSTM neural net engine only.
"""
try:
import pytesseract
import cv2
# Tesseract works best on grayscale
gray = cv2.cvtColor(img_np, cv2.COLOR_RGB2GRAY)
config = "--oem 1 --psm 11"
text = await loop.run_in_executor(
None,
lambda: pytesseract.image_to_string(gray, config=config, lang="eng")
)
# Split into lines, strip whitespace, drop empty
lines = [l.strip() for l in text.splitlines() if l.strip()]
return lines
except ImportError:
# pytesseract not installed β silently skip
return []
except Exception as e:
print(f"Tesseract fallback failed: {e}")
return []
def _merge_tesseract_lines(paddle_page, tess_lines: List[str]):
"""
Merge Tesseract-recovered lines into the PaddleOCR result.
Strategy: collect all text already captured by PaddleOCR, then
append any Tesseract lines that are not already present as a
'synthetic' low-priority item (no bounding box, confidence 0.60).
This ensures the formatter sees them without duplicating content.
"""
if not tess_lines:
return paddle_page
existing_texts = set()
if paddle_page:
for item in paddle_page:
if item and item[1]:
existing_texts.add(item[1][0].strip().lower())
merged = list(paddle_page) if paddle_page else []
for line in tess_lines:
normalised = line.strip().lower()
if normalised and normalised not in existing_texts:
# Synthetic entry: no real bounding box β place at the bottom of the page
# The formatter handles this gracefully (falls back to plain text for lines without boxes)
merged.append([
[[0, 9999], [100, 9999], [100, 10000], [0, 10000]],
(line, 0.60)
])
existing_texts.add(normalised)
return merged
# Global singleton
ocr_service = OCREngineService()
|