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()