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
22 changes: 17 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,31 @@ on:
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.9", "3.10", "3.11", "3.12"]
fail-fast: false

steps:
- name: Check out repository
uses: actions/checkout@v4

- name: Set up Python
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: "3.11"
python-version: ${{ matrix.python-version }}

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install ".[dev]"
pip install -r requirements.txt

- name: Run tests with coverage
run: pytest -v --cov=docling_pk --cov-report=term-missing --cov-report=xml

- name: Run tests
run: pytest -v --cov=docling_pk --cov-report=term-missing
- name: Upload coverage report
uses: actions/upload-artifact@v4
with:
name: coverage-report-py${{ matrix.python-version }}
path: coverage.xml
if: always()
10 changes: 8 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
*.docx
.__pycache__/
.vscode/
__pycache__/
.vscode/
*.pyc
venv/
*.egg-info/
.pytest_cache/
.coverage
coverage.xml
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,4 +75,4 @@ alone.

## License

TBD
This project is licensed under the MIT License. See [LICENSE](LICENSE) for the full text.
1 change: 1 addition & 0 deletions docling_pk/extractor.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from docling_pk.parsers import cnic, matric, intermediate
from docling_pk.parsers._federal_board import detect_document_type
from docling_pk.schema import DocumentResult, FieldResult
from __future__ import annotations

SUPPORTED_TYPES = ("cnic", "matric", "intermediate")

Expand Down
67 changes: 58 additions & 9 deletions docling_pk/ocr.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,65 @@
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, certificates read better
English-only since the combined model introduces Arabic-script noise
into English words.

Phone photos are sometimes shot in the wrong orientation. run_ocr
tries all four right-angle rotations and keeps whichever one EasyOCR
reads with the highest confidence times detection count.
"""

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 from preprocessor.preprocess().
languages: EasyOCR language list. Use ("en",) for English-only
documents like certificates.

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
3 changes: 0 additions & 3 deletions docling_pk/parsers/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +0,0 @@
from . import cnic, intermediate, matric

__all__ = ["cnic", "matric", "intermediate"]
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.
171 changes: 169 additions & 2 deletions docling_pk/parsers/_federal_board.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,172 @@
"""
Shared field extraction for Federal Board of Intermediate and Secondary
Education (Islamabad) certificates.

Digit matching restricted to ASCII 0-9 explicitly, not \\d, since \\d and
int() both silently accept Unicode digits (Arabic-Indic included),
which showed up in real testing as a silently wrong number instead of
a correctly failed match.

Label patterns are kept loose since testing found labels misread in
ways that keep breaking stricter patterns ("Certiiicate", "Cerlificate",
"erlific3te"). Letter-for-digit misreads (O/o/l/I for 0/0/1/1) are
corrected inside number tokens, confirmed necessary on a real roll
number read as "1217O6" instead of "121706". The name/father-name
anchor tolerates common misspellings of "daughter" found across real
samples. The TOTAL row search stops at an all-caps subject-name line
(a hard boundary), confirmed necessary on a real sample where the true
total-possible value drifted far from the label and the search
otherwise crossed into a subject row and wrongly grabbed that
subject's own marks instead.
"""

import re
from docling_pk.schema import FieldResult

SERIAL_LABEL = re.compile(r"ser\w*\s*n[oa]", re.IGNORECASE)
CERTIFICATE_LABEL = re.compile(r"cer\w*\s*n[oa]", re.IGNORECASE)
ROLL_LABEL = re.compile(r"roll\s*n[oa]", re.IGNORECASE)
REGISTRATION_LABEL = re.compile(r"reg\w*\s*n[oa]", re.IGNORECASE)
GROUP_KEYWORDS = re.compile(r"\b(SCIENCE(?:\s+GENERAL)?|ARTS|COMMERCE|HUMANITIES)\b", re.IGNORECASE)
SESSION_YEAR = re.compile(r"annual\W*([0-9]{4})", re.IGNORECASE)
GRADE = re.compile(r"grade\s+([A-Z])\b", re.IGNORECASE)
DAUGHTER_OF_ANCHOR = re.compile(r"d[ao]ugh\w*\s*[o0]f", re.IGNORECASE)
DOB = re.compile(r"date of birth is\s+([0-9./-]+)", re.IGNORECASE)
TOTAL_LABEL = re.compile(r"\btotal\b", re.IGNORECASE)
NUMBER_RUN = re.compile(r"[0-9][0-9OolI./\s]{2,}[0-9]|[0-9]{3,}")
HARD_BOUNDARY = re.compile(r"^[A-Z\s]{4,}$")

SKIP_LINE = re.compile(r"certif|cerif|^that$|board|education|islamabad|secondary|intermediate", re.IGNORECASE)


def _clean_number(raw: str) -> str:
corrected = raw.translate(str.maketrans("OolI", "0011"))
return re.sub(r"[^0-9/]", "", corrected)


def _find_number_near_label(lines: list[str], label_pattern: re.Pattern, window: int = 2) -> FieldResult:
for i, line in enumerate(lines):
if not label_pattern.search(line):
continue

same_line = label_pattern.sub("", line)
match = NUMBER_RUN.search(same_line)
if match:
return FieldResult(value=_clean_number(match.group()), confidence=0.75)

offsets = []
for d in range(1, window + 1):
offsets.append(d)
offsets.append(-d)
for offset in offsets:
j = i + offset
if 0 <= j < len(lines):
match = NUMBER_RUN.search(lines[j])
if match:
return FieldResult(value=_clean_number(match.group()), confidence=0.65)
return FieldResult(value=None, confidence=0.0)

return FieldResult(value=None, confidence=0.0)


def _find_name_pair(lines: list[str]) -> tuple[FieldResult, FieldResult]:
anchor_idx = None
for i, line in enumerate(lines):
if DAUGHTER_OF_ANCHOR.search(line):
anchor_idx = i
break

if anchor_idx is None:
return FieldResult(value=None, confidence=0.0), FieldResult(value=None, confidence=0.0)

name = FieldResult(value=None, confidence=0.0)
for offset in (1, 2, 3):
j = anchor_idx - offset
if j < 0:
break
candidate = lines[j].strip(" '.-")
if len(candidate) >= 5 and not SKIP_LINE.search(candidate):
name = FieldResult(value=candidate, confidence=0.6)
break

father_name = FieldResult(value=None, confidence=0.0)
for offset in (1, 2):
j = anchor_idx + offset
if j >= len(lines):
break
candidate = lines[j].strip(" '.-")
if len(candidate) >= 5 and not SKIP_LINE.search(candidate):
father_name = FieldResult(value=candidate, confidence=0.6)
break

return name, father_name


def _find_total(lines: list[str]) -> tuple[FieldResult, FieldResult]:
for i, line in enumerate(lines):
if not TOTAL_LABEL.search(line):
continue

numbers = []
misses = 0
for offset in range(1, 6):
j = i - offset
if j < 0:
break
candidate = lines[j].strip()
if HARD_BOUNDARY.match(candidate):
break
match = NUMBER_RUN.search(candidate)
if match:
cleaned = _clean_number(match.group())
if cleaned:
numbers.append(int(cleaned))
misses = 0
else:
misses += 1
if misses >= 2:
break
if len(numbers) == 2:
break

if len(numbers) == 2:
possible, obtained = max(numbers), min(numbers)
return (
FieldResult(value=str(possible), confidence=0.7),
FieldResult(value=str(obtained), confidence=0.7),
)
return FieldResult(value=None, confidence=0.0), FieldResult(value=None, confidence=0.0)

return FieldResult(value=None, confidence=0.0), FieldResult(value=None, confidence=0.0)


def extract_common_fields(raw_text: str) -> dict[str, FieldResult]:
lines = raw_text.splitlines()

fields = {
"serial_number": _find_number_near_label(lines, SERIAL_LABEL),
"certificate_number": _find_number_near_label(lines, CERTIFICATE_LABEL),
"roll_number": _find_number_near_label(lines, ROLL_LABEL),
"registration_number": _find_number_near_label(lines, REGISTRATION_LABEL),
}

group_match = GROUP_KEYWORDS.search(raw_text)
fields["group"] = FieldResult(value=group_match.group(1).upper(), confidence=0.75) if group_match else FieldResult(value=None, confidence=0.0)

year_match = SESSION_YEAR.search(raw_text)
fields["session_year"] = FieldResult(value=year_match.group(1), confidence=0.8) if year_match else FieldResult(value=None, confidence=0.0)

grade_match = GRADE.search(raw_text)
fields["grade"] = FieldResult(value=grade_match.group(1), confidence=0.6) if grade_match else FieldResult(value=None, confidence=0.0)

fields["name"], fields["father_name"] = _find_name_pair(lines)
fields["total_marks_possible"], fields["total_marks_obtained"] = _find_total(lines)
fields["institute"] = FieldResult(value=None, confidence=0.0)

return fields


def detect_document_type(raw_text: str) -> str:
text = (raw_text or "").upper()
if "INTERMEDIATE" in text:
if re.search(r"higher\s+secondary\s+school\s+certificate", raw_text, re.IGNORECASE):
return "intermediate"
return "matric"
Loading
Loading