File size: 6,107 Bytes
b9d34d8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Per-page extraction: text-layer when present, else render + OCR.

Implements the project contract exactly:
  DPI_MAX, DPI_FAST
  render_page_image(page, dpi) -> BGR uint8 ndarray
  extract_page(page, *, mode, lang, preprocess, min_chars=50) -> dict
"""

import numpy as np

from pipeline.preprocess import preprocess_image
from pipeline.ocr_engine import engine
from pipeline.textlayer import has_text_layer, extract_text_layer

# fitz is PyMuPDF; imported lazily-safe at module import.
import fitz  # type: ignore


DPI_MAX = 300
DPI_FAST = 150


def render_page_image(page, dpi: int) -> np.ndarray:
    """Render a fitz.Page to an HxWx3 BGR uint8 ndarray at the given DPI.

    Uses a pixmap with zoom = dpi/72. Handles RGB, grayscale and alpha
    channels, always returning a 3-channel BGR image (OpenCV/PaddleOCR
    convention).
    """
    zoom = float(dpi) / 72.0
    matrix = fitz.Matrix(zoom, zoom)
    # alpha=False keeps things simple, but some pages/pixmaps can still carry
    # channel counts other than 3; normalise defensively below.
    pix = page.get_pixmap(matrix=matrix, alpha=False)

    # Build an ndarray view from the pixmap samples.
    buf = np.frombuffer(pix.samples, dtype=np.uint8)
    n = pix.n  # bytes per pixel (channels)
    arr = buf.reshape(pix.height, pix.width, n)

    if n == 1:
        # Grayscale -> BGR
        bgr = np.repeat(arr, 3, axis=2)
    elif n == 3:
        # PyMuPDF emits RGB; convert to BGR for OpenCV/PaddleOCR.
        bgr = arr[:, :, ::-1]
    elif n == 4:
        # RGBA -> drop alpha, RGB -> BGR.
        rgb = arr[:, :, :3]
        bgr = rgb[:, :, ::-1]
    else:
        # CMYK or other exotic layouts: re-render forcing an RGB colorspace.
        pix = page.get_pixmap(matrix=matrix, alpha=False, colorspace=fitz.csRGB)
        buf = np.frombuffer(pix.samples, dtype=np.uint8)
        arr = buf.reshape(pix.height, pix.width, pix.n)
        bgr = arr[:, :, :3][:, :, ::-1]

    # Ensure a contiguous, owned uint8 array (the buffer view is read-only).
    return np.ascontiguousarray(bgr, dtype=np.uint8)


def extract_page(
    page,
    *,
    mode: str,
    lang: str,
    preprocess: bool,
    binarize: bool = False,
    handwriting: bool = False,
    online: bool = False,
    online_key: str = "",
    online_model: str = "",
    force_ocr: bool = False,
    min_chars: int = 50,
) -> dict:
    """Extract text from a single page.

    If the page has a usable text layer, return it directly (exact, so no
    confidence is reported). Otherwise render the page to an image, optionally
    preprocess (deskew + CLAHE + denoise), optionally binarize, and OCR it.

    ``preprocess`` and ``binarize`` are independent: ``preprocess`` enables the
    grayscale clean-up pipeline; ``binarize`` additionally Sauvola-thresholds
    the image (only meaningful when ``preprocess`` is on).

    Routing precedence for a page that needs reading: text-layer (unless
    ``force_ocr``) -> online Gemini (if ``online`` + key) -> local handwriting
    (if ``handwriting``) -> local OCR.

    Returns a uniform dict from EVERY branch:
    ``{"source": "text"|"ocr"|"online"|"handwriting", "text": str,
    "confidence": float|None, "lines": list|None}``. ``confidence`` and
    ``lines`` (per-line ``[{text, confidence}]``) are populated only for local
    ``"ocr"``; the other sources set both to ``None`` so consumers never have to
    guard for a missing key.
    """
    # "Force OCR" skips the text-layer shortcut so even born-digital pages are
    # rendered + OCR'd (or sent online). Use it when the embedded text is
    # garbled/wrong, or when text is trapped inside images on a text page.
    if not force_ocr and has_text_layer(page, min_chars=min_chars):
        return {
            "source": "text",
            "text": extract_text_layer(page),
            "confidence": None,
            "lines": None,
        }

    dpi = DPI_MAX if mode == "max" else DPI_FAST
    img = render_page_image(page, dpi)

    # Online vision path (opt-in): send the RAW page image to Google Gemini and
    # let it transcribe (handwriting + printed). Takes precedence over the local
    # OCR/handwriting engines for any page that needs OCR. The page bytes leave
    # this machine — gated on an explicit api key, never the default. Errors
    # propagate to the per-page handler with an actionable message.
    if online:
        from pipeline import online_ocr
        if online_ocr.is_configured(online_key):
            text = online_ocr.transcribe_image_bgr(
                img, api_key=online_key, model=(online_model or None)
            )
            return {"source": "online", "text": text, "confidence": None, "lines": None}

    # Handwriting path: detect lines with PaddleOCR, recognise with TrOCR.
    # Crops are taken from the natural render (no binarization, which TrOCR
    # dislikes). Slow on CPU; opt-in per document.
    if handwriting:
        from pipeline.handwriting import get_engine
        # Line DETECTION stays on the script-agnostic "en" DB detector (it finds
        # text regions regardless of script, and avoids pulling an extra
        # PaddleOCR language model). Only the RECOGNISER is language-aware.
        boxes = engine.detect_boxes(img, lang="en")
        hw = get_engine(lang=lang)
        text = hw.ocr_text(img, boxes)
        return {"source": "handwriting", "text": text, "confidence": None, "lines": None}

    if preprocess:
        img = preprocess_image(img, mode=mode, binarize=binarize)

    use_angle_cls = (mode == "max")
    lines = engine.ocr_lines(img, lang=lang, use_angle_cls=use_angle_cls)
    text = "\n".join(ln["text"] for ln in lines)
    conf = (sum(ln["confidence"] for ln in lines) / len(lines)) if lines else None
    # Per-line confidence so the UI can flag exactly which lines to double-check.
    # Order matches the joined text (one entry per "\n"-separated line).
    line_conf = [
        {"text": ln["text"], "confidence": round(float(ln["confidence"]), 4)}
        for ln in lines
    ]
    return {"source": "ocr", "text": text, "confidence": conf, "lines": line_conf}