Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install ".[dev]"

- name: Run tests
run: pytest -v --cov=docling_pk --cov-report=term-missing
71 changes: 9 additions & 62 deletions docling_pk/ocr.py
Original file line number Diff line number Diff line change
@@ -1,69 +1,16 @@
"""
OCR wrapper for docling-pk.

Wraps EasyOCR and returns plain text plus average confidence. The
reader is created once per process and reused. Languages are
configurable: CNIC needs English plus Urdu, but certificates are
almost entirely English text, and testing showed the combined en+ur
model introduces Arabic-script noise into English words that a
single-language reader does not. Default stays en+ur; pass
languages=("en",) for documents that do not need Urdu.

Phone photos are sometimes shot in the wrong orientation. Preprocessing
only corrects small skew, not full 90/180/270 rotations, so run_ocr
tries all four right-angle rotations and keeps whichever one EasyOCR
reads with the highest confidence times detection count.
"""
from typing import Iterable

import numpy as np
import easyocr

_reader = None
_reader_languages = [None]


def _get_reader(languages: tuple = ("en", "ur")) -> easyocr.Reader:
global _reader
if _reader is None or _reader_languages[0] != languages:
_reader = easyocr.Reader(list(languages), gpu=True)
_reader_languages[0] = languages
return _reader


def _ocr_once(reader: easyocr.Reader, image: np.ndarray) -> tuple[str, float, int]:
detections = reader.readtext(image)
if not detections:
return "", 0.0, 0

lines = [text for (_, text, _) in detections]
scores = [score for (_, _, score) in detections]
raw_text = "\n".join(lines)
avg_confidence = sum(scores) / len(scores)
return raw_text, avg_confidence, len(detections)


def run_ocr(image: np.ndarray, languages: tuple = ("en", "ur")) -> tuple[str, float]:
"""
Runs OCR on a preprocessed image, correcting 90-degree rotations.

Args:
image: single-channel numpy array, output of preprocessor.preprocess().
languages: EasyOCR language list. Use ("en",) for English-only
documents like certificates, which measurably reduces noise
compared to the default en+ur reader.
def run_ocr(image: np.ndarray, languages: Iterable[str] = ("en",)) -> tuple[str, float]:
import easyocr

Returns:
A tuple of (raw_text, average_confidence) from whichever of the
four right-angle rotations scored best.
"""
reader = _get_reader(languages)
best_text, best_confidence, best_score = "", 0.0, -1.0
reader = easyocr.Reader(list(languages), gpu=False)
results = reader.readtext(image)

for k in range(4):
rotated = np.rot90(image, k=k)
text, confidence, count = _ocr_once(reader, rotated)
score = confidence * count
if score > best_score:
best_text, best_confidence, best_score = text, confidence, score
text_parts = [item[1] for item in results]
confidences = [float(item[2]) for item in results]
avg_confidence = sum(confidences) / len(confidences) if confidences else 0.0

return best_text, best_confidence
return "\n".join(text_parts), avg_confidence
3 changes: 3 additions & 0 deletions docling_pk/parsers/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from . import cnic, intermediate, matric

__all__ = ["cnic", "matric", "intermediate"]
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
5 changes: 5 additions & 0 deletions docling_pk/parsers/_federal_board.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
def detect_document_type(raw_text: str) -> str:
text = (raw_text or "").upper()
if "INTERMEDIATE" in text:
return "intermediate"
return "matric"
20 changes: 20 additions & 0 deletions docling_pk/parsers/cnic.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import re

from docling_pk.schema import FieldResult


CNIC_PATTERN = re.compile(r"\b\d{5}-\d{7}-\d\b")
GENDER_PATTERN = re.compile(r"\b(?:gender\s*[:\-]?\s*)?(male|female|m|f)\b", re.IGNORECASE)


def extract(raw_text: str) -> dict[str, FieldResult]:
cnic_match = CNIC_PATTERN.search(raw_text or "")
gender_match = GENDER_PATTERN.search(raw_text or "")

cnic_value = cnic_match.group(0) if cnic_match else None
gender_value = gender_match.group(1).upper() if gender_match else None

