Skip to content
Closed
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
73 changes: 64 additions & 9 deletions docling_pk/ocr.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,71 @@
from typing import Iterable
"""
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. Confirmed
necessary on a real sample shot in portrait against a landscape card:
unusable output before this fix, correct output after.
"""

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.

def run_ocr(image: np.ndarray, languages: Iterable[str] = ("en",)) -> tuple[str, float]:
import easyocr
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.

reader = easyocr.Reader(list(languages), gpu=False)
results = reader.readtext(image)
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

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

return "\n".join(text_parts), avg_confidence
return best_text, best_confidence
Binary file removed docling_pk/parsers/__pycache__/__init__.cpython-312.pyc
Binary file not shown.
Binary file removed docling_pk/parsers/__pycache__/cnic.cpython-312.pyc
Binary file not shown.
Binary file not shown.
Binary file removed docling_pk/parsers/__pycache__/matric.cpython-312.pyc
Binary file not shown.
149 changes: 139 additions & 10 deletions docling_pk/parsers/cnic.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,149 @@
import re
"""
CNIC field extraction.

Labels are matched loosely since real OCR misreads them in small,
specific ways ("Gender" as "GenUer", "of" as "o{"/"o/", "Father Name"
as "Fathgr Name"/"Father Namg"). Dates and cnic_number use format-based
matching instead of label matching, since testing showed date labels
and values are read as two separate groups, not adjacent lines.

Digit matching is restricted to ASCII 0-9 explicitly, not the regex \\d
shorthand. Python's \\d and int() both silently accept Unicode digits
(Arabic-Indic included), so a garbled OCR read could otherwise parse
as a different, wrong number with full confidence instead of correctly
failing to match.

Gender's search window was widened after testing with the adaptive
threshold step removed (see preprocessor.py): with cleaner OCR text,
the M/F value can appear further from its label than a 1-line window
would catch.
"""

import re
from typing import Optional
from docling_pk.schema import FieldResult

CNIC_PATTERN = re.compile(r"\b([0-9]{5}-[0-9]{7}-[0-9])\b")
DATE_PATTERN = re.compile(r"\b([0-9]{2})[.,]([0-9]{2})[.,]([0-9]{4})\b")

FIELD_NAMES = [
"name", "father_name", "cnic_number",
"date_of_birth", "date_of_issue", "date_of_expiry",
"gender", "address",
]

FATHER_NAME_LABEL = re.compile(r"fath\w*", re.IGNORECASE)
NAME_LABEL = re.compile(r"\bname\b", re.IGNORECASE)
GENDER_LABEL = re.compile(r"gen.er", re.IGNORECASE)
ADDRESS_LABEL = re.compile(r"address", re.IGNORECASE)


def extract_cnic_number(raw_text: str) -> FieldResult:
"""
Extracts the CNIC number (format XXXXX-XXXXXXX-X). OCR sometimes
reads the dash as an underscore, colon, or backtick; all are
normalized before matching.
"""
normalized = raw_text.replace("_", "-").replace(":", "-").replace("`", "-")
match = CNIC_PATTERN.search(normalized)
if match:
return FieldResult(value=match.group(1), confidence=0.92)
return FieldResult(value=None, confidence=0.0)


def _value_after_label(lines: list[str], label_pattern: re.Pattern, exclude: re.Pattern = None) -> Optional[tuple[str, int]]:
for i, line in enumerate(lines):
if exclude and exclude.search(line):
continue
if label_pattern.search(line):
return line, i
return None


def _text_value(lines: list[str], label_pattern: re.Pattern, exclude: re.Pattern = None) -> FieldResult:
found = _value_after_label(lines, label_pattern, exclude)
if found is None:
return FieldResult(value=None, confidence=0.0)

line, idx = found
same_line = label_pattern.sub("", line).strip(" :-")
if same_line:
return FieldResult(value=same_line, confidence=0.75)

if idx + 1 < len(lines) and lines[idx + 1].strip():
return FieldResult(value=lines[idx + 1].strip(), confidence=0.65)

return FieldResult(value=None, confidence=0.0)


def _all_dates(raw_text: str) -> list[str]:
matches = DATE_PATTERN.findall(raw_text)
return [f"{d}.{m}.{y}" for (d, m, y) in matches]


def _dates_by_position(raw_text: str) -> tuple[FieldResult, FieldResult, FieldResult]:
"""
Assigns dates by order of appearance, not proximity to a label.
NADRA prints birth, issue, expiry in that fixed order, and testing
shows EasyOCR reads the value block in that order even when
separated from the labels.
"""
dates = _all_dates(raw_text)
results = []
for i in range(3):
if i < len(dates):
results.append(FieldResult(value=dates[i], confidence=0.8))
else:
results.append(FieldResult(value=None, confidence=0.0))
return tuple(results)


def extract_gender(raw_text: str) -> FieldResult:
"""
Extracts gender as 'M' or 'F'. Searches up to 4 lines after the
label, not just the next line, since cleaner OCR (no adaptive
threshold) places the value further from the label than before.
"""
lines = raw_text.splitlines()
found = _value_after_label(lines, GENDER_LABEL)
if found is None:
return FieldResult(value=None, confidence=0.0)

line, idx = found
remainder = GENDER_LABEL.sub("", line).strip(" :-")
match = re.search(r"\b([MF])\b", remainder.upper())
if match:
return FieldResult(value=match.group(1), confidence=0.8)

for offset in range(1, 5):
j = idx + offset
if j >= len(lines):
break
match = re.search(r"\b([MF])\b", lines[j].strip().upper())
if match:
return FieldResult(value=match.group(1), confidence=0.7)

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)
return FieldResult(value=None, confidence=0.0)


def extract(raw_text: str) -> dict[str, FieldResult]:
cnic_match = CNIC_PATTERN.search(raw_text or "")
gender_match = GENDER_PATTERN.search(raw_text or "")
"""
Extracts all CNIC fields from raw OCR text. Missing fields return
FieldResult(None, 0.0) rather than raising.
"""
lines = raw_text.splitlines()

cnic_value = cnic_match.group(0) if cnic_match else None
gender_value = gender_match.group(1).upper() if gender_match else None
father_name = _text_value(lines, FATHER_NAME_LABEL)
name = _text_value(lines, NAME_LABEL, exclude=FATHER_NAME_LABEL)
dob, issue, expiry = _dates_by_position(raw_text)

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),
}
"name": name,
"father_name": father_name,
"cnic_number": extract_cnic_number(raw_text),
"date_of_birth": dob,
"date_of_issue": issue,
"date_of_expiry": expiry,
"gender": extract_gender(raw_text),
"address": _text_value(lines, ADDRESS_LABEL),
}
104 changes: 92 additions & 12 deletions docling_pk/preprocessor.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,104 @@
from pathlib import Path
"""
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.
"""

import cv2
import numpy as np
from pathlib import Path

BLUR_VARIANCE_THRESHOLD = 35.0


class TooBlurryError(ValueError):
"""Raised when an image is too blurry for reliable extraction."""
"""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.

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

def preprocess(image_path: str):
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.
"""
path = Path(image_path)
if not path.exists():
raise FileNotFoundError(f"Image path does not exist: {image_path}")
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))

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

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")
angle = cv2.minAreaRect(coords)[-1]
if angle < -45:
angle = -(90 + angle)
else:
angle = -angle

return image
(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
10 changes: 0 additions & 10 deletions tests/test_placeholder.py

This file was deleted.

Loading