diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 396e2c0..4900b39 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 \ No newline at end of file diff --git a/docling_pk/ocr.py b/docling_pk/ocr.py index c42f0b5..5f51215 100644 --- a/docling_pk/ocr.py +++ b/docling_pk/ocr.py @@ -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 \ No newline at end of file + return "\n".join(text_parts), avg_confidence diff --git a/docling_pk/parsers/__init__.py b/docling_pk/parsers/__init__.py new file mode 100644 index 0000000..bcc5cba --- /dev/null +++ b/docling_pk/parsers/__init__.py @@ -0,0 +1,3 @@ +from . import cnic, intermediate, matric + +__all__ = ["cnic", "matric", "intermediate"] diff --git a/docling_pk/parsers/__pycache__/__init__.cpython-312.pyc b/docling_pk/parsers/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..780ab0b Binary files /dev/null and b/docling_pk/parsers/__pycache__/__init__.cpython-312.pyc differ diff --git a/docling_pk/parsers/__pycache__/cnic.cpython-312.pyc b/docling_pk/parsers/__pycache__/cnic.cpython-312.pyc new file mode 100644 index 0000000..81969ba Binary files /dev/null and b/docling_pk/parsers/__pycache__/cnic.cpython-312.pyc differ diff --git a/docling_pk/parsers/__pycache__/intermediate.cpython-312.pyc b/docling_pk/parsers/__pycache__/intermediate.cpython-312.pyc new file mode 100644 index 0000000..12e2324 Binary files /dev/null and b/docling_pk/parsers/__pycache__/intermediate.cpython-312.pyc differ diff --git a/docling_pk/parsers/__pycache__/matric.cpython-312.pyc b/docling_pk/parsers/__pycache__/matric.cpython-312.pyc new file mode 100644 index 0000000..2c66430 Binary files /dev/null and b/docling_pk/parsers/__pycache__/matric.cpython-312.pyc differ diff --git a/docling_pk/parsers/_federal_board.py b/docling_pk/parsers/_federal_board.py new file mode 100644 index 0000000..049e0fa --- /dev/null +++ b/docling_pk/parsers/_federal_board.py @@ -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" diff --git a/docling_pk/parsers/cnic.py b/docling_pk/parsers/cnic.py new file mode 100644 index 0000000..a8282f5 --- /dev/null +++ b/docling_pk/parsers/cnic.py @@ -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), + } diff --git a/docling_pk/parsers/intermediate.py b/docling_pk/parsers/intermediate.py new file mode 100644 index 0000000..4ee77f9 --- /dev/null +++ b/docling_pk/parsers/intermediate.py @@ -0,0 +1,2 @@ +def extract(raw_text: str) -> dict: + return {} diff --git a/docling_pk/parsers/matric.py b/docling_pk/parsers/matric.py new file mode 100644 index 0000000..4ee77f9 --- /dev/null +++ b/docling_pk/parsers/matric.py @@ -0,0 +1,2 @@ +def extract(raw_text: str) -> dict: + return {} diff --git a/docling_pk/preprocessor.py b/docling_pk/preprocessor.py index 087f99f..bd983ce 100644 --- a/docling_pk/preprocessor.py +++ b/docling_pk/preprocessor.py @@ -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 \ No newline at end of file + return image