return {
"cnic_number": FieldResult(value=cnic_value, confidence=0.95 if cnic_value else 0.0),
"gender": FieldResult(value=gender_value, confidence=0.9 if gender_value else 0.0),
}
2 changes: 2 additions & 0 deletions docling_pk/parsers/intermediate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
def extract(raw_text: str) -> dict:
return {}
2 changes: 2 additions & 0 deletions docling_pk/parsers/matric.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
def extract(raw_text: str) -> dict:
return {}
106 changes: 12 additions & 94 deletions docling_pk/preprocessor.py
Original file line number Diff line number Diff line change
@@ -1,106 +1,24 @@
"""
Image preprocessing for docling-pk.

Prepares a document photo for OCR. Order: grayscale, then blur check,
then deskew, then denoise. Adaptive thresholding was tested directly
(A/B test comparing thresholded output against plain grayscale on real
CNIC and certificate samples) and removed: it consistently lowered
EasyOCR's own confidence on every sample tested (CNIC 0.551 vs 0.759,
Matric 0.485 vs 0.660, Intermediate 0.481 vs 0.675, all worse with
thresholding). EasyOCR's detector is trained on natural photos, not
pre-binarized ones, so feeding it our own black-and-white conversion
worked against it rather than for it. This also matches the earlier
finding on the blurry sample, where thresholding turned soft edges into
hollow, unreadable outlines.
"""

import cv2
import numpy as np
from pathlib import Path

BLUR_VARIANCE_THRESHOLD = 35.0
import cv2


class TooBlurryError(ValueError):
"""Raised when an image is too blurry to reliably OCR."""


def preprocess(image_path: str) -> np.ndarray:
"""
Prepares a document image for OCR.

Steps:
1. Load and validate
2. Grayscale
3. Blur check
4. Deskew
5. Denoise

Args:
image_path: path to the input image file.
"""Raised when an image is too blurry for reliable extraction."""

Returns:
A single-channel numpy array ready for OCR.

Raises:
FileNotFoundError: if image_path does not exist.
ValueError: if the file cannot be read as an image.
TooBlurryError: if the image is too blurry to reliably OCR.
"""
def preprocess(image_path: str):
path = Path(image_path)
if not path.exists():
raise FileNotFoundError(f"Image not found: {image_path}")

img = cv2.imread(str(path))
if img is None:
raise ValueError(f"Could not read image: {image_path}. Check the file is a valid PNG/JPG.")

gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
_check_blur(gray)

deskewed = _deskew(gray)
denoised = cv2.fastNlMeansDenoising(deskewed, h=10)
return denoised


def _check_blur(gray: np.ndarray) -> None:
"""
Measures sharpness using the variance of the Laplacian. Threshold of
35 picked from 4 real samples: two failing blurry images scored 12.9
and 27.1, two working images scored 48.2 and 54.9. Small sample,
should be recalibrated as more images are tested.
"""
variance = cv2.Laplacian(gray, cv2.CV_64F).var()
if variance < BLUR_VARIANCE_THRESHOLD:
raise TooBlurryError(
f"Image too blurry to process (sharpness score {variance:.1f}, "
f"minimum {BLUR_VARIANCE_THRESHOLD}). Please retake the photo."
)


def _deskew(gray: np.ndarray) -> np.ndarray:
"""
Estimates and corrects rotation using the minimum-area bounding box
of dark pixels. Known limitation, confirmed by direct testing: this
fails against cluttered or textured backgrounds (fabric, patterned
surfaces), since the angle is estimated from every dark pixel in the
frame, not the document specifically. v1 assumes a plain background.
"""
inverted = cv2.bitwise_not(gray)
_, binary = cv2.threshold(inverted, 0, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU)
coords = np.column_stack(np.where(binary > 0))
raise FileNotFoundError(f"Image path does not exist: {image_path}")

if coords.shape[0] < 50:
return gray
image = cv2.imread(str(path))
if image is None:
raise ValueError(f"Invalid image file: {image_path}")

angle = cv2.minAreaRect(coords)[-1]
if angle < -45:
angle = -(90 + angle)
else:
angle = -angle
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
sharpness = float(cv2.Laplacian(gray, cv2.CV_64F).var())
if sharpness < 25.0:
raise TooBlurryError("Image is too blurry to process reliably")

(h, w) = gray.shape
center = (w // 2, h // 2)
matrix = cv2.getRotationMatrix2D(center, angle, 1.0)
rotated = cv2.warpAffine(gray, matrix, (w, h), flags=cv2.INTER_CUBIC, borderMode=cv2.BORDER_REPLICATE)
return rotated
return image
Loading