From 01fcaa4d0a8cfcfffd1de4ef94ee11b746d73d21 Mon Sep 17 00:00:00 2001 From: Abdul Moiz Muhammad Date: Tue, 8 Sep 2026 11:41:59 +0500 Subject: [PATCH] Restore tested preprocessor, OCR, and CNIC parser logic overwritten by PR #9 --- docling_pk/ocr.py | 73 +++++++-- .../__pycache__/__init__.cpython-312.pyc | Bin 281 -> 0 bytes .../parsers/__pycache__/cnic.cpython-312.pyc | Bin 1246 -> 0 bytes .../__pycache__/intermediate.cpython-312.pyc | Bin 330 -> 0 bytes .../__pycache__/matric.cpython-312.pyc | Bin 324 -> 0 bytes docling_pk/parsers/cnic.py | 149 ++++++++++++++++-- docling_pk/preprocessor.py | 104 ++++++++++-- tests/test_placeholder.py | 10 -- 8 files changed, 295 insertions(+), 41 deletions(-) delete mode 100644 docling_pk/parsers/__pycache__/__init__.cpython-312.pyc delete mode 100644 docling_pk/parsers/__pycache__/cnic.cpython-312.pyc delete mode 100644 docling_pk/parsers/__pycache__/intermediate.cpython-312.pyc delete mode 100644 docling_pk/parsers/__pycache__/matric.cpython-312.pyc delete mode 100644 tests/test_placeholder.py diff --git a/docling_pk/ocr.py b/docling_pk/ocr.py index 5f51215..efffc69 100644 --- a/docling_pk/ocr.py +++ b/docling_pk/ocr.py @@ -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 \ No newline at end of file diff --git a/docling_pk/parsers/__pycache__/__init__.cpython-312.pyc b/docling_pk/parsers/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 780ab0bc705b7c15eb446f23415e6de197d4ca88..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 281 zcmZuryH3ME5Zv`S1Ok!x1j<+f?gtPhB`uPhmE}8`gS)e}c7#&$6*N?IdilB8Y5z2#mLx#$L>({8odJqxW_dV9|Ma(4W- WKNR{>i1-GJS6IByj_;?uC*c;3Oh^#` diff --git a/docling_pk/parsers/__pycache__/cnic.cpython-312.pyc b/docling_pk/parsers/__pycache__/cnic.cpython-312.pyc deleted file mode 100644 index 81969ba410c53b1ed7ed3f2ba0f2c11311c86377..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1246 zcma)5OK1~Y7(Vk%nzrg>Kx=#!D`dnrq7_BNTyJl#s3liY5lvG|GUp^soJ_)*iH|yk zEUw^P8CSgv7vZj|b>UKXcBkBXF(HNMu^SN=f=HyUTzO8C@r8mv%>VfQ*E#=x=HLE) zAE0!>x|Lhv0eHy_m*lRpTA|83fBT%M2f9N+;0IXBMDqY?^>T*r)yKa3G+ zZoB&ziBw&r=U^0dll9XNc0ovx4N04^T1|Un{o{Oh8%F2v*?&0=Z<5Jvqd-PCo_15 z!-~V@JZ|1+33<1!$w4qDI6U6w@ZXL6w2)lS=X_UN>?-XlqTTD_j^GHGha$zLyRy5O zFZ`k-QorYGKlI+>CxF%yB>=NszGm~wq}%j1oNoa7{u}Ji{NbD0XWpJTX zBvPqZL>RN(XcA_lr`=fDeA1>_eVwXZtK;dM$@R1?i*0>7qYuA^W(s)}!X?v0ICQCi zFN9!0H?n4CxcEJFnxe%dwh*>LtnocR5(i{Rc$ps5VjmBH%ZvNCjEET4Eb`b*_- zQ{7otcUF@PHP}=~>gvd{y5}J*kH43}z>bB$W8ackJ3G0&A+h4$QXO9!e=t!yb8gum ze-EK0|+!_8f^vFXW3^1L1xeOMJ(VMgNFJ#*R)z z@LC24mW_!Bv)%jM26%wNcs*qwyod7zSTfKNyq%itU05;7S^~#$&q2>CptWSME^r;+ znEh+E76>)g?kV>@RW?=p^MRXz+UApwVo#@aj;W2sIT1A*QjIXRVX9e4n?GVlUC3on4O z@&YFvQzx9N-LRDJ``L~ye`VPraCGkD>4N*g%U;x1g6!*#`uj(#Y%3Ot% zip9+1iw`40-%(wP{WyDM$!{33tii%9ZJKg! z>xZ-UKbEyy7`ZEKHIp|OR>^L81=ds^Eud{45~^}q3voejI zm-WM0`ybA@+7{$2IaL*&N`2lg! 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), + } \ No newline at end of file diff --git a/docling_pk/preprocessor.py b/docling_pk/preprocessor.py index bd983ce..17d966f 100644 --- a/docling_pk/preprocessor.py +++ b/docling_pk/preprocessor.py @@ -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 \ No newline at end of file diff --git a/tests/test_placeholder.py b/tests/test_placeholder.py deleted file mode 100644 index e11fa75..0000000 --- a/tests/test_placeholder.py +++ /dev/null @@ -1,10 +0,0 @@ -""" -Placeholder test so pytest has something to collect. pytest exits with -code 5 when zero tests exist at all, which is what happens with no test -suite merged yet. This gets replaced once tests/test_cnic.py (the -Integration Engineer's Week 2 deliverable) lands as its own PR. -""" - - -def test_placeholder(): - assert True \ No newline at end of file