diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..f3d4de8 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +* text=auto eol=lf +*.png binary +*.jpg binary +*.jpeg binary +*.pdf binary diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 9c9f075..4957783 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,18 +1,24 @@ ## What does this PR do? -[One sentence: what does this PR add, fix, or change?] - -## Which module(s) does it touch? -[List the files changed and why each was changed] - -## How did you test it? -[What tests cover this? Run: pytest and paste the result summary] - -## Edge cases handled: -[What inputs could break this? How did you handle them?] - -## Checklist: -- [ ] Tests added or updated -- [ ] Docstrings on all new public functions -- [ ] No unused imports -- [ ] README updated if public API changed -- [ ] Lead Engineer has reviewed and approved before submitting to director \ No newline at end of file + + +## Modules touched + + +## How was it tested? + + +## Benchmark impact + + +## Edge cases handled + + +## Checklist +- [ ] `ruff check`, `ruff format --check`, `mypy src` pass +- [ ] Tests added or updated; `pytest` passes +- [ ] No field can come back `ok` with a guessed value (fallbacks are `low_confidence` with a reason) +- [ ] Docstrings on new public functions (parameters, returns, example) +- [ ] README / CHANGELOG updated if behaviour or the public API changed +- [ ] **No real documents or personal data** in code, fixtures, logs or this description diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7e962ef..0756eef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,36 +4,128 @@ on: push: branches: [main] pull_request: - branches: [main] + workflow_dispatch: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true jobs: - test: + lint: + name: Lint and type-check runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: pip + - run: python -m pip install --upgrade pip + - run: pip install -e ".[dev]" + - run: ruff check src tests benchmarks + - run: ruff format --check src tests benchmarks + - run: mypy src + + test: + name: Tests (Python ${{ matrix.python-version }}, ${{ matrix.os }}) + runs-on: ${{ matrix.os }} strategy: + fail-fast: false matrix: + os: [ubuntu-latest] python-version: ["3.9", "3.10", "3.11", "3.12"] - fail-fast: false - + include: + - os: windows-latest + python-version: "3.11" + - os: macos-latest + python-version: "3.11" steps: - - name: Check out repository - uses: actions/checkout@v4 - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - - - name: Install dependencies + cache: pip + - name: System libraries for OpenCV (Linux) + if: runner.os == 'Linux' + timeout-minutes: 5 + # Usually preinstalled on GitHub runners: only touch apt when missing, so a + # slow package mirror can never stall the job. run: | - python -m pip install --upgrade pip - pip install -r requirements.txt + if ldconfig -p | grep -q "libGL.so.1" && ldconfig -p | grep -q "libgthread-2.0.so.0"; then + echo "OpenCV system libraries already present" + else + sudo apt-get update -o Acquire::Retries=3 && sudo apt-get install -y --no-install-recommends libgl1 libglib2.0-0 + fi + - run: python -m pip install --upgrade pip + - run: pip install -e ".[dev,pdf]" + - name: Unit tests with coverage gate (no OCR models) + run: pytest -m "not ocr" --cov=docling_pk --cov-report=term-missing --cov-report=xml --cov-fail-under=90 + - uses: actions/upload-artifact@v4 + if: always() && matrix.os == 'ubuntu-latest' && matrix.python-version == '3.11' + with: + name: coverage-xml + path: coverage.xml - - name: Run tests with coverage - run: pytest -v --cov=docling_pk --cov-report=term-missing --cov-report=xml + integration: + name: End-to-end OCR tests (RapidOCR on synthetic fixtures) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: pip + - name: System libraries for OpenCV (Linux) + if: runner.os == 'Linux' + timeout-minutes: 5 + # Usually preinstalled on GitHub runners: only touch apt when missing, so a + # slow package mirror can never stall the job. + run: | + if ldconfig -p | grep -q "libGL.so.1" && ldconfig -p | grep -q "libgthread-2.0.so.0"; then + echo "OpenCV system libraries already present" + else + sudo apt-get update -o Acquire::Retries=3 && sudo apt-get install -y --no-install-recommends libgl1 libglib2.0-0 + fi + - run: python -m pip install --upgrade pip + - run: pip install -e ".[dev,pdf]" + - run: pytest -m ocr -v + - name: Synthetic benchmark + run: python benchmarks/run_benchmark.py tests/fixtures/synthetic/labels.json --backend rapidocr --out benchmark_synthetic.json + - uses: actions/upload-artifact@v4 + with: + name: benchmark-synthetic + path: benchmark_synthetic.json - - name: Upload coverage report - uses: actions/upload-artifact@v4 + package: + name: Build and verify distribution + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 with: - name: coverage-report-py${{ matrix.python-version }} - path: coverage.xml - if: always() \ No newline at end of file + python-version: "3.11" + - name: System libraries for OpenCV (Linux) + timeout-minutes: 5 + # Usually preinstalled on GitHub runners: only touch apt when missing, so a + # slow package mirror can never stall the job. + run: | + if ldconfig -p | grep -q "libGL.so.1" && ldconfig -p | grep -q "libgthread-2.0.so.0"; then + echo "OpenCV system libraries already present" + else + sudo apt-get update -o Acquire::Retries=3 && sudo apt-get install -y --no-install-recommends libgl1 libglib2.0-0 + fi + - run: python -m pip install --upgrade pip build twine + - run: python -m build + - run: twine check --strict dist/* + - name: Install the wheel in a clean environment and smoke-test it + run: | + python -m venv /tmp/clean + /tmp/clean/bin/pip install dist/*.whl + cd /tmp + /tmp/clean/bin/python -c "import docling_pk; print(docling_pk.__version__)" + /tmp/clean/bin/docling-pk --version + /tmp/clean/bin/docling-pk extract "$GITHUB_WORKSPACE/tests/fixtures/synthetic/00_cnic_front_clean.jpg" --type cnic --no-verify --no-urdu + - uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..7ec1ea9 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,44 @@ +name: Release to PyPI + +# Publishes when a version tag (v1.0.0, v1.1.0, ...) is pushed. +# Uses PyPI Trusted Publishing: no API token is stored in GitHub. One-time +# setup on pypi.org: Account -> Publishing -> add a pending publisher with +# owner "Inference-LAB", repository "docling-pk", workflow "release.yml", +# environment "pypi". + +on: + push: + tags: ["v*"] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - run: python -m pip install --upgrade pip build twine + - run: python -m build + - run: twine check --strict dist/* + - name: Tag matches package version + run: | + VERSION=$(python -c "import re,pathlib;print(re.search(r'__version__ = \"(.+?)\"', pathlib.Path('src/docling_pk/__init__.py').read_text()).group(1))") + test "v$VERSION" = "${GITHUB_REF_NAME}" || { echo "Tag ${GITHUB_REF_NAME} != package version v$VERSION"; exit 1; } + - uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + + publish: + needs: build + runs-on: ubuntu-latest + environment: pypi + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + - uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.gitignore b/.gitignore index 057506a..5a018ac 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,28 @@ -*.docx +# Python __pycache__/ -.vscode/ -*.pyc -venv/ +*.py[cod] *.egg-info/ +build/ +dist/ +.eggs/ + +# Environments +venv/ +.venv/ +env/ + +# Tooling .pytest_cache/ +.ruff_cache/ +.mypy_cache/ .coverage coverage.xml +htmlcov/ +.vscode/ +.idea/ + +# Documents and data that must never be committed: real people's documents +*.docx +private/ +private-eval/ +benchmarks/results/*.full.json diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e2ec62e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,51 @@ +# Changelog + +All notable changes to this project are documented here. The format follows +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses +[Semantic Versioning](https://semver.org/). + +## [1.0.0] - 2026-10 + +First release on PyPI. Field accuracy on 16 real document photos went from +34.9% (v0.1) to 94.4%, with 99.2% of fields marked `ok` correct. + +### Added +- `extract()` public API accepting paths, bytes, NumPy arrays, PIL images, + PDFs and lists (CNIC front + back merged into one result). +- `document_type="auto"` detection; aliases `ssc`, `hssc`, `fsc`, `inter`, `transcript`. +- Degree and transcript extraction. +- Per-field `status` and `reason`; `FieldResult.as_date()`; `DocumentResult.to_json()` + and dict-style access; marks table in `result.tables["subjects"]`. +- RapidOCR backend (new default) and pluggable `OCRBackend` interface. +- Cross-engine verification of CNIC numbers, dates, marks and IDs (with the `urdu` extra). +- CNIC: label-geometry parsing, chronological date assignment, gender vs CNIC + parity check, back-side support, experimental Urdu address with normalization + and lexicon correction. +- Certificates: column-aligned marks table validated against the TOTAL row, + subject sums and "marks in words"; board detection for FBISE and other boards. +- Orientation (0/90/180/270) and skew correction that work on cluttered backgrounds. +- Image-quality warnings (blur, exposure, glare, resolution). +- CLI: `extract`, `batch`, `info`, `--version`, table output. +- Synthetic document generator, 23 committed fixtures, benchmark runner and report. +- Test suite (253 tests, 92% coverage), CI on Python 3.9–3.12 / Linux, Windows, + macOS, clean-install check, PyPI trusted-publishing release workflow. + +### Changed +- EasyOCR moved to the optional `urdu` extra; the core install no longer needs PyTorch. +- Blurry images produce warnings and partial results instead of being rejected. +- Package moved to a `src/` layout; the CLI entry point is `docling_pk.cli:app`. + +### Fixed +- `docling_pk.extractor` could not be imported (`from __future__` after other imports). +- `from docling_pk import extract` did not exist. +- The installed `docling-pk` command pointed to a module that was not packaged. +- Orientation selection preferred Urdu-script noise on sideways reads, so the + cleanest CNIC photo extracted 0 of 8 fields. +- Deskew was a no-op (or wrong) on OpenCV ≥ 4.5. +- "Father Name" label minus "Father" was returned as the father's name ("Name"). +- Images with non-ASCII paths failed to load on Windows. + +## [0.1.0] - 2026-09 + +Fellowship prototype: EasyOCR + regex CNIC parser, Federal Board certificate +parser, CLI skeleton returning placeholder output. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..587cb17 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,52 @@ +# Contributing to docling-pk + +## Setup + +```bash +git clone https://github.com/Inference-LAB/docling-pk.git +cd docling-pk +python -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate +pip install -e ".[dev,pdf]" # add ,urdu to work on the Urdu address or verifier +``` + +## Before opening a PR + +```bash +ruff check src tests benchmarks +ruff format src tests benchmarks +mypy src +pytest -m "not ocr" # fast, no OCR models +pytest # full suite incl. end-to-end OCR (downloads ~15 MB of models once) +python benchmarks/run_benchmark.py tests/fixtures/synthetic/labels.json +``` + +CI runs all of these, enforces 90% coverage, and fails if any synthetic +fixture returns a field with status `ok` and a wrong value. Paste the +benchmark summary into the PR description whenever extraction logic changes. + +## Never commit real documents + +Real CNICs and certificates contain personal data. Do not add them to the +repository, to issues, or to PR descriptions, even blurred. To evaluate on +real samples, keep the images and a labels file outside the repo and run +`benchmarks/run_benchmark.py /path/to/private/labels.json`; share only the +aggregate numbers. For tests, add synthetic cases to +`benchmarks/synthetic/generate.py` and regenerate the fixtures. + +## Adding a board, university or document type + +1. Collect a few real samples (privately) and label them. +2. Run the benchmark to see where the current parser fails. +3. Prefer **label vocabulary and validation** over coordinates: add labels to + the parser's label lists, formats to `validation/`, and checks that can turn + a wrong value into `low_confidence`. +4. Add constructed-layout unit tests (see `tests/conftest.py`) and, if + possible, a synthetic fixture. +5. Values found by fallbacks must be `low_confidence` with a `reason`. + +## Design rules + +- A field is never filled with a guess. Return `None` with a reason instead. +- Status `ok` means it passed every check that applies to it. +- Problems with the call raise; problems with the document go in `warnings`. +- Keep the core install free of PyTorch; heavy models go in extras. diff --git a/README.md b/README.md index 8a971e1..557cbb5 100644 --- a/README.md +++ b/README.md @@ -1,78 +1,225 @@ # docling-pk -Extract structured data from Pakistani identity and education documents, -from a photo, without a cloud API. +**Structured data extraction from Pakistani identity and education documents.** +Give it a photo of a CNIC, a Matric or Intermediate certificate, or a university +degree / transcript, and get back typed fields with confidence scores, plus a +reason for every field it could not read. -Supported document types in v1: CNIC, Matric certificate, Intermediate -certificate. Degree/Transcript is not supported yet, see Known Limitations. +[![CI](https://github.com/Inference-LAB/docling-pk/actions/workflows/ci.yml/badge.svg)](https://github.com/Inference-LAB/docling-pk/actions/workflows/ci.yml) +![Python](https://img.shields.io/badge/python-3.9%20%7C%203.10%20%7C%203.11%20%7C%203.12-blue) +![License](https://img.shields.io/badge/license-MIT-green) + +```python +from docling_pk import extract + +result = extract("cnic_front.jpg", document_type="cnic") +print(result.to_json()) +``` + +```json +{ + "document_type": "cnic", + "fields": { + "name": {"value": "Muhammad Ali", "confidence": 0.99, "status": "ok"}, + "father_name": {"value": "Muhammad Akram", "confidence": 0.99, "status": "ok"}, + "gender": {"value": "M", "confidence": 1.0, "status": "ok"}, + "country_of_stay":{"value": "Pakistan", "confidence": 1.0, "status": "ok"}, + "cnic_number": {"value": "35202-1234567-9", "confidence": 1.0, "status": "ok"}, + "date_of_birth": {"value": "01-01-1995", "confidence": 1.0, "status": "ok"}, + "date_of_issue": {"value": "15-03-2020", "confidence": 1.0, "status": "ok"}, + "date_of_expiry": {"value": "15-03-2030", "confidence": 1.0, "status": "ok"}, + "address": {"value": null, "confidence": 0.0, "status": "not_applicable", + "reason": "printed on the back of the card; pass the back image too"}, + "permanent_address": {"value": null, "confidence": 0.0, "status": "not_applicable", "reason": "..."} + }, + "confidence": 0.99, + "warnings": [], + "metadata": {"ocr_backend": "rapidocr", "sides": ["front"], "pages": [{"rotation_degrees": 90, "skew_degrees": 0.0}]} +} +``` + +## Why it is built this way + +- **It does not return a wrong value as `ok`.** On the synthetic benchmark, every + field reported with status `ok` was correct; on 16 real photos, 99.2% were. + When a field cannot be read reliably, `value` is `None` and the result says + why (`not_found`, `invalid`, `low_confidence`, `not_applicable`). A blank + goes to a human. A wrong CNIC digit would quietly change someone's identity. +- **Cross-checks, not just OCR.** CNIC numbers and dates are re-read from a + tight crop by a second OCR engine. The three CNIC dates must satisfy + birth < issue < expiry. The CNIC's last digit must match the printed gender + (odd = male). Marks are checked against the TOTAL row, the subject rows' + sum, and the "marks in words" line. +- **Real phone photos.** It detects and fixes 90/180/270-degree rotation and + skew, filters security-paper micro-text, and warns about blur, glare and low + resolution instead of rejecting the image. +- **Light install.** The default engine is RapidOCR (PP-OCR models on ONNX + Runtime): about 370 MB installed, CPU-only, around 2.5 s per document. No + PyTorch is needed unless you want the Urdu address. ## Installation ```bash -pip install docling-pk +pip install docling-pk # CNIC front, certificates, degrees (CPU, ~370 MB) +pip install "docling-pk[urdu]" # + Urdu address on CNIC backs, + second-engine verification (EasyOCR/PyTorch) +pip install "docling-pk[pdf]" # + PDF input (PyMuPDF) +pip install "docling-pk[all]" ``` -## Quickstart +Python 3.9 to 3.12 on Linux, Windows and macOS. Models are bundled with or +downloaded by the OCR engines on first use; nothing is sent to any server. +On minimal Linux images (e.g. `python:3.x-slim` in Docker), OpenCV needs two +system libraries: `apt-get install -y libgl1 libglib2.0-0`. + +## Usage + +### Python ```python -from docling_pk.extractor import extract +from docling_pk import extract + +# CNIC: pass front and back together to also get the address +r = extract(["cnic_front.jpg", "cnic_back.jpg"], document_type="cnic") +r.fields["cnic_number"].value # '35202-1234567-9' +r.fields["date_of_birth"].as_date() # datetime.date(1995, 1, 1) +r["fields"]["name"]["value"] # dict-style access mirrors the JSON + +# Board certificate: subject-wise marks come back as a table +r = extract("matric.jpg", document_type="matric") +r.fields["total_marks"].value # '787' +r.tables["subjects"][0] # {'subject': 'ENGLISH (COMPULSORY)', 'max_marks': 150, 'obtained_marks': 118} + +# Let it figure out the document type +r = extract("unknown_scan.png") # document_type="auto" +r.document_type # 'intermediate' + +# Inputs: path, bytes, NumPy array (BGR), PIL image, PDF, or a list of these +r = extract(open("card.jpg", "rb").read(), "cnic") -result = extract("path/to/cnic_photo.jpg", document_type="cnic") +# Acting only on fields that passed every check +trusted = {k: f.value for k, f in r.fields.items() if f.status == "ok"} +needs_review = {k: f.reason for k, f in r.fields.items() if f.status != "ok"} +``` + +Options: `backend="auto" | "rapidocr" | "easyocr"` (or your own +`OCRBackend`), `verify=True` (second-engine re-reads when EasyOCR is +installed), `urdu=True`, `gpu=None` (auto), `auto_rotate=True`, +`include_raw_text=True`. -print(result.confidence) -print(result.fields["name"].value) -print(result.warnings) +### Command line + +```bash +docling-pk extract cnic_front.jpg cnic_back.jpg --type cnic # JSON to stdout +docling-pk extract certificate.jpg --type auto --output table # readable table +docling-pk extract transcript.pdf --type degree --out result.json +docling-pk batch ./scans --type matric --out results.jsonl # a folder, one JSON line per file +docling-pk info # engines installed, GPU ``` -Or from the command line: +Exit codes: `0` success, `1` processed but nothing could be extracted, `2` bad +input (missing file, unreadable image, unknown type). + +## Supported documents + +| `document_type` | Fields | Verified on real samples | +|---|---|---| +| `cnic` | `name`, `father_name`, `gender`, `country_of_stay`, `cnic_number`, `date_of_birth`, `date_of_issue`, `date_of_expiry`, `address`, `permanent_address` | Current green CNIC / SNIC, front and back | +| `matric` (`ssc`) | `student_name`, `father_name`, `date_of_birth`, `roll_number`, `registration_number`, `serial_number`, `certificate_number`, `board`, `exam`, `year`, `session`, `group`, `institute`, `grade`, `total_marks`, `max_marks`, plus `tables["subjects"]` | FBISE | +| `intermediate` (`hssc`, `fsc`, `inter`) | Same as matric | FBISE | +| `degree` (`transcript`) | `student_name`, `father_name`, `degree`, `institution`, `campus`, `registration_number`, `serial_number`, `cgpa`, `division`, `date_of_birth`, `date_of_issue`, `year` | COMSATS degree and transcript | + +Every field key is always present for its document type. Matric and +intermediate are confirmed against the certificate title, which wins if it +disagrees with `document_type` (a warning says so). Other boards (BISE +Lahore, Rawalpindi, Karachi, Peshawar...) are detected and parsed with the +same label vocabulary, but come with a warning because their layouts have +not been verified on real samples yet. + +## Accuracy + +Exact-match field accuracy. *Precision* is the share of returned values that +are correct; *ok-precision* is the same restricted to fields with status +`ok`, which is the number that matters if your system auto-accepts them. + +| Dataset | Accuracy | Precision | ok-precision | Marks rows | Time / doc | +|---|---|---|---|---|---| +| 16 real photos, original v0.1 pipeline | 34.9% | n/a | n/a | 0 / 30 | n/a | +| 16 real photos, v1 (`pip install docling-pk`) | **94.4%** | 97.1% | 98.5% | 30 / 30 | 3.7 s | +| 16 real photos, v1 with the `urdu` extra | **94.4%** | 96.4% | **99.2%** | 30 / 30 | 3.7 s | +| 23 synthetic fixtures (committed) | **96.2%** | 100% | **100%** | 53 / 53 | 2.5 s (CPU) | + +By document type on real photos: CNIC 91.2%, Matric 96.4%, Intermediate +100%, Degree 100%, Transcript 90%. Every date and gender on the real CNICs was +correct. The misses: the Urdu address (both cards), three fields on a +motion-blurred photo (left blank, plus a misspelled name flagged +`low_confidence`), one father's name on a small soft-focus photo (left +blank), one registration number with an I/l confusion (flagged +`low_confidence`) and one dropped space in a school name. Full methodology +and per-field numbers: +[docs/benchmark.md](https://github.com/Inference-LAB/docling-pk/blob/main/docs/benchmark.md). The real photos contain personal data +and are not published; only aggregate counts are +([benchmarks/results/real_samples_summary.json](https://github.com/Inference-LAB/docling-pk/blob/main/benchmarks/results/real_samples_summary.json)). + +Reproduce the public benchmark: + +```bash +python benchmarks/run_benchmark.py tests/fixtures/synthetic/labels.json +``` + +## Known limitations + +- **Urdu address (CNIC back) is experimental.** Nastaliq OCR with EasyOCR + averages about 49% character error on real cards, even after Urdu + normalization and lexicon correction of address words and district names. + It is always returned as `low_confidence`. UTRNet, the strongest open Urdu + recognizer we evaluated, is licensed CC BY-NC-SA (non-commercial), so it + cannot ship in an MIT library. +- **Handwritten fields, heavily damaged documents, and stamps covering more + than ~30% of a field** are out of scope (per the v1 brief). They return + `None` with a reason; they do not crash. +- **Old (pre-2012) Urdu-only CNICs**: numbers and dates are read; Urdu-only + names are not. +- **Board layouts other than FBISE** are parsed best-effort and flagged. +- **Severe motion blur** loses fields. The result is a blur warning and blank + fields, never invented values. +- **No NADRA verification.** docling-pk reads documents; it does not check + that they are genuine. + +## Privacy + +Processing is fully local. `DocumentResult.to_dict()` and the CLI leave out +the raw OCR text by default, because it can contain personal data that is not +part of any extracted field (`include_raw=True` / `--raw` to include it). The +repository's test images are synthetic and stamped "SYNTHETIC SPECIMEN". + +## How it works + +``` +image/PDF ─► load (Unicode paths, EXIF, PDF pages) ─► quality checks (blur, glare, exposure) + ─► orientation (0/90/180/270 by Latin-text score × box shape) ─► deskew from text-line angles + ─► OCR with positions (RapidOCR / EasyOCR) ─► micro-text filter (+ ink-only re-read if needed) + ─► per-type parser: labels found by fuzzy match, values by position + ─► validation & cross-checks (second engine, CNIC structure, date order, marks sums) + ─► DocumentResult (value, confidence, status, reason per field) +``` + +Details and the decisions behind them, including the measurements that +replaced the brief's EasyOCR default: [docs/design_doc.md](https://github.com/Inference-LAB/docling-pk/blob/main/docs/design_doc.md). + +## Development ```bash -docling-pk cnic path/to/cnic_photo.jpg +pip install -e ".[dev,pdf]" +pytest -m "not ocr" # 211 fast tests, no models needed +pytest # + end-to-end OCR tests on synthetic fixtures +ruff check . && ruff format --check . && mypy src +python benchmarks/synthetic/generate.py # regenerate the synthetic fixtures ``` -## Supported Document Types - -| Type | Fields extracted | -|---|---| -| `cnic` | name, father_name, cnic_number, date_of_birth, date_of_issue, date_of_expiry, gender, address | -| `matric` | serial_number, certificate_number, roll_number, registration_number, group, session_year, grade, name, father_name, institute, date_of_birth, total_marks_possible, total_marks_obtained | -| `intermediate` | same as matric, minus date_of_birth (not printed on this certificate type) | - -`matric` and `intermediate` share a parser structure (both from Federal -Board Islamabad in current testing) and are told apart automatically from -the certificate's own title text, not from the document_type argument -alone. - -## Known Limitations - -- **Address on CNIC** is only extracted from the front of the card. The - front of a Smart CNIC does not print an address at all, it is on the - back, in Urdu (Nastaliq script). Back-of-card address extraction was - investigated extensively and found to be a genuine, currently unsolved - problem across every tool tested (see design doc for the full - investigation). Not supported in v1. -- **Gender** on CNIC is unreliable. Some card sub-formats print it as - plain text, others render it in a way that is not reliably recoverable - from OCR output. -- **Grade** on Matric/Intermediate certificates is unreliable, for the - same underlying reason as gender: the character is either dropped or - misread by OCR. -- **Institute name** on Matric/Intermediate is not extracted. Tested and - found too badly corrupted by OCR on real samples to parse reliably. -- **Deskew** (image rotation correction) assumes a plain background - behind the document. Confirmed to fail against cluttered or textured - backgrounds (e.g. a patterned surface behind the photo). -- **Blurry images** are rejected with a clear warning rather than - processed, based on a sharpness threshold calibrated on a small number - of real samples. This threshold may need adjustment as more real-world - images are tested. -- **Degree/Transcript** is not implemented in v1. - -## Requirements - -- Python 3.9+ -- A GPU is recommended for reasonable OCR speed, not required. +See [CONTRIBUTING.md](https://github.com/Inference-LAB/docling-pk/blob/main/CONTRIBUTING.md). Releases are published to PyPI from +version tags by GitHub Actions (trusted publishing). ## License -This project is licensed under the MIT License. See [LICENSE](LICENSE) for the full text. \ No newline at end of file +MIT. See [LICENSE](https://github.com/Inference-LAB/docling-pk/blob/main/LICENSE). Built at [INFERENCE Lab](https://inference-lab.org) +as part of Engineering Fellowship Cohort 01. diff --git a/benchmarks/results/real_samples_summary.json b/benchmarks/results/real_samples_summary.json new file mode 100644 index 0000000..793146c --- /dev/null +++ b/benchmarks/results/real_samples_summary.json @@ -0,0 +1,603 @@ +{ + "_note": "Aggregate results on 16 real photos of Pakistani documents (8 CNIC fronts, 2 CNIC backs, 2 Matric, 2 Intermediate, 1 degree, 1 transcript). The images and per-field values contain personal data and are NOT published; only counts are.", + "dataset": { + "images": 16, + "labelled_fields": 146, + "subject_rows": 30, + "conditions": [ + "clean photo", + "rotated 90/270", + "skewed on striped cloth", + "plastic sleeve glare", + "small and soft focus", + "motion blur", + "scanned", + "watermarked digital certificate" + ] + }, + "baseline_v0_1": { + "accuracy": 0.3493150684931507, + "by_doc_type": { + "cnic": 0.35294117647058826, + "matric": 0.6333333333333333, + "intermediate": 0.27586206896551724, + "degree": 0.0, + "transcript": 0.0 + }, + "note": "original pipeline (EasyOCR, line-order regex); its extract() could not be imported, logic replicated" + }, + "v1_core_install": { + "overall": { + "n": 142, + "accuracy": 0.9436619718309859, + "coverage": 0.971830985915493, + "precision": 0.9710144927536232, + "ok_precision": 0.9849624060150376, + "ok_count": 133 + }, + "by_doc_type": { + "cnic": { + "n": 68, + "accuracy": 0.9117647058823529, + "coverage": 0.9411764705882353, + "precision": 0.96875, + "ok_precision": 1.0, + "ok_count": 59 + }, + "degree": { + "n": 9, + "accuracy": 1.0, + "coverage": 1.0, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 9 + }, + "intermediate": { + "n": 27, + "accuracy": 1.0, + "coverage": 1.0, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 27 + }, + "matric": { + "n": 28, + "accuracy": 0.9642857142857143, + "coverage": 1.0, + "precision": 0.9642857142857143, + "ok_precision": 0.9642857142857143, + "ok_count": 28 + }, + "transcript": { + "n": 10, + "accuracy": 0.9, + "coverage": 1.0, + "precision": 0.9, + "ok_precision": 0.9, + "ok_count": 10 + } + }, + "by_field": { + "cnic.address": { + "accuracy": 0.0, + "n": 2 + }, + "cnic.cnic_number": { + "accuracy": 0.9, + "n": 10 + }, + "cnic.country_of_stay": { + "accuracy": 0.875, + "n": 8 + }, + "cnic.date_of_birth": { + "accuracy": 1.0, + "n": 8 + }, + "cnic.date_of_expiry": { + "accuracy": 1.0, + "n": 8 + }, + "cnic.date_of_issue": { + "accuracy": 1.0, + "n": 8 + }, + "cnic.father_name": { + "accuracy": 0.875, + "n": 8 + }, + "cnic.gender": { + "accuracy": 1.0, + "n": 8 + }, + "cnic.name": { + "accuracy": 0.875, + "n": 8 + }, + "degree.campus": { + "accuracy": 1.0, + "n": 1 + }, + "degree.date_of_issue": { + "accuracy": 1.0, + "n": 1 + }, + "degree.degree": { + "accuracy": 1.0, + "n": 1 + }, + "degree.father_name": { + "accuracy": 1.0, + "n": 1 + }, + "degree.institution": { + "accuracy": 1.0, + "n": 1 + }, + "degree.registration_number": { + "accuracy": 1.0, + "n": 1 + }, + "degree.serial_number": { + "accuracy": 1.0, + "n": 1 + }, + "degree.student_name": { + "accuracy": 1.0, + "n": 1 + }, + "degree.year": { + "accuracy": 1.0, + "n": 1 + }, + "intermediate.board": { + "accuracy": 1.0, + "n": 2 + }, + "intermediate.certificate_number": { + "accuracy": 1.0, + "n": 2 + }, + "intermediate.date_of_birth": { + "accuracy": 1.0, + "n": 1 + }, + "intermediate.father_name": { + "accuracy": 1.0, + "n": 2 + }, + "intermediate.grade": { + "accuracy": 1.0, + "n": 2 + }, + "intermediate.group": { + "accuracy": 1.0, + "n": 2 + }, + "intermediate.institute": { + "accuracy": 1.0, + "n": 2 + }, + "intermediate.max_marks": { + "accuracy": 1.0, + "n": 2 + }, + "intermediate.registration_number": { + "accuracy": 1.0, + "n": 2 + }, + "intermediate.roll_number": { + "accuracy": 1.0, + "n": 2 + }, + "intermediate.serial_number": { + "accuracy": 1.0, + "n": 2 + }, + "intermediate.student_name": { + "accuracy": 1.0, + "n": 2 + }, + "intermediate.total_marks": { + "accuracy": 1.0, + "n": 2 + }, + "intermediate.year": { + "accuracy": 1.0, + "n": 2 + }, + "matric.board": { + "accuracy": 1.0, + "n": 2 + }, + "matric.certificate_number": { + "accuracy": 1.0, + "n": 2 + }, + "matric.date_of_birth": { + "accuracy": 1.0, + "n": 2 + }, + "matric.father_name": { + "accuracy": 1.0, + "n": 2 + }, + "matric.grade": { + "accuracy": 1.0, + "n": 2 + }, + "matric.group": { + "accuracy": 1.0, + "n": 2 + }, + "matric.institute": { + "accuracy": 0.5, + "n": 2 + }, + "matric.max_marks": { + "accuracy": 1.0, + "n": 2 + }, + "matric.registration_number": { + "accuracy": 1.0, + "n": 2 + }, + "matric.roll_number": { + "accuracy": 1.0, + "n": 2 + }, + "matric.serial_number": { + "accuracy": 1.0, + "n": 2 + }, + "matric.student_name": { + "accuracy": 1.0, + "n": 2 + }, + "matric.total_marks": { + "accuracy": 1.0, + "n": 2 + }, + "matric.year": { + "accuracy": 1.0, + "n": 2 + }, + "transcript.campus": { + "accuracy": 1.0, + "n": 1 + }, + "transcript.cgpa": { + "accuracy": 1.0, + "n": 1 + }, + "transcript.date_of_birth": { + "accuracy": 1.0, + "n": 1 + }, + "transcript.date_of_issue": { + "accuracy": 1.0, + "n": 1 + }, + "transcript.degree": { + "accuracy": 1.0, + "n": 1 + }, + "transcript.father_name": { + "accuracy": 1.0, + "n": 1 + }, + "transcript.institution": { + "accuracy": 1.0, + "n": 1 + }, + "transcript.registration_number": { + "accuracy": 0.0, + "n": 1 + }, + "transcript.student_name": { + "accuracy": 1.0, + "n": 1 + }, + "transcript.year": { + "accuracy": 1.0, + "n": 1 + } + }, + "subjects": { + "rows_correct": 30, + "rows_expected": 30, + "rows_returned": 30 + }, + "seconds_per_document": { + "mean": 3.3218988687499995, + "max": 9.456651 + } + }, + "v1_full_install": { + "overall": { + "n": 142, + "accuracy": 0.9436619718309859, + "coverage": 0.9788732394366197, + "precision": 0.9640287769784173, + "ok_precision": 0.9923664122137404, + "ok_count": 131 + }, + "by_doc_type": { + "cnic": { + "n": 68, + "accuracy": 0.9117647058823529, + "coverage": 0.9558823529411765, + "precision": 0.9538461538461539, + "ok_precision": 1.0, + "ok_count": 59 + }, + "degree": { + "n": 9, + "accuracy": 1.0, + "coverage": 1.0, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 8 + }, + "intermediate": { + "n": 27, + "accuracy": 1.0, + "coverage": 1.0, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 27 + }, + "matric": { + "n": 28, + "accuracy": 0.9642857142857143, + "coverage": 1.0, + "precision": 0.9642857142857143, + "ok_precision": 0.9642857142857143, + "ok_count": 28 + }, + "transcript": { + "n": 10, + "accuracy": 0.9, + "coverage": 1.0, + "precision": 0.9, + "ok_precision": 1.0, + "ok_count": 9 + } + }, + "by_field": { + "cnic.address": { + "accuracy": 0.0, + "n": 2 + }, + "cnic.cnic_number": { + "accuracy": 0.9, + "n": 10 + }, + "cnic.country_of_stay": { + "accuracy": 0.875, + "n": 8 + }, + "cnic.date_of_birth": { + "accuracy": 1.0, + "n": 8 + }, + "cnic.date_of_expiry": { + "accuracy": 1.0, + "n": 8 + }, + "cnic.date_of_issue": { + "accuracy": 1.0, + "n": 8 + }, + "cnic.father_name": { + "accuracy": 0.875, + "n": 8 + }, + "cnic.gender": { + "accuracy": 1.0, + "n": 8 + }, + "cnic.name": { + "accuracy": 0.875, + "n": 8 + }, + "degree.campus": { + "accuracy": 1.0, + "n": 1 + }, + "degree.date_of_issue": { + "accuracy": 1.0, + "n": 1 + }, + "degree.degree": { + "accuracy": 1.0, + "n": 1 + }, + "degree.father_name": { + "accuracy": 1.0, + "n": 1 + }, + "degree.institution": { + "accuracy": 1.0, + "n": 1 + }, + "degree.registration_number": { + "accuracy": 1.0, + "n": 1 + }, + "degree.serial_number": { + "accuracy": 1.0, + "n": 1 + }, + "degree.student_name": { + "accuracy": 1.0, + "n": 1 + }, + "degree.year": { + "accuracy": 1.0, + "n": 1 + }, + "intermediate.board": { + "accuracy": 1.0, + "n": 2 + }, + "intermediate.certificate_number": { + "accuracy": 1.0, + "n": 2 + }, + "intermediate.date_of_birth": { + "accuracy": 1.0, + "n": 1 + }, + "intermediate.father_name": { + "accuracy": 1.0, + "n": 2 + }, + "intermediate.grade": { + "accuracy": 1.0, + "n": 2 + }, + "intermediate.group": { + "accuracy": 1.0, + "n": 2 + }, + "intermediate.institute": { + "accuracy": 1.0, + "n": 2 + }, + "intermediate.max_marks": { + "accuracy": 1.0, + "n": 2 + }, + "intermediate.registration_number": { + "accuracy": 1.0, + "n": 2 + }, + "intermediate.roll_number": { + "accuracy": 1.0, + "n": 2 + }, + "intermediate.serial_number": { + "accuracy": 1.0, + "n": 2 + }, + "intermediate.student_name": { + "accuracy": 1.0, + "n": 2 + }, + "intermediate.total_marks": { + "accuracy": 1.0, + "n": 2 + }, + "intermediate.year": { + "accuracy": 1.0, + "n": 2 + }, + "matric.board": { + "accuracy": 1.0, + "n": 2 + }, + "matric.certificate_number": { + "accuracy": 1.0, + "n": 2 + }, + "matric.date_of_birth": { + "accuracy": 1.0, + "n": 2 + }, + "matric.father_name": { + "accuracy": 1.0, + "n": 2 + }, + "matric.grade": { + "accuracy": 1.0, + "n": 2 + }, + "matric.group": { + "accuracy": 1.0, + "n": 2 + }, + "matric.institute": { + "accuracy": 0.5, + "n": 2 + }, + "matric.max_marks": { + "accuracy": 1.0, + "n": 2 + }, + "matric.registration_number": { + "accuracy": 1.0, + "n": 2 + }, + "matric.roll_number": { + "accuracy": 1.0, + "n": 2 + }, + "matric.serial_number": { + "accuracy": 1.0, + "n": 2 + }, + "matric.student_name": { + "accuracy": 1.0, + "n": 2 + }, + "matric.total_marks": { + "accuracy": 1.0, + "n": 2 + }, + "matric.year": { + "accuracy": 1.0, + "n": 2 + }, + "transcript.campus": { + "accuracy": 1.0, + "n": 1 + }, + "transcript.cgpa": { + "accuracy": 1.0, + "n": 1 + }, + "transcript.date_of_birth": { + "accuracy": 1.0, + "n": 1 + }, + "transcript.date_of_issue": { + "accuracy": 1.0, + "n": 1 + }, + "transcript.degree": { + "accuracy": 1.0, + "n": 1 + }, + "transcript.father_name": { + "accuracy": 1.0, + "n": 1 + }, + "transcript.institution": { + "accuracy": 1.0, + "n": 1 + }, + "transcript.registration_number": { + "accuracy": 0.0, + "n": 1 + }, + "transcript.student_name": { + "accuracy": 1.0, + "n": 1 + }, + "transcript.year": { + "accuracy": 1.0, + "n": 1 + } + }, + "subjects": { + "rows_correct": 30, + "rows_expected": 30, + "rows_returned": 30 + }, + "seconds_per_document": { + "mean": 3.710477887499792, + "max": 9.105147599999327 + } + } +} \ No newline at end of file diff --git a/benchmarks/results/synthetic_core_cpu.json b/benchmarks/results/synthetic_core_cpu.json new file mode 100644 index 0000000..8af59a8 --- /dev/null +++ b/benchmarks/results/synthetic_core_cpu.json @@ -0,0 +1,3999 @@ +{ + "labels": "labels.json", + "backend": "rapidocr", + "overall": { + "n": 213, + "accuracy": 0.9624413145539906, + "coverage": 0.9624413145539906, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 205 + }, + "by_doc_type": { + "cnic": { + "n": 97, + "accuracy": 0.9175257731958762, + "coverage": 0.9175257731958762, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 89 + }, + "degree": { + "n": 18, + "accuracy": 1.0, + "coverage": 1.0, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 18 + }, + "intermediate": { + "n": 42, + "accuracy": 1.0, + "coverage": 1.0, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 42 + }, + "matric": { + "n": 56, + "accuracy": 1.0, + "coverage": 1.0, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 56 + } + }, + "by_field": { + "cnic.cnic_number": { + "n": 14, + "accuracy": 0.9285714285714286, + "coverage": 0.9285714285714286, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 13 + }, + "cnic.country_of_stay": { + "n": 12, + "accuracy": 0.9166666666666666, + "coverage": 0.9166666666666666, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 11 + }, + "cnic.date_of_birth": { + "n": 12, + "accuracy": 0.9166666666666666, + "coverage": 0.9166666666666666, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 11 + }, + "cnic.date_of_expiry": { + "n": 12, + "accuracy": 0.9166666666666666, + "coverage": 0.9166666666666666, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 11 + }, + "cnic.date_of_issue": { + "n": 12, + "accuracy": 0.9166666666666666, + "coverage": 0.9166666666666666, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 11 + }, + "cnic.father_name": { + "n": 12, + "accuracy": 0.9166666666666666, + "coverage": 0.9166666666666666, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 11 + }, + "cnic.gender": { + "n": 12, + "accuracy": 0.9166666666666666, + "coverage": 0.9166666666666666, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 11 + }, + "cnic.name": { + "n": 11, + "accuracy": 0.9090909090909091, + "coverage": 0.9090909090909091, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 10 + }, + "degree.cgpa": { + "n": 2, + "accuracy": 1.0, + "coverage": 1.0, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 2 + }, + "degree.date_of_issue": { + "n": 2, + "accuracy": 1.0, + "coverage": 1.0, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 2 + }, + "degree.degree": { + "n": 2, + "accuracy": 1.0, + "coverage": 1.0, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 2 + }, + "degree.father_name": { + "n": 2, + "accuracy": 1.0, + "coverage": 1.0, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 2 + }, + "degree.institution": { + "n": 2, + "accuracy": 1.0, + "coverage": 1.0, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 2 + }, + "degree.registration_number": { + "n": 2, + "accuracy": 1.0, + "coverage": 1.0, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 2 + }, + "degree.serial_number": { + "n": 2, + "accuracy": 1.0, + "coverage": 1.0, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 2 + }, + "degree.student_name": { + "n": 2, + "accuracy": 1.0, + "coverage": 1.0, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 2 + }, + "degree.year": { + "n": 2, + "accuracy": 1.0, + "coverage": 1.0, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 2 + }, + "intermediate.board": { + "n": 3, + "accuracy": 1.0, + "coverage": 1.0, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 3 + }, + "intermediate.certificate_number": { + "n": 3, + "accuracy": 1.0, + "coverage": 1.0, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 3 + }, + "intermediate.date_of_birth": { + "n": 3, + "accuracy": 1.0, + "coverage": 1.0, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 3 + }, + "intermediate.father_name": { + "n": 3, + "accuracy": 1.0, + "coverage": 1.0, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 3 + }, + "intermediate.grade": { + "n": 3, + "accuracy": 1.0, + "coverage": 1.0, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 3 + }, + "intermediate.group": { + "n": 3, + "accuracy": 1.0, + "coverage": 1.0, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 3 + }, + "intermediate.institute": { + "n": 3, + "accuracy": 1.0, + "coverage": 1.0, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 3 + }, + "intermediate.max_marks": { + "n": 3, + "accuracy": 1.0, + "coverage": 1.0, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 3 + }, + "intermediate.registration_number": { + "n": 3, + "accuracy": 1.0, + "coverage": 1.0, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 3 + }, + "intermediate.roll_number": { + "n": 3, + "accuracy": 1.0, + "coverage": 1.0, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 3 + }, + "intermediate.serial_number": { + "n": 3, + "accuracy": 1.0, + "coverage": 1.0, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 3 + }, + "intermediate.student_name": { + "n": 3, + "accuracy": 1.0, + "coverage": 1.0, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 3 + }, + "intermediate.total_marks": { + "n": 3, + "accuracy": 1.0, + "coverage": 1.0, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 3 + }, + "intermediate.year": { + "n": 3, + "accuracy": 1.0, + "coverage": 1.0, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 3 + }, + "matric.board": { + "n": 4, + "accuracy": 1.0, + "coverage": 1.0, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 4 + }, + "matric.certificate_number": { + "n": 4, + "accuracy": 1.0, + "coverage": 1.0, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 4 + }, + "matric.date_of_birth": { + "n": 4, + "accuracy": 1.0, + "coverage": 1.0, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 4 + }, + "matric.father_name": { + "n": 4, + "accuracy": 1.0, + "coverage": 1.0, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 4 + }, + "matric.grade": { + "n": 4, + "accuracy": 1.0, + "coverage": 1.0, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 4 + }, + "matric.group": { + "n": 4, + "accuracy": 1.0, + "coverage": 1.0, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 4 + }, + "matric.institute": { + "n": 4, + "accuracy": 1.0, + "coverage": 1.0, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 4 + }, + "matric.max_marks": { + "n": 4, + "accuracy": 1.0, + "coverage": 1.0, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 4 + }, + "matric.registration_number": { + "n": 4, + "accuracy": 1.0, + "coverage": 1.0, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 4 + }, + "matric.roll_number": { + "n": 4, + "accuracy": 1.0, + "coverage": 1.0, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 4 + }, + "matric.serial_number": { + "n": 4, + "accuracy": 1.0, + "coverage": 1.0, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 4 + }, + "matric.student_name": { + "n": 4, + "accuracy": 1.0, + "coverage": 1.0, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 4 + }, + "matric.total_marks": { + "n": 4, + "accuracy": 1.0, + "coverage": 1.0, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 4 + }, + "matric.year": { + "n": 4, + "accuracy": 1.0, + "coverage": 1.0, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 4 + } + }, + "subjects": { + "rows_correct": 53, + "rows_expected": 53, + "rows_returned": 53 + }, + "seconds_per_document": { + "mean": 2.511810191304348, + "max": 5.543541900000001 + }, + "cases": [ + { + "id": "00_cnic_front_clean", + "correct": 8, + "total": 8, + "seconds": 2.29, + "error": null + }, + { + "id": "01_cnic_front_clean", + "correct": 8, + "total": 8, + "seconds": 1.44, + "error": null + }, + { + "id": "02_cnic_front_rotated90", + "correct": 8, + "total": 8, + "seconds": 2.95, + "error": null + }, + { + "id": "03_cnic_front_rotated270", + "correct": 8, + "total": 8, + "seconds": 3.0, + "error": null + }, + { + "id": "04_cnic_front_upside_down", + "correct": 8, + "total": 8, + "seconds": 2.8, + "error": null + }, + { + "id": "05_cnic_front_skewed", + "correct": 8, + "total": 8, + "seconds": 3.13, + "error": null + }, + { + "id": "06_cnic_front_photo", + "correct": 8, + "total": 8, + "seconds": 1.61, + "error": null + }, + { + "id": "07_cnic_front_photo", + "correct": 8, + "total": 8, + "seconds": 1.36, + "error": null + }, + { + "id": "08_cnic_front_low_res", + "correct": 8, + "total": 8, + "seconds": 1.59, + "error": null + }, + { + "id": "09_cnic_front_blurry", + "correct": 8, + "total": 8, + "seconds": 1.39, + "error": null + }, + { + "id": "10_cnic_front_very_blurry", + "correct": 0, + "total": 8, + "seconds": 1.88, + "error": null + }, + { + "id": "11_cnic_front_occluded_name", + "correct": 7, + "total": 7, + "seconds": 1.51, + "error": null + }, + { + "id": "12_cnic_back_clean", + "correct": 1, + "total": 1, + "seconds": 2.3, + "error": null + }, + { + "id": "13_cnic_back_photo", + "correct": 1, + "total": 1, + "seconds": 3.13, + "error": null + }, + { + "id": "14_matric_clean", + "correct": 14, + "total": 14, + "seconds": 3.0, + "error": null + }, + { + "id": "15_matric_rotated90", + "correct": 14, + "total": 14, + "seconds": 5.54, + "error": null + }, + { + "id": "16_matric_photo", + "correct": 14, + "total": 14, + "seconds": 2.68, + "error": null + }, + { + "id": "17_matric_low_res", + "correct": 14, + "total": 14, + "seconds": 2.5, + "error": null + }, + { + "id": "18_intermediate_clean", + "correct": 14, + "total": 14, + "seconds": 2.79, + "error": null + }, + { + "id": "19_intermediate_skewed", + "correct": 14, + "total": 14, + "seconds": 4.98, + "error": null + }, + { + "id": "20_intermediate_photo", + "correct": 14, + "total": 14, + "seconds": 2.56, + "error": null + }, + { + "id": "21_degree_clean", + "correct": 9, + "total": 9, + "seconds": 1.71, + "error": null + }, + { + "id": "22_degree_photo", + "correct": 9, + "total": 9, + "seconds": 1.65, + "error": null + } + ], + "mean_cer_text_fields": 0.038461538461538464, + "rows": [ + { + "case": "00_cnic_front_clean", + "doc_type": "cnic", + "field": "name", + "expected": "Mahnoor Nawaz", + "actual": "Mahnoor Nawaz", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": 0.0, + "tags": [ + "cnic_front", + "clean" + ], + "error": null + }, + { + "case": "00_cnic_front_clean", + "doc_type": "cnic", + "field": "father_name", + "expected": "Ahmed Butt", + "actual": "Ahmed Butt", + "status": "ok", + "correct": true, + "confidence": 0.9983, + "cer": 0.0, + "tags": [ + "cnic_front", + "clean" + ], + "error": null + }, + { + "case": "00_cnic_front_clean", + "doc_type": "cnic", + "field": "gender", + "expected": "F", + "actual": "F", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "clean" + ], + "error": null + }, + { + "case": "00_cnic_front_clean", + "doc_type": "cnic", + "field": "country_of_stay", + "expected": "Pakistan", + "actual": "Pakistan", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "clean" + ], + "error": null + }, + { + "case": "00_cnic_front_clean", + "doc_type": "cnic", + "field": "cnic_number", + "expected": "59869-8797309-2", + "actual": "59869-8797309-2", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "clean" + ], + "error": null + }, + { + "case": "00_cnic_front_clean", + "doc_type": "cnic", + "field": "date_of_birth", + "expected": "08-12-1959", + "actual": "08-12-1959", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "clean" + ], + "error": null + }, + { + "case": "00_cnic_front_clean", + "doc_type": "cnic", + "field": "date_of_issue", + "expected": "11-06-2018", + "actual": "11-06-2018", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "clean" + ], + "error": null + }, + { + "case": "00_cnic_front_clean", + "doc_type": "cnic", + "field": "date_of_expiry", + "expected": "11-06-2023", + "actual": "11-06-2023", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "clean" + ], + "error": null + }, + { + "case": "01_cnic_front_clean", + "doc_type": "cnic", + "field": "name", + "expected": "Iqra Siddiqui", + "actual": "Iqra Siddiqui", + "status": "ok", + "correct": true, + "confidence": 0.9782, + "cer": 0.0, + "tags": [ + "cnic_front", + "clean" + ], + "error": null + }, + { + "case": "01_cnic_front_clean", + "doc_type": "cnic", + "field": "father_name", + "expected": "Usman Javed", + "actual": "Usman Javed", + "status": "ok", + "correct": true, + "confidence": 0.9997, + "cer": 0.0, + "tags": [ + "cnic_front", + "clean" + ], + "error": null + }, + { + "case": "01_cnic_front_clean", + "doc_type": "cnic", + "field": "gender", + "expected": "F", + "actual": "F", + "status": "ok", + "correct": true, + "confidence": 0.9999, + "cer": null, + "tags": [ + "cnic_front", + "clean" + ], + "error": null + }, + { + "case": "01_cnic_front_clean", + "doc_type": "cnic", + "field": "country_of_stay", + "expected": "Pakistan", + "actual": "Pakistan", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "clean" + ], + "error": null + }, + { + "case": "01_cnic_front_clean", + "doc_type": "cnic", + "field": "cnic_number", + "expected": "35568-1518844-8", + "actual": "35568-1518844-8", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "clean" + ], + "error": null + }, + { + "case": "01_cnic_front_clean", + "doc_type": "cnic", + "field": "date_of_birth", + "expected": "22-08-1962", + "actual": "22-08-1962", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "clean" + ], + "error": null + }, + { + "case": "01_cnic_front_clean", + "doc_type": "cnic", + "field": "date_of_issue", + "expected": "13-12-2024", + "actual": "13-12-2024", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "clean" + ], + "error": null + }, + { + "case": "01_cnic_front_clean", + "doc_type": "cnic", + "field": "date_of_expiry", + "expected": "13-12-2031", + "actual": "13-12-2031", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "clean" + ], + "error": null + }, + { + "case": "02_cnic_front_rotated90", + "doc_type": "cnic", + "field": "name", + "expected": "Hamza Abbasi", + "actual": "Hamza Abbasi", + "status": "ok", + "correct": true, + "confidence": 0.9999, + "cer": 0.0, + "tags": [ + "cnic_front", + "rotated90" + ], + "error": null + }, + { + "case": "02_cnic_front_rotated90", + "doc_type": "cnic", + "field": "father_name", + "expected": "Zain Rehman", + "actual": "Zain Rehman", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": 0.0, + "tags": [ + "cnic_front", + "rotated90" + ], + "error": null + }, + { + "case": "02_cnic_front_rotated90", + "doc_type": "cnic", + "field": "gender", + "expected": "M", + "actual": "M", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "rotated90" + ], + "error": null + }, + { + "case": "02_cnic_front_rotated90", + "doc_type": "cnic", + "field": "country_of_stay", + "expected": "Pakistan", + "actual": "Pakistan", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "rotated90" + ], + "error": null + }, + { + "case": "02_cnic_front_rotated90", + "doc_type": "cnic", + "field": "cnic_number", + "expected": "16987-1689768-5", + "actual": "16987-1689768-5", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "rotated90" + ], + "error": null + }, + { + "case": "02_cnic_front_rotated90", + "doc_type": "cnic", + "field": "date_of_birth", + "expected": "05-09-1987", + "actual": "05-09-1987", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "rotated90" + ], + "error": null + }, + { + "case": "02_cnic_front_rotated90", + "doc_type": "cnic", + "field": "date_of_issue", + "expected": "25-08-2024", + "actual": "25-08-2024", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "rotated90" + ], + "error": null + }, + { + "case": "02_cnic_front_rotated90", + "doc_type": "cnic", + "field": "date_of_expiry", + "expected": "25-08-2031", + "actual": "25-08-2031", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "rotated90" + ], + "error": null + }, + { + "case": "03_cnic_front_rotated270", + "doc_type": "cnic", + "field": "name", + "expected": "Muhammad Tariq", + "actual": "Muhammad Tariq", + "status": "ok", + "correct": true, + "confidence": 0.9997, + "cer": 0.0, + "tags": [ + "cnic_front", + "rotated270" + ], + "error": null + }, + { + "case": "03_cnic_front_rotated270", + "doc_type": "cnic", + "field": "father_name", + "expected": "Usman Raza", + "actual": "Usman Raza", + "status": "ok", + "correct": true, + "confidence": 0.9981, + "cer": 0.0, + "tags": [ + "cnic_front", + "rotated270" + ], + "error": null + }, + { + "case": "03_cnic_front_rotated270", + "doc_type": "cnic", + "field": "gender", + "expected": "M", + "actual": "M", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "rotated270" + ], + "error": null + }, + { + "case": "03_cnic_front_rotated270", + "doc_type": "cnic", + "field": "country_of_stay", + "expected": "Pakistan", + "actual": "Pakistan", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "rotated270" + ], + "error": null + }, + { + "case": "03_cnic_front_rotated270", + "doc_type": "cnic", + "field": "cnic_number", + "expected": "70819-6727317-7", + "actual": "70819-6727317-7", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "rotated270" + ], + "error": null + }, + { + "case": "03_cnic_front_rotated270", + "doc_type": "cnic", + "field": "date_of_birth", + "expected": "14-12-1998", + "actual": "14-12-1998", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "rotated270" + ], + "error": null + }, + { + "case": "03_cnic_front_rotated270", + "doc_type": "cnic", + "field": "date_of_issue", + "expected": "25-10-2018", + "actual": "25-10-2018", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "rotated270" + ], + "error": null + }, + { + "case": "03_cnic_front_rotated270", + "doc_type": "cnic", + "field": "date_of_expiry", + "expected": "25-10-2028", + "actual": "25-10-2028", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "rotated270" + ], + "error": null + }, + { + "case": "04_cnic_front_upside_down", + "doc_type": "cnic", + "field": "name", + "expected": "Bilal Saleem", + "actual": "Bilal Saleem", + "status": "ok", + "correct": true, + "confidence": 0.9999, + "cer": 0.0, + "tags": [ + "cnic_front", + "upside_down" + ], + "error": null + }, + { + "case": "04_cnic_front_upside_down", + "doc_type": "cnic", + "field": "father_name", + "expected": "Fahad Aslam", + "actual": "Fahad Aslam", + "status": "ok", + "correct": true, + "confidence": 0.9998, + "cer": 0.0, + "tags": [ + "cnic_front", + "upside_down" + ], + "error": null + }, + { + "case": "04_cnic_front_upside_down", + "doc_type": "cnic", + "field": "gender", + "expected": "M", + "actual": "M", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "upside_down" + ], + "error": null + }, + { + "case": "04_cnic_front_upside_down", + "doc_type": "cnic", + "field": "country_of_stay", + "expected": "Pakistan", + "actual": "Pakistan", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "upside_down" + ], + "error": null + }, + { + "case": "04_cnic_front_upside_down", + "doc_type": "cnic", + "field": "cnic_number", + "expected": "76634-4002978-7", + "actual": "76634-4002978-7", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "upside_down" + ], + "error": null + }, + { + "case": "04_cnic_front_upside_down", + "doc_type": "cnic", + "field": "date_of_birth", + "expected": "17-04-1982", + "actual": "17-04-1982", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "upside_down" + ], + "error": null + }, + { + "case": "04_cnic_front_upside_down", + "doc_type": "cnic", + "field": "date_of_issue", + "expected": "25-06-2016", + "actual": "25-06-2016", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "upside_down" + ], + "error": null + }, + { + "case": "04_cnic_front_upside_down", + "doc_type": "cnic", + "field": "date_of_expiry", + "expected": "25-06-2023", + "actual": "25-06-2023", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "upside_down" + ], + "error": null + }, + { + "case": "05_cnic_front_skewed", + "doc_type": "cnic", + "field": "name", + "expected": "Fahad Aslam", + "actual": "Fahad Aslam", + "status": "ok", + "correct": true, + "confidence": 0.9735, + "cer": 0.0, + "tags": [ + "cnic_front", + "skewed" + ], + "error": null + }, + { + "case": "05_cnic_front_skewed", + "doc_type": "cnic", + "field": "father_name", + "expected": "Hassan Saleem", + "actual": "Hassan Saleem", + "status": "ok", + "correct": true, + "confidence": 0.9999, + "cer": 0.0, + "tags": [ + "cnic_front", + "skewed" + ], + "error": null + }, + { + "case": "05_cnic_front_skewed", + "doc_type": "cnic", + "field": "gender", + "expected": "M", + "actual": "M", + "status": "ok", + "correct": true, + "confidence": 0.9999, + "cer": null, + "tags": [ + "cnic_front", + "skewed" + ], + "error": null + }, + { + "case": "05_cnic_front_skewed", + "doc_type": "cnic", + "field": "country_of_stay", + "expected": "Pakistan", + "actual": "Pakistan", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "skewed" + ], + "error": null + }, + { + "case": "05_cnic_front_skewed", + "doc_type": "cnic", + "field": "cnic_number", + "expected": "60457-1784726-7", + "actual": "60457-1784726-7", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "skewed" + ], + "error": null + }, + { + "case": "05_cnic_front_skewed", + "doc_type": "cnic", + "field": "date_of_birth", + "expected": "08-10-1955", + "actual": "08-10-1955", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "skewed" + ], + "error": null + }, + { + "case": "05_cnic_front_skewed", + "doc_type": "cnic", + "field": "date_of_issue", + "expected": "22-12-2017", + "actual": "22-12-2017", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "skewed" + ], + "error": null + }, + { + "case": "05_cnic_front_skewed", + "doc_type": "cnic", + "field": "date_of_expiry", + "expected": "22-12-2024", + "actual": "22-12-2024", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "skewed" + ], + "error": null + }, + { + "case": "06_cnic_front_photo", + "doc_type": "cnic", + "field": "name", + "expected": "Hassan Raza", + "actual": "Hassan Raza", + "status": "ok", + "correct": true, + "confidence": 0.9999, + "cer": 0.0, + "tags": [ + "cnic_front", + "photo" + ], + "error": null + }, + { + "case": "06_cnic_front_photo", + "doc_type": "cnic", + "field": "father_name", + "expected": "Zain Khan", + "actual": "Zain Khan", + "status": "ok", + "correct": true, + "confidence": 0.9888, + "cer": 0.0, + "tags": [ + "cnic_front", + "photo" + ], + "error": null + }, + { + "case": "06_cnic_front_photo", + "doc_type": "cnic", + "field": "gender", + "expected": "M", + "actual": "M", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "photo" + ], + "error": null + }, + { + "case": "06_cnic_front_photo", + "doc_type": "cnic", + "field": "country_of_stay", + "expected": "Pakistan", + "actual": "Pakistan", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "photo" + ], + "error": null + }, + { + "case": "06_cnic_front_photo", + "doc_type": "cnic", + "field": "cnic_number", + "expected": "10798-1933190-9", + "actual": "10798-1933190-9", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "photo" + ], + "error": null + }, + { + "case": "06_cnic_front_photo", + "doc_type": "cnic", + "field": "date_of_birth", + "expected": "03-02-1974", + "actual": "03-02-1974", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "photo" + ], + "error": null + }, + { + "case": "06_cnic_front_photo", + "doc_type": "cnic", + "field": "date_of_issue", + "expected": "05-01-2020", + "actual": "05-01-2020", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "photo" + ], + "error": null + }, + { + "case": "06_cnic_front_photo", + "doc_type": "cnic", + "field": "date_of_expiry", + "expected": "05-01-2025", + "actual": "05-01-2025", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "photo" + ], + "error": null + }, + { + "case": "07_cnic_front_photo", + "doc_type": "cnic", + "field": "name", + "expected": "Zain Raza", + "actual": "Zain Raza", + "status": "ok", + "correct": true, + "confidence": 0.9999, + "cer": 0.0, + "tags": [ + "cnic_front", + "photo" + ], + "error": null + }, + { + "case": "07_cnic_front_photo", + "doc_type": "cnic", + "field": "father_name", + "expected": "Fahad Butt", + "actual": "Fahad Butt", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": 0.0, + "tags": [ + "cnic_front", + "photo" + ], + "error": null + }, + { + "case": "07_cnic_front_photo", + "doc_type": "cnic", + "field": "gender", + "expected": "M", + "actual": "M", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "photo" + ], + "error": null + }, + { + "case": "07_cnic_front_photo", + "doc_type": "cnic", + "field": "country_of_stay", + "expected": "Pakistan", + "actual": "Pakistan", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "photo" + ], + "error": null + }, + { + "case": "07_cnic_front_photo", + "doc_type": "cnic", + "field": "cnic_number", + "expected": "49298-4162294-9", + "actual": "49298-4162294-9", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "photo" + ], + "error": null + }, + { + "case": "07_cnic_front_photo", + "doc_type": "cnic", + "field": "date_of_birth", + "expected": "19-12-1986", + "actual": "19-12-1986", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "photo" + ], + "error": null + }, + { + "case": "07_cnic_front_photo", + "doc_type": "cnic", + "field": "date_of_issue", + "expected": "03-10-2020", + "actual": "03-10-2020", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "photo" + ], + "error": null + }, + { + "case": "07_cnic_front_photo", + "doc_type": "cnic", + "field": "date_of_expiry", + "expected": "03-10-2025", + "actual": "03-10-2025", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "photo" + ], + "error": null + }, + { + "case": "08_cnic_front_low_res", + "doc_type": "cnic", + "field": "name", + "expected": "Hassan Hussain", + "actual": "Hassan Hussain", + "status": "ok", + "correct": true, + "confidence": 0.9998, + "cer": 0.0, + "tags": [ + "cnic_front", + "low_res" + ], + "error": null + }, + { + "case": "08_cnic_front_low_res", + "doc_type": "cnic", + "field": "father_name", + "expected": "Muhammad Abbasi", + "actual": "Muhammad Abbasi", + "status": "ok", + "correct": true, + "confidence": 0.9764, + "cer": 0.0, + "tags": [ + "cnic_front", + "low_res" + ], + "error": null + }, + { + "case": "08_cnic_front_low_res", + "doc_type": "cnic", + "field": "gender", + "expected": "M", + "actual": "M", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "low_res" + ], + "error": null + }, + { + "case": "08_cnic_front_low_res", + "doc_type": "cnic", + "field": "country_of_stay", + "expected": "Pakistan", + "actual": "Pakistan", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "low_res" + ], + "error": null + }, + { + "case": "08_cnic_front_low_res", + "doc_type": "cnic", + "field": "cnic_number", + "expected": "65008-7547517-7", + "actual": "65008-7547517-7", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "low_res" + ], + "error": null + }, + { + "case": "08_cnic_front_low_res", + "doc_type": "cnic", + "field": "date_of_birth", + "expected": "13-04-2001", + "actual": "13-04-2001", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "low_res" + ], + "error": null + }, + { + "case": "08_cnic_front_low_res", + "doc_type": "cnic", + "field": "date_of_issue", + "expected": "08-12-2025", + "actual": "08-12-2025", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "low_res" + ], + "error": null + }, + { + "case": "08_cnic_front_low_res", + "doc_type": "cnic", + "field": "date_of_expiry", + "expected": "08-12-2030", + "actual": "08-12-2030", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "low_res" + ], + "error": null + }, + { + "case": "09_cnic_front_blurry", + "doc_type": "cnic", + "field": "name", + "expected": "Usman Rehman", + "actual": "Usman Rehman", + "status": "ok", + "correct": true, + "confidence": 0.9999, + "cer": 0.0, + "tags": [ + "cnic_front", + "blurry" + ], + "error": null + }, + { + "case": "09_cnic_front_blurry", + "doc_type": "cnic", + "field": "father_name", + "expected": "Imran Siddiqui", + "actual": "Imran Siddiqui", + "status": "ok", + "correct": true, + "confidence": 0.9996, + "cer": 0.0, + "tags": [ + "cnic_front", + "blurry" + ], + "error": null + }, + { + "case": "09_cnic_front_blurry", + "doc_type": "cnic", + "field": "gender", + "expected": "M", + "actual": "M", + "status": "ok", + "correct": true, + "confidence": 0.9999, + "cer": null, + "tags": [ + "cnic_front", + "blurry" + ], + "error": null + }, + { + "case": "09_cnic_front_blurry", + "doc_type": "cnic", + "field": "country_of_stay", + "expected": "Pakistan", + "actual": "Pakistan", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "blurry" + ], + "error": null + }, + { + "case": "09_cnic_front_blurry", + "doc_type": "cnic", + "field": "cnic_number", + "expected": "49318-6032080-7", + "actual": "49318-6032080-7", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "blurry" + ], + "error": null + }, + { + "case": "09_cnic_front_blurry", + "doc_type": "cnic", + "field": "date_of_birth", + "expected": "30-11-1966", + "actual": "30-11-1966", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "blurry" + ], + "error": null + }, + { + "case": "09_cnic_front_blurry", + "doc_type": "cnic", + "field": "date_of_issue", + "expected": "07-11-2022", + "actual": "07-11-2022", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "blurry" + ], + "error": null + }, + { + "case": "09_cnic_front_blurry", + "doc_type": "cnic", + "field": "date_of_expiry", + "expected": "07-11-2032", + "actual": "07-11-2032", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "blurry" + ], + "error": null + }, + { + "case": "10_cnic_front_very_blurry", + "doc_type": "cnic", + "field": "name", + "expected": "Ali Nawaz", + "actual": null, + "status": "not_found", + "correct": false, + "confidence": 0.0, + "cer": 1.0, + "tags": [ + "cnic_front", + "very_blurry", + "unreadable" + ], + "error": null + }, + { + "case": "10_cnic_front_very_blurry", + "doc_type": "cnic", + "field": "father_name", + "expected": "Usman Nawaz", + "actual": null, + "status": "not_found", + "correct": false, + "confidence": 0.0, + "cer": 1.0, + "tags": [ + "cnic_front", + "very_blurry", + "unreadable" + ], + "error": null + }, + { + "case": "10_cnic_front_very_blurry", + "doc_type": "cnic", + "field": "gender", + "expected": "M", + "actual": null, + "status": "not_found", + "correct": false, + "confidence": 0.0, + "cer": null, + "tags": [ + "cnic_front", + "very_blurry", + "unreadable" + ], + "error": null + }, + { + "case": "10_cnic_front_very_blurry", + "doc_type": "cnic", + "field": "country_of_stay", + "expected": "Pakistan", + "actual": null, + "status": "not_found", + "correct": false, + "confidence": 0.0, + "cer": null, + "tags": [ + "cnic_front", + "very_blurry", + "unreadable" + ], + "error": null + }, + { + "case": "10_cnic_front_very_blurry", + "doc_type": "cnic", + "field": "cnic_number", + "expected": "22264-6861642-5", + "actual": null, + "status": "not_found", + "correct": false, + "confidence": 0.0, + "cer": null, + "tags": [ + "cnic_front", + "very_blurry", + "unreadable" + ], + "error": null + }, + { + "case": "10_cnic_front_very_blurry", + "doc_type": "cnic", + "field": "date_of_birth", + "expected": "03-09-1965", + "actual": null, + "status": "not_found", + "correct": false, + "confidence": 0.0, + "cer": null, + "tags": [ + "cnic_front", + "very_blurry", + "unreadable" + ], + "error": null + }, + { + "case": "10_cnic_front_very_blurry", + "doc_type": "cnic", + "field": "date_of_issue", + "expected": "28-11-2017", + "actual": null, + "status": "not_found", + "correct": false, + "confidence": 0.0, + "cer": null, + "tags": [ + "cnic_front", + "very_blurry", + "unreadable" + ], + "error": null + }, + { + "case": "10_cnic_front_very_blurry", + "doc_type": "cnic", + "field": "date_of_expiry", + "expected": "28-11-2024", + "actual": null, + "status": "not_found", + "correct": false, + "confidence": 0.0, + "cer": null, + "tags": [ + "cnic_front", + "very_blurry", + "unreadable" + ], + "error": null + }, + { + "case": "11_cnic_front_occluded_name", + "doc_type": "cnic", + "field": "father_name", + "expected": "Zain Shah", + "actual": "Zain Shah", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": 0.0, + "tags": [ + "cnic_front", + "occluded_name" + ], + "error": null + }, + { + "case": "11_cnic_front_occluded_name", + "doc_type": "cnic", + "field": "gender", + "expected": "M", + "actual": "M", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "occluded_name" + ], + "error": null + }, + { + "case": "11_cnic_front_occluded_name", + "doc_type": "cnic", + "field": "country_of_stay", + "expected": "Pakistan", + "actual": "Pakistan", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "occluded_name" + ], + "error": null + }, + { + "case": "11_cnic_front_occluded_name", + "doc_type": "cnic", + "field": "cnic_number", + "expected": "38771-8841330-1", + "actual": "38771-8841330-1", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "occluded_name" + ], + "error": null + }, + { + "case": "11_cnic_front_occluded_name", + "doc_type": "cnic", + "field": "date_of_birth", + "expected": "28-09-1990", + "actual": "28-09-1990", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "occluded_name" + ], + "error": null + }, + { + "case": "11_cnic_front_occluded_name", + "doc_type": "cnic", + "field": "date_of_issue", + "expected": "30-05-2019", + "actual": "30-05-2019", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "occluded_name" + ], + "error": null + }, + { + "case": "11_cnic_front_occluded_name", + "doc_type": "cnic", + "field": "date_of_expiry", + "expected": "30-05-2026", + "actual": "30-05-2026", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "occluded_name" + ], + "error": null + }, + { + "case": "12_cnic_back_clean", + "doc_type": "cnic", + "field": "cnic_number", + "expected": "72052-5898319-5", + "actual": "72052-5898319-5", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_back", + "clean" + ], + "error": null + }, + { + "case": "13_cnic_back_photo", + "doc_type": "cnic", + "field": "cnic_number", + "expected": "54948-2141621-1", + "actual": "54948-2141621-1", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_back", + "photo" + ], + "error": null + }, + { + "case": "14_matric_clean", + "doc_type": "matric", + "field": "board", + "expected": "FBISE", + "actual": "FBISE", + "status": "ok", + "correct": true, + "confidence": 0.9, + "cer": null, + "tags": [ + "matric", + "clean" + ], + "error": null + }, + { + "case": "14_matric_clean", + "doc_type": "matric", + "field": "serial_number", + "expected": "2618161", + "actual": "2618161", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "matric", + "clean" + ], + "error": null + }, + { + "case": "14_matric_clean", + "doc_type": "matric", + "field": "roll_number", + "expected": "4046427", + "actual": "4046427", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "matric", + "clean" + ], + "error": null + }, + { + "case": "14_matric_clean", + "doc_type": "matric", + "field": "registration_number", + "expected": "5393655803", + "actual": "5393655803", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "matric", + "clean" + ], + "error": null + }, + { + "case": "14_matric_clean", + "doc_type": "matric", + "field": "certificate_number", + "expected": "232092060/198429", + "actual": "232092060/198429", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "matric", + "clean" + ], + "error": null + }, + { + "case": "14_matric_clean", + "doc_type": "matric", + "field": "group", + "expected": "GENERAL", + "actual": "GENERAL", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "matric", + "clean" + ], + "error": null + }, + { + "case": "14_matric_clean", + "doc_type": "matric", + "field": "year", + "expected": "2015", + "actual": "2015", + "status": "ok", + "correct": true, + "confidence": 0.9, + "cer": null, + "tags": [ + "matric", + "clean" + ], + "error": null + }, + { + "case": "14_matric_clean", + "doc_type": "matric", + "field": "grade", + "expected": "B", + "actual": "B", + "status": "ok", + "correct": true, + "confidence": 0.9895, + "cer": null, + "tags": [ + "matric", + "clean" + ], + "error": null + }, + { + "case": "14_matric_clean", + "doc_type": "matric", + "field": "student_name", + "expected": "RABIA HUSSAIN", + "actual": "RABIA HUSSAIN", + "status": "ok", + "correct": true, + "confidence": 0.9627, + "cer": 0.0, + "tags": [ + "matric", + "clean" + ], + "error": null + }, + { + "case": "14_matric_clean", + "doc_type": "matric", + "field": "father_name", + "expected": "BILAL SIDDIQUI", + "actual": "BILAL SIDDIQUI", + "status": "ok", + "correct": true, + "confidence": 0.9949, + "cer": 0.0, + "tags": [ + "matric", + "clean" + ], + "error": null + }, + { + "case": "14_matric_clean", + "doc_type": "matric", + "field": "date_of_birth", + "expected": "28-04-1997", + "actual": "28-04-1997", + "status": "ok", + "correct": true, + "confidence": 0.986, + "cer": null, + "tags": [ + "matric", + "clean" + ], + "error": null + }, + { + "case": "14_matric_clean", + "doc_type": "matric", + "field": "institute", + "expected": "FG GIRLS COLLEGE, CANTT, PESHAWAR", + "actual": "FG GIRLS COLLEGE, CANTT, PESHAWAR", + "status": "ok", + "correct": true, + "confidence": 0.9965, + "cer": 0.0, + "tags": [ + "matric", + "clean" + ], + "error": null + }, + { + "case": "14_matric_clean", + "doc_type": "matric", + "field": "max_marks", + "expected": "1100", + "actual": "1100", + "status": "ok", + "correct": true, + "confidence": 0.95, + "cer": null, + "tags": [ + "matric", + "clean" + ], + "error": null + }, + { + "case": "14_matric_clean", + "doc_type": "matric", + "field": "total_marks", + "expected": "651", + "actual": "651", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "matric", + "clean" + ], + "error": null + }, + { + "case": "15_matric_rotated90", + "doc_type": "matric", + "field": "board", + "expected": "FBISE", + "actual": "FBISE", + "status": "ok", + "correct": true, + "confidence": 0.9, + "cer": null, + "tags": [ + "matric", + "rotated90" + ], + "error": null + }, + { + "case": "15_matric_rotated90", + "doc_type": "matric", + "field": "serial_number", + "expected": "5220771", + "actual": "5220771", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "matric", + "rotated90" + ], + "error": null + }, + { + "case": "15_matric_rotated90", + "doc_type": "matric", + "field": "roll_number", + "expected": "6627130", + "actual": "6627130", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "matric", + "rotated90" + ], + "error": null + }, + { + "case": "15_matric_rotated90", + "doc_type": "matric", + "field": "registration_number", + "expected": "8803163805", + "actual": "8803163805", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "matric", + "rotated90" + ], + "error": null + }, + { + "case": "15_matric_rotated90", + "doc_type": "matric", + "field": "certificate_number", + "expected": "161880504/871109", + "actual": "161880504/871109", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "matric", + "rotated90" + ], + "error": null + }, + { + "case": "15_matric_rotated90", + "doc_type": "matric", + "field": "group", + "expected": "SCIENCE", + "actual": "SCIENCE", + "status": "ok", + "correct": true, + "confidence": 0.9999, + "cer": null, + "tags": [ + "matric", + "rotated90" + ], + "error": null + }, + { + "case": "15_matric_rotated90", + "doc_type": "matric", + "field": "year", + "expected": "2020", + "actual": "2020", + "status": "ok", + "correct": true, + "confidence": 0.9, + "cer": null, + "tags": [ + "matric", + "rotated90" + ], + "error": null + }, + { + "case": "15_matric_rotated90", + "doc_type": "matric", + "field": "grade", + "expected": "C", + "actual": "C", + "status": "ok", + "correct": true, + "confidence": 0.9911, + "cer": null, + "tags": [ + "matric", + "rotated90" + ], + "error": null + }, + { + "case": "15_matric_rotated90", + "doc_type": "matric", + "field": "student_name", + "expected": "IMRAN AHMED", + "actual": "IMRAN AHMED", + "status": "ok", + "correct": true, + "confidence": 0.9549, + "cer": 0.0, + "tags": [ + "matric", + "rotated90" + ], + "error": null + }, + { + "case": "15_matric_rotated90", + "doc_type": "matric", + "field": "father_name", + "expected": "HASSAN SIDDIQUI", + "actual": "HASSAN SIDDIQUI", + "status": "ok", + "correct": true, + "confidence": 0.9923, + "cer": 0.0, + "tags": [ + "matric", + "rotated90" + ], + "error": null + }, + { + "case": "15_matric_rotated90", + "doc_type": "matric", + "field": "date_of_birth", + "expected": "23-10-2004", + "actual": "23-10-2004", + "status": "ok", + "correct": true, + "confidence": 0.9811, + "cer": null, + "tags": [ + "matric", + "rotated90" + ], + "error": null + }, + { + "case": "15_matric_rotated90", + "doc_type": "matric", + "field": "institute", + "expected": "GOVT BOYS COLLEGE, MALL ROAD, LAHORE", + "actual": "GOVT BOYS COLLEGE, MALL ROAD, LAHORE", + "status": "ok", + "correct": true, + "confidence": 0.995, + "cer": 0.0, + "tags": [ + "matric", + "rotated90" + ], + "error": null + }, + { + "case": "15_matric_rotated90", + "doc_type": "matric", + "field": "max_marks", + "expected": "1100", + "actual": "1100", + "status": "ok", + "correct": true, + "confidence": 0.95, + "cer": null, + "tags": [ + "matric", + "rotated90" + ], + "error": null + }, + { + "case": "15_matric_rotated90", + "doc_type": "matric", + "field": "total_marks", + "expected": "853", + "actual": "853", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "matric", + "rotated90" + ], + "error": null + }, + { + "case": "16_matric_photo", + "doc_type": "matric", + "field": "board", + "expected": "FBISE", + "actual": "FBISE", + "status": "ok", + "correct": true, + "confidence": 0.9, + "cer": null, + "tags": [ + "matric", + "photo" + ], + "error": null + }, + { + "case": "16_matric_photo", + "doc_type": "matric", + "field": "serial_number", + "expected": "3058316", + "actual": "3058316", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "matric", + "photo" + ], + "error": null + }, + { + "case": "16_matric_photo", + "doc_type": "matric", + "field": "roll_number", + "expected": "2084458", + "actual": "2084458", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "matric", + "photo" + ], + "error": null + }, + { + "case": "16_matric_photo", + "doc_type": "matric", + "field": "registration_number", + "expected": "1160282720", + "actual": "1160282720", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "matric", + "photo" + ], + "error": null + }, + { + "case": "16_matric_photo", + "doc_type": "matric", + "field": "certificate_number", + "expected": "661120183/471238", + "actual": "661120183/471238", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "matric", + "photo" + ], + "error": null + }, + { + "case": "16_matric_photo", + "doc_type": "matric", + "field": "group", + "expected": "SCIENCE", + "actual": "SCIENCE", + "status": "ok", + "correct": true, + "confidence": 0.9999, + "cer": null, + "tags": [ + "matric", + "photo" + ], + "error": null + }, + { + "case": "16_matric_photo", + "doc_type": "matric", + "field": "year", + "expected": "2017", + "actual": "2017", + "status": "ok", + "correct": true, + "confidence": 0.9, + "cer": null, + "tags": [ + "matric", + "photo" + ], + "error": null + }, + { + "case": "16_matric_photo", + "doc_type": "matric", + "field": "grade", + "expected": "B", + "actual": "B", + "status": "ok", + "correct": true, + "confidence": 0.9927, + "cer": null, + "tags": [ + "matric", + "photo" + ], + "error": null + }, + { + "case": "16_matric_photo", + "doc_type": "matric", + "field": "student_name", + "expected": "FATIMA QURESHI", + "actual": "FATIMA QURESHI", + "status": "ok", + "correct": true, + "confidence": 0.9968, + "cer": 0.0, + "tags": [ + "matric", + "photo" + ], + "error": null + }, + { + "case": "16_matric_photo", + "doc_type": "matric", + "field": "father_name", + "expected": "HASSAN SIDDIQUI", + "actual": "HASSAN SIDDIQUI", + "status": "ok", + "correct": true, + "confidence": 0.9875, + "cer": 0.0, + "tags": [ + "matric", + "photo" + ], + "error": null + }, + { + "case": "16_matric_photo", + "doc_type": "matric", + "field": "date_of_birth", + "expected": "21-06-2002", + "actual": "21-06-2002", + "status": "ok", + "correct": true, + "confidence": 0.9794, + "cer": null, + "tags": [ + "matric", + "photo" + ], + "error": null + }, + { + "case": "16_matric_photo", + "doc_type": "matric", + "field": "institute", + "expected": "FG GIRLS COLLEGE, CANTT, PESHAWAR", + "actual": "FG GIRLS COLLEGE, CANTT, PESHAWAR", + "status": "ok", + "correct": true, + "confidence": 0.9883, + "cer": 0.0, + "tags": [ + "matric", + "photo" + ], + "error": null + }, + { + "case": "16_matric_photo", + "doc_type": "matric", + "field": "max_marks", + "expected": "1100", + "actual": "1100", + "status": "ok", + "correct": true, + "confidence": 0.95, + "cer": null, + "tags": [ + "matric", + "photo" + ], + "error": null + }, + { + "case": "16_matric_photo", + "doc_type": "matric", + "field": "total_marks", + "expected": "688", + "actual": "688", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "matric", + "photo" + ], + "error": null + }, + { + "case": "17_matric_low_res", + "doc_type": "matric", + "field": "board", + "expected": "FBISE", + "actual": "FBISE", + "status": "ok", + "correct": true, + "confidence": 0.9, + "cer": null, + "tags": [ + "matric", + "low_res" + ], + "error": null + }, + { + "case": "17_matric_low_res", + "doc_type": "matric", + "field": "serial_number", + "expected": "4829496", + "actual": "4829496", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "matric", + "low_res" + ], + "error": null + }, + { + "case": "17_matric_low_res", + "doc_type": "matric", + "field": "roll_number", + "expected": "491949", + "actual": "491949", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "matric", + "low_res" + ], + "error": null + }, + { + "case": "17_matric_low_res", + "doc_type": "matric", + "field": "registration_number", + "expected": "5366245723", + "actual": "5366245723", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "matric", + "low_res" + ], + "error": null + }, + { + "case": "17_matric_low_res", + "doc_type": "matric", + "field": "certificate_number", + "expected": "681055366/602103", + "actual": "681055366/602103", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "matric", + "low_res" + ], + "error": null + }, + { + "case": "17_matric_low_res", + "doc_type": "matric", + "field": "group", + "expected": "GENERAL", + "actual": "GENERAL", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "matric", + "low_res" + ], + "error": null + }, + { + "case": "17_matric_low_res", + "doc_type": "matric", + "field": "year", + "expected": "2019", + "actual": "2019", + "status": "ok", + "correct": true, + "confidence": 0.9, + "cer": null, + "tags": [ + "matric", + "low_res" + ], + "error": null + }, + { + "case": "17_matric_low_res", + "doc_type": "matric", + "field": "grade", + "expected": "C", + "actual": "C", + "status": "ok", + "correct": true, + "confidence": 0.9921, + "cer": null, + "tags": [ + "matric", + "low_res" + ], + "error": null + }, + { + "case": "17_matric_low_res", + "doc_type": "matric", + "field": "student_name", + "expected": "AMNA BUTT", + "actual": "AMNA BUTT", + "status": "ok", + "correct": true, + "confidence": 0.989, + "cer": 0.0, + "tags": [ + "matric", + "low_res" + ], + "error": null + }, + { + "case": "17_matric_low_res", + "doc_type": "matric", + "field": "father_name", + "expected": "ALI SHAH", + "actual": "ALI SHAH", + "status": "ok", + "correct": true, + "confidence": 0.9913, + "cer": 0.0, + "tags": [ + "matric", + "low_res" + ], + "error": null + }, + { + "case": "17_matric_low_res", + "doc_type": "matric", + "field": "date_of_birth", + "expected": "30-10-2001", + "actual": "30-10-2001", + "status": "ok", + "correct": true, + "confidence": 0.9724, + "cer": null, + "tags": [ + "matric", + "low_res" + ], + "error": null + }, + { + "case": "17_matric_low_res", + "doc_type": "matric", + "field": "institute", + "expected": "GOVT BOYS COLLEGE, MALL ROAD, LAHORE", + "actual": "GOVT BOYS COLLEGE, MALL ROAD, LAHORE", + "status": "ok", + "correct": true, + "confidence": 0.9861, + "cer": 0.0, + "tags": [ + "matric", + "low_res" + ], + "error": null + }, + { + "case": "17_matric_low_res", + "doc_type": "matric", + "field": "max_marks", + "expected": "1100", + "actual": "1100", + "status": "ok", + "correct": true, + "confidence": 0.95, + "cer": null, + "tags": [ + "matric", + "low_res" + ], + "error": null + }, + { + "case": "17_matric_low_res", + "doc_type": "matric", + "field": "total_marks", + "expected": "876", + "actual": "876", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "matric", + "low_res" + ], + "error": null + }, + { + "case": "18_intermediate_clean", + "doc_type": "intermediate", + "field": "board", + "expected": "FBISE", + "actual": "FBISE", + "status": "ok", + "correct": true, + "confidence": 0.9, + "cer": null, + "tags": [ + "intermediate", + "clean" + ], + "error": null + }, + { + "case": "18_intermediate_clean", + "doc_type": "intermediate", + "field": "serial_number", + "expected": "1191416", + "actual": "1191416", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "intermediate", + "clean" + ], + "error": null + }, + { + "case": "18_intermediate_clean", + "doc_type": "intermediate", + "field": "roll_number", + "expected": "4173617", + "actual": "4173617", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "intermediate", + "clean" + ], + "error": null + }, + { + "case": "18_intermediate_clean", + "doc_type": "intermediate", + "field": "registration_number", + "expected": "3020975659", + "actual": "3020975659", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "intermediate", + "clean" + ], + "error": null + }, + { + "case": "18_intermediate_clean", + "doc_type": "intermediate", + "field": "certificate_number", + "expected": "338197458/812133", + "actual": "338197458/812133", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "intermediate", + "clean" + ], + "error": null + }, + { + "case": "18_intermediate_clean", + "doc_type": "intermediate", + "field": "group", + "expected": "PRE-ENGINEERING", + "actual": "PRE-ENGINEERING", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "intermediate", + "clean" + ], + "error": null + }, + { + "case": "18_intermediate_clean", + "doc_type": "intermediate", + "field": "year", + "expected": "2020", + "actual": "2020", + "status": "ok", + "correct": true, + "confidence": 0.9, + "cer": null, + "tags": [ + "intermediate", + "clean" + ], + "error": null + }, + { + "case": "18_intermediate_clean", + "doc_type": "intermediate", + "field": "grade", + "expected": "A", + "actual": "A", + "status": "ok", + "correct": true, + "confidence": 0.9985, + "cer": null, + "tags": [ + "intermediate", + "clean" + ], + "error": null + }, + { + "case": "18_intermediate_clean", + "doc_type": "intermediate", + "field": "student_name", + "expected": "ALI SALEEM", + "actual": "ALI SALEEM", + "status": "ok", + "correct": true, + "confidence": 0.9878, + "cer": 0.0, + "tags": [ + "intermediate", + "clean" + ], + "error": null + }, + { + "case": "18_intermediate_clean", + "doc_type": "intermediate", + "field": "father_name", + "expected": "MUHAMMAD NAWAZ", + "actual": "MUHAMMAD NAWAZ", + "status": "ok", + "correct": true, + "confidence": 0.9836, + "cer": 0.0, + "tags": [ + "intermediate", + "clean" + ], + "error": null + }, + { + "case": "18_intermediate_clean", + "doc_type": "intermediate", + "field": "date_of_birth", + "expected": "26-04-2002", + "actual": "26-04-2002", + "status": "ok", + "correct": true, + "confidence": 0.9999, + "cer": null, + "tags": [ + "intermediate", + "clean" + ], + "error": null + }, + { + "case": "18_intermediate_clean", + "doc_type": "intermediate", + "field": "institute", + "expected": "GOVT BOYS COLLEGE, MALL ROAD, LAHORE", + "actual": "GOVT BOYS COLLEGE, MALL ROAD, LAHORE", + "status": "ok", + "correct": true, + "confidence": 0.9948, + "cer": 0.0, + "tags": [ + "intermediate", + "clean" + ], + "error": null + }, + { + "case": "18_intermediate_clean", + "doc_type": "intermediate", + "field": "max_marks", + "expected": "1100", + "actual": "1100", + "status": "ok", + "correct": true, + "confidence": 0.95, + "cer": null, + "tags": [ + "intermediate", + "clean" + ], + "error": null + }, + { + "case": "18_intermediate_clean", + "doc_type": "intermediate", + "field": "total_marks", + "expected": "864", + "actual": "864", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "intermediate", + "clean" + ], + "error": null + }, + { + "case": "19_intermediate_skewed", + "doc_type": "intermediate", + "field": "board", + "expected": "FBISE", + "actual": "FBISE", + "status": "ok", + "correct": true, + "confidence": 0.9, + "cer": null, + "tags": [ + "intermediate", + "skewed" + ], + "error": null + }, + { + "case": "19_intermediate_skewed", + "doc_type": "intermediate", + "field": "serial_number", + "expected": "6237740", + "actual": "6237740", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "intermediate", + "skewed" + ], + "error": null + }, + { + "case": "19_intermediate_skewed", + "doc_type": "intermediate", + "field": "roll_number", + "expected": "3252477", + "actual": "3252477", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "intermediate", + "skewed" + ], + "error": null + }, + { + "case": "19_intermediate_skewed", + "doc_type": "intermediate", + "field": "registration_number", + "expected": "2763248246", + "actual": "2763248246", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "intermediate", + "skewed" + ], + "error": null + }, + { + "case": "19_intermediate_skewed", + "doc_type": "intermediate", + "field": "certificate_number", + "expected": "620610912/105569", + "actual": "620610912/105569", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "intermediate", + "skewed" + ], + "error": null + }, + { + "case": "19_intermediate_skewed", + "doc_type": "intermediate", + "field": "group", + "expected": "PRE-ENGINEERING", + "actual": "PRE-ENGINEERING", + "status": "ok", + "correct": true, + "confidence": 0.9999, + "cer": null, + "tags": [ + "intermediate", + "skewed" + ], + "error": null + }, + { + "case": "19_intermediate_skewed", + "doc_type": "intermediate", + "field": "year", + "expected": "2025", + "actual": "2025", + "status": "ok", + "correct": true, + "confidence": 0.9, + "cer": null, + "tags": [ + "intermediate", + "skewed" + ], + "error": null + }, + { + "case": "19_intermediate_skewed", + "doc_type": "intermediate", + "field": "grade", + "expected": "B", + "actual": "B", + "status": "ok", + "correct": true, + "confidence": 0.9972, + "cer": null, + "tags": [ + "intermediate", + "skewed" + ], + "error": null + }, + { + "case": "19_intermediate_skewed", + "doc_type": "intermediate", + "field": "student_name", + "expected": "HAMZA MALIK", + "actual": "HAMZA MALIK", + "status": "ok", + "correct": true, + "confidence": 0.9963, + "cer": 0.0, + "tags": [ + "intermediate", + "skewed" + ], + "error": null + }, + { + "case": "19_intermediate_skewed", + "doc_type": "intermediate", + "field": "father_name", + "expected": "HAMZA ABBASI", + "actual": "HAMZA ABBASI", + "status": "ok", + "correct": true, + "confidence": 0.9855, + "cer": 0.0, + "tags": [ + "intermediate", + "skewed" + ], + "error": null + }, + { + "case": "19_intermediate_skewed", + "doc_type": "intermediate", + "field": "date_of_birth", + "expected": "04-04-2010", + "actual": "04-04-2010", + "status": "ok", + "correct": true, + "confidence": 0.9911, + "cer": null, + "tags": [ + "intermediate", + "skewed" + ], + "error": null + }, + { + "case": "19_intermediate_skewed", + "doc_type": "intermediate", + "field": "institute", + "expected": "MODEL HIGH SCHOOL, SECTOR G-7, ISLAMABAD", + "actual": "MODEL HIGH SCHOOL, SECTOR G-7, ISLAMABAD", + "status": "ok", + "correct": true, + "confidence": 0.9937, + "cer": 0.0, + "tags": [ + "intermediate", + "skewed" + ], + "error": null + }, + { + "case": "19_intermediate_skewed", + "doc_type": "intermediate", + "field": "max_marks", + "expected": "1100", + "actual": "1100", + "status": "ok", + "correct": true, + "confidence": 0.95, + "cer": null, + "tags": [ + "intermediate", + "skewed" + ], + "error": null + }, + { + "case": "19_intermediate_skewed", + "doc_type": "intermediate", + "field": "total_marks", + "expected": "887", + "actual": "887", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "intermediate", + "skewed" + ], + "error": null + }, + { + "case": "20_intermediate_photo", + "doc_type": "intermediate", + "field": "board", + "expected": "FBISE", + "actual": "FBISE", + "status": "ok", + "correct": true, + "confidence": 0.9, + "cer": null, + "tags": [ + "intermediate", + "photo" + ], + "error": null + }, + { + "case": "20_intermediate_photo", + "doc_type": "intermediate", + "field": "serial_number", + "expected": "7598076", + "actual": "7598076", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "intermediate", + "photo" + ], + "error": null + }, + { + "case": "20_intermediate_photo", + "doc_type": "intermediate", + "field": "roll_number", + "expected": "6485104", + "actual": "6485104", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "intermediate", + "photo" + ], + "error": null + }, + { + "case": "20_intermediate_photo", + "doc_type": "intermediate", + "field": "registration_number", + "expected": "3842279313", + "actual": "3842279313", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "intermediate", + "photo" + ], + "error": null + }, + { + "case": "20_intermediate_photo", + "doc_type": "intermediate", + "field": "certificate_number", + "expected": "646278472/210018", + "actual": "646278472/210018", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "intermediate", + "photo" + ], + "error": null + }, + { + "case": "20_intermediate_photo", + "doc_type": "intermediate", + "field": "group", + "expected": "PRE-MEDICAL", + "actual": "PRE-MEDICAL", + "status": "ok", + "correct": true, + "confidence": 0.9999, + "cer": null, + "tags": [ + "intermediate", + "photo" + ], + "error": null + }, + { + "case": "20_intermediate_photo", + "doc_type": "intermediate", + "field": "year", + "expected": "2024", + "actual": "2024", + "status": "ok", + "correct": true, + "confidence": 0.9, + "cer": null, + "tags": [ + "intermediate", + "photo" + ], + "error": null + }, + { + "case": "20_intermediate_photo", + "doc_type": "intermediate", + "field": "grade", + "expected": "A1", + "actual": "A1", + "status": "ok", + "correct": true, + "confidence": 0.9909, + "cer": null, + "tags": [ + "intermediate", + "photo" + ], + "error": null + }, + { + "case": "20_intermediate_photo", + "doc_type": "intermediate", + "field": "student_name", + "expected": "HIRA RAZA", + "actual": "HIRA RAZA", + "status": "ok", + "correct": true, + "confidence": 0.9651, + "cer": 0.0, + "tags": [ + "intermediate", + "photo" + ], + "error": null + }, + { + "case": "20_intermediate_photo", + "doc_type": "intermediate", + "field": "father_name", + "expected": "AHMED KHAN", + "actual": "AHMED KHAN", + "status": "ok", + "correct": true, + "confidence": 0.9979, + "cer": 0.0, + "tags": [ + "intermediate", + "photo" + ], + "error": null + }, + { + "case": "20_intermediate_photo", + "doc_type": "intermediate", + "field": "date_of_birth", + "expected": "02-04-2006", + "actual": "02-04-2006", + "status": "ok", + "correct": true, + "confidence": 0.9949, + "cer": null, + "tags": [ + "intermediate", + "photo" + ], + "error": null + }, + { + "case": "20_intermediate_photo", + "doc_type": "intermediate", + "field": "institute", + "expected": "FG GIRLS COLLEGE, CANTT, PESHAWAR", + "actual": "FG GIRLS COLLEGE, CANTT, PESHAWAR", + "status": "ok", + "correct": true, + "confidence": 0.9993, + "cer": 0.0, + "tags": [ + "intermediate", + "photo" + ], + "error": null + }, + { + "case": "20_intermediate_photo", + "doc_type": "intermediate", + "field": "max_marks", + "expected": "1100", + "actual": "1100", + "status": "ok", + "correct": true, + "confidence": 0.95, + "cer": null, + "tags": [ + "intermediate", + "photo" + ], + "error": null + }, + { + "case": "20_intermediate_photo", + "doc_type": "intermediate", + "field": "total_marks", + "expected": "791", + "actual": "791", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "intermediate", + "photo" + ], + "error": null + }, + { + "case": "21_degree_clean", + "doc_type": "degree", + "field": "institution", + "expected": "Sample Institute of Engineering and Technology", + "actual": "Sample Institute of Engineering and Technology", + "status": "ok", + "correct": true, + "confidence": 0.9942, + "cer": 0.0, + "tags": [ + "degree", + "clean" + ], + "error": null + }, + { + "case": "21_degree_clean", + "doc_type": "degree", + "field": "student_name", + "expected": "USMAN SHAH", + "actual": "USMAN SHAH", + "status": "ok", + "correct": true, + "confidence": 0.9996, + "cer": 0.0, + "tags": [ + "degree", + "clean" + ], + "error": null + }, + { + "case": "21_degree_clean", + "doc_type": "degree", + "field": "father_name", + "expected": "BILAL QURESHI", + "actual": "BILAL QURESHI", + "status": "ok", + "correct": true, + "confidence": 0.9996, + "cer": 0.0, + "tags": [ + "degree", + "clean" + ], + "error": null + }, + { + "case": "21_degree_clean", + "doc_type": "degree", + "field": "degree", + "expected": "Master of Business Administration", + "actual": "Master of Business Administration", + "status": "ok", + "correct": true, + "confidence": 0.9999, + "cer": 0.0, + "tags": [ + "degree", + "clean" + ], + "error": null + }, + { + "case": "21_degree_clean", + "doc_type": "degree", + "field": "serial_number", + "expected": "297863", + "actual": "297863", + "status": "ok", + "correct": true, + "confidence": 0.9989, + "cer": null, + "tags": [ + "degree", + "clean" + ], + "error": null + }, + { + "case": "21_degree_clean", + "doc_type": "degree", + "field": "registration_number", + "expected": "EX/SP13-BCS-192", + "actual": "EX/SP13-BCS-192", + "status": "ok", + "correct": true, + "confidence": 0.9995, + "cer": null, + "tags": [ + "degree", + "clean" + ], + "error": null + }, + { + "case": "21_degree_clean", + "doc_type": "degree", + "field": "cgpa", + "expected": "3.26", + "actual": "3.26", + "status": "ok", + "correct": true, + "confidence": 0.9967, + "cer": null, + "tags": [ + "degree", + "clean" + ], + "error": null + }, + { + "case": "21_degree_clean", + "doc_type": "degree", + "field": "date_of_issue", + "expected": "18-07-2017", + "actual": "18-07-2017", + "status": "ok", + "correct": true, + "confidence": 0.998, + "cer": null, + "tags": [ + "degree", + "clean" + ], + "error": null + }, + { + "case": "21_degree_clean", + "doc_type": "degree", + "field": "year", + "expected": "2017", + "actual": "2017", + "status": "ok", + "correct": true, + "confidence": 0.998, + "cer": null, + "tags": [ + "degree", + "clean" + ], + "error": null + }, + { + "case": "22_degree_photo", + "doc_type": "degree", + "field": "institution", + "expected": "Example University of Technology", + "actual": "Example University of Technology", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": 0.0, + "tags": [ + "degree", + "photo" + ], + "error": null + }, + { + "case": "22_degree_photo", + "doc_type": "degree", + "field": "student_name", + "expected": "HASSAN ABBASI", + "actual": "HASSAN ABBASI", + "status": "ok", + "correct": true, + "confidence": 0.9997, + "cer": 0.0, + "tags": [ + "degree", + "photo" + ], + "error": null + }, + { + "case": "22_degree_photo", + "doc_type": "degree", + "field": "father_name", + "expected": "FAHAD SHAH", + "actual": "FAHAD SHAH", + "status": "ok", + "correct": true, + "confidence": 0.9997, + "cer": 0.0, + "tags": [ + "degree", + "photo" + ], + "error": null + }, + { + "case": "22_degree_photo", + "doc_type": "degree", + "field": "degree", + "expected": "Bachelor of Science in Computer Science", + "actual": "Bachelor of Science in Computer Science", + "status": "ok", + "correct": true, + "confidence": 0.9974, + "cer": 0.0, + "tags": [ + "degree", + "photo" + ], + "error": null + }, + { + "case": "22_degree_photo", + "doc_type": "degree", + "field": "serial_number", + "expected": "251776", + "actual": "251776", + "status": "ok", + "correct": true, + "confidence": 0.9923, + "cer": null, + "tags": [ + "degree", + "photo" + ], + "error": null + }, + { + "case": "22_degree_photo", + "doc_type": "degree", + "field": "registration_number", + "expected": "EX/SP16-BCS-042", + "actual": "EX/SP16-BCS-042", + "status": "ok", + "correct": true, + "confidence": 0.995, + "cer": null, + "tags": [ + "degree", + "photo" + ], + "error": null + }, + { + "case": "22_degree_photo", + "doc_type": "degree", + "field": "cgpa", + "expected": "2.06", + "actual": "2.06", + "status": "ok", + "correct": true, + "confidence": 0.9859, + "cer": null, + "tags": [ + "degree", + "photo" + ], + "error": null + }, + { + "case": "22_degree_photo", + "doc_type": "degree", + "field": "date_of_issue", + "expected": "03-10-2020", + "actual": "03-10-2020", + "status": "ok", + "correct": true, + "confidence": 0.9846, + "cer": null, + "tags": [ + "degree", + "photo" + ], + "error": null + }, + { + "case": "22_degree_photo", + "doc_type": "degree", + "field": "year", + "expected": "2020", + "actual": "2020", + "status": "ok", + "correct": true, + "confidence": 0.9846, + "cer": null, + "tags": [ + "degree", + "photo" + ], + "error": null + } + ] +} \ No newline at end of file diff --git a/benchmarks/run_benchmark.py b/benchmarks/run_benchmark.py new file mode 100644 index 0000000..49672b8 --- /dev/null +++ b/benchmarks/run_benchmark.py @@ -0,0 +1,218 @@ +""" +Accuracy benchmark for docling-pk. + + python benchmarks/run_benchmark.py benchmarks/synthetic/labels.json + python benchmarks/run_benchmark.py path/to/private/labels.json --backend rapidocr + +A labels file is JSON: ``{"cases": [{"id", "image", "doc_type", "expected": +{field: value, "subjects": [[name, max, obtained], ...]}, "tags": [...]}]}`` +with ``image`` relative to the labels file. ``image`` may also be a list +(e.g. CNIC front and back). + +Metrics, per field and overall: + +* **accuracy** exact match after normalization, over all expected fields. + A missing value counts as wrong. +* **coverage** fraction of expected fields that got any value. +* **precision** of the values that were returned, how many were right. + For KYC this matters more than accuracy: a ``None`` is safe (a human + looks at it), a wrong value is not. +* **ok-precision** precision restricted to fields with status ``ok``; the + number to quote if a pipeline auto-accepts ``ok`` fields. +* **CER** character error rate for free-text fields (names, addresses). +""" + +from __future__ import annotations + +import argparse +import json +import re +import statistics +import sys +import time +import unicodedata +from collections import defaultdict +from pathlib import Path +from typing import Any, Dict, List, Optional + +from rapidfuzz.distance import Levenshtein + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "src")) + +from docling_pk import extract # noqa: E402 + +TEXT_FIELDS = { + "name", + "father_name", + "student_name", + "address", + "permanent_address", + "institute", + "institution", + "degree", +} + + +def norm(field: str, value: Any) -> Optional[str]: + if value is None: + return None + v = unicodedata.normalize("NFKC", str(value)).strip() + if field.startswith("date"): + m = re.search(r"(\d{1,2})\D+(\d{1,2})\D+(\d{4})", v) + return f"{int(m.group(1)):02d}-{int(m.group(2)):02d}-{m.group(3)}" if m else v + if field in ("total_marks", "max_marks") or field.endswith("_number"): + v = re.sub(r"\s+", "", v) + v = re.sub(r"[\s,.]+", " ", v).upper().strip() + v = v.replace("،", " ").replace("۔", " ") + return re.sub(r"\s+", " ", v) + + +def cer(expected: str, actual: Optional[str]) -> float: + e = norm("x", expected) or "" + a = norm("x", actual) or "" + return Levenshtein.distance(e, a) / max(len(e), 1) + + +def score_subjects(expected: List, actual: List[Dict]) -> Dict[str, int]: + got = {} + for r in actual or []: + got.setdefault(re.sub(r"[^A-Z]", "", str(r.get("subject", "")).upper()), r) + correct = 0 + for name, mx, ob in expected: + key = re.sub(r"[^A-Z]", "", name.upper()) + r = got.get(key) + if r is None: + # fuzzy subject name match + best = min(got, key=lambda k: Levenshtein.distance(k, key), default=None) + if best is not None and Levenshtein.distance(best, key) <= max(2, len(key) // 6): + r = got[best] + if r is not None and r.get("max_marks") == mx and r.get("obtained_marks") == ob: + correct += 1 + return {"rows_correct": correct, "rows_expected": len(expected), "rows_returned": len(actual or [])} + + +def run(labels_path: Path, backend: str, out: Optional[Path], limit_tags: Optional[str]) -> Dict: + data = json.loads(labels_path.read_text(encoding="utf-8")) + base = labels_path.parent + rows = [] + subj_tot = defaultdict(int) + timings = [] + per_case = [] + for case in data["cases"]: + if limit_tags and not set(limit_tags.split(",")) & set(case.get("tags", [])): + continue + imgs = case["image"] if isinstance(case["image"], list) else [case["image"]] + paths = [str(base / p) for p in imgs] + t0 = time.perf_counter() + try: + result = extract(paths if len(paths) > 1 else paths[0], document_type=case["doc_type"], backend=backend) + fields = {k: v for k, v in result.fields.items()} + tables = result.tables + err = None + except Exception as exc: # report, keep going + fields, tables, err = {}, {}, f"{type(exc).__name__}: {exc}" + dt = time.perf_counter() - t0 + timings.append(dt) + case_ok = 0 + for fname, exp in case["expected"].items(): + if fname == "subjects": + s = score_subjects(exp, tables.get("subjects", [])) + for k, v in s.items(): + subj_tot[k] += v + continue + f = fields.get(fname) + got = f.value if f is not None else None + status = f.status.value if f is not None else "absent" + correct = norm(fname, got) == norm(fname, exp) + case_ok += correct + rows.append( + { + "case": case["id"], + "doc_type": case["doc_type"], + "field": fname, + "expected": exp, + "actual": got, + "status": status, + "correct": correct, + "confidence": f.confidence if f is not None else 0.0, + "cer": cer(exp, got) if fname in TEXT_FIELDS else None, + "tags": case.get("tags", []), + "error": err, + } + ) + n = len([k for k in case["expected"] if k != "subjects"]) + per_case.append((case["id"], case_ok, n, dt, err)) + print(f"{case['id']:32s} {case_ok:>3}/{n:<3} {dt:6.1f}s {err or ''}", flush=True) + + def summarize(rs): + n = len(rs) + returned = [r for r in rs if r["actual"] is not None] + okr = [r for r in rs if r["status"] == "ok"] + return { + "n": n, + "accuracy": sum(r["correct"] for r in rs) / n if n else 0.0, + "coverage": len(returned) / n if n else 0.0, + "precision": (sum(r["correct"] for r in returned) / len(returned)) if returned else None, + "ok_precision": (sum(r["correct"] for r in okr) / len(okr)) if okr else None, + "ok_count": len(okr), + } + + report: Dict[str, Any] = { + "labels": str(labels_path.name), + "backend": backend, + "overall": summarize(rows), + "by_doc_type": {}, + "by_field": {}, + "subjects": dict(subj_tot), + "seconds_per_document": { + "mean": statistics.mean(timings) if timings else 0, + "max": max(timings) if timings else 0, + }, + "cases": [ + {"id": c, "correct": k, "total": n, "seconds": round(t, 2), "error": e} for c, k, n, t, e in per_case + ], + } + by_type = defaultdict(list) + by_field = defaultdict(list) + for r in rows: + by_type[r["doc_type"]].append(r) + by_field[f"{r['doc_type']}.{r['field']}"].append(r) + report["by_doc_type"] = {k: summarize(v) for k, v in sorted(by_type.items())} + report["by_field"] = {k: summarize(v) for k, v in sorted(by_field.items())} + text_cer = [r["cer"] for r in rows if r["cer"] is not None] + report["mean_cer_text_fields"] = statistics.mean(text_cer) if text_cer else None + + o = report["overall"] + print( + f"\nOVERALL accuracy {o['accuracy']:.1%} coverage {o['coverage']:.1%} " + f"precision {o['precision'] or 0:.1%} ok-precision {o['ok_precision'] or 0:.1%} (n_ok={o['ok_count']})" + ) + for k, v in report["by_doc_type"].items(): + print(f" {k:14s} accuracy {v['accuracy']:.1%} precision {v['precision'] or 0:.1%} (n={v['n']})") + if subj_tot: + print(f" subjects rows correct {subj_tot['rows_correct']}/{subj_tot['rows_expected']}") + print("\nPer field:") + for k, v in report["by_field"].items(): + print( + f" {k:40s} {v['accuracy']:6.1%} prec {v['precision'] if v['precision'] is not None else float('nan'):6.1%} n={v['n']}" + ) + if out: + report["rows"] = rows + out.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") + print(f"\nWrote {out}") + return report + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + ap.add_argument("labels", type=Path) + ap.add_argument("--backend", default="auto") + ap.add_argument("--out", type=Path, default=None, help="Write the full JSON report here.") + ap.add_argument("--tags", default=None, help="Only cases with any of these comma-separated tags.") + args = ap.parse_args() + run(args.labels, args.backend, args.out, args.tags) + + +if __name__ == "__main__": + main() diff --git a/benchmarks/synthetic/generate.py b/benchmarks/synthetic/generate.py new file mode 100644 index 0000000..30b24ab --- /dev/null +++ b/benchmarks/synthetic/generate.py @@ -0,0 +1,571 @@ +""" +Synthetic document generator for tests and the public benchmark. + +Real CNICs and certificates contain personal data and must never be +committed, so the repository's fixtures are generated here instead. Every +image is fabricated from random data and stamped "SYNTHETIC SPECIMEN"; the +layouts reproduce *field positions and label wording* (what the parsers +depend on), not official artwork: there are no emblems, seals, photos, +holograms or security patterns that could make an image pass as a real +document. + + python benchmarks/synthetic/generate.py # writes tests/fixtures/synthetic + python benchmarks/synthetic/generate.py --seed 7 --out /tmp/synth --count 40 + +Requires Pillow and a TrueType font (Arial on Windows, DejaVu/Liberation on +Linux, Helvetica on macOS). Output is deterministic for a given seed and font. +""" + +from __future__ import annotations + +import argparse +import datetime as dt +import json +import random +from pathlib import Path +from typing import Dict, List, Tuple + +import numpy as np +from PIL import Image, ImageDraw, ImageFilter, ImageFont + +ROOT = Path(__file__).resolve().parents[2] +DEFAULT_OUT = ROOT / "tests" / "fixtures" / "synthetic" + +FIRST = [ + "Muhammad", + "Ahmed", + "Ali", + "Usman", + "Bilal", + "Hamza", + "Hassan", + "Zain", + "Fahad", + "Imran", + "Ayesha", + "Fatima", + "Zainab", + "Maryam", + "Hira", + "Sana", + "Amna", + "Iqra", + "Mahnoor", + "Rabia", +] +LAST = [ + "Khan", + "Ahmed", + "Hussain", + "Iqbal", + "Raza", + "Shah", + "Malik", + "Butt", + "Chaudhry", + "Qureshi", + "Siddiqui", + "Abbasi", + "Javed", + "Rehman", + "Akram", + "Aslam", + "Nawaz", + "Saleem", + "Tariq", + "Yousaf", +] +SCHOOLS = [ + "MODEL HIGH SCHOOL, SECTOR G-7, ISLAMABAD", + "CITY PUBLIC SCHOOL, SADDAR, RAWALPINDI", + "GOVT BOYS COLLEGE, MALL ROAD, LAHORE", + "FG GIRLS COLLEGE, CANTT, PESHAWAR", +] +SUBJECTS_SSC = [ + "ENGLISH (COMPULSORY)", + "URDU (COMPULSORY)", + "ISLAMIYAT (COMPULSORY)", + "PAKISTAN STUDIES", + "MATHEMATICS", + "PHYSICS", + "CHEMISTRY", + "BIOLOGY", +] +MAX_SSC = [150, 150, 100, 100, 150, 150, 150, 150] +SUBJECTS_HSSC = [ + "ENGLISH (COMPULSORY)", + "URDU (COMPULSORY)", + "ISLAMIC EDUCATION", + "PAKISTAN STUDIES", + "MATHEMATICS", + "PHYSICS", + "CHEMISTRY", +] +MAX_HSSC = [200, 200, 50, 50, 200, 200, 200] +ONES = [ + "", + "One", + "Two", + "Three", + "Four", + "Five", + "Six", + "Seven", + "Eight", + "Nine", + "Ten", + "Eleven", + "Twelve", + "Thirteen", + "Fourteen", + "Fifteen", + "Sixteen", + "Seventeen", + "Eighteen", + "Nineteen", +] +TENS = ["", "", "Twenty", "Thirty", "Forty", "Fifty", "Sixty", "Seventy", "Eighty", "Ninety"] +MONTHS = [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", +] + +STAMP = "SYNTHETIC SPECIMEN - NOT A REAL DOCUMENT" + + +def find_font(bold: bool = False) -> str: + candidates = ( + [ + "C:/Windows/Fonts/arialbd.ttf", + "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", + "/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf", + "/Library/Fonts/Arial Bold.ttf", + ] + if bold + else [ + "C:/Windows/Fonts/arial.ttf", + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", + "/Library/Fonts/Arial.ttf", + ] + ) + for c in candidates: + if Path(c).exists(): + return c + raise SystemExit("No TrueType font found; install DejaVu or Liberation fonts.") + + +def font(size: int, bold: bool = False) -> ImageFont.FreeTypeFont: + return ImageFont.truetype(find_font(bold), size) + + +def number_words(n: int) -> str: + parts = [] + if n >= 1000: + parts += [ONES[n // 1000], "Thousand"] + n %= 1000 + if n >= 100: + parts += [ONES[n // 100], "Hundred"] + n %= 100 + if n >= 20: + parts.append(TENS[n // 10]) + n %= 10 + if n: + parts.append(ONES[n]) + return " ".join(p for p in parts if p) + + +def person(rng: random.Random, female: bool = False) -> Tuple[str, str]: + first = rng.choice(FIRST[10:] if female else FIRST[:10]) + return f"{first} {rng.choice(LAST)}", f"{rng.choice(FIRST[:10])} {rng.choice(LAST)}" + + +def rand_date(rng: random.Random, y0: int, y1: int) -> dt.date: + start = dt.date(y0, 1, 1).toordinal() + return dt.date.fromordinal(rng.randint(start, dt.date(y1, 12, 28).toordinal())) + + +def cnic_number(rng: random.Random, female: bool) -> str: + digits = [str(rng.randint(1, 7))] + [str(rng.randint(0, 9)) for _ in range(11)] + last = rng.choice("2468" if female else "13579") + d = "".join(digits) + last + return f"{d[:5]}-{d[5:12]}-{d[12]}" + + +# --------------------------------------------------------------- renderers + + +def render_cnic_front(rng: random.Random) -> Tuple[Image.Image, Dict[str, str]]: + female = rng.random() < 0.4 + name, father = person(rng, female) + number = cnic_number(rng, female) + dob = rand_date(rng, 1950, 2006) + doi = rand_date(rng, max(dob.year + 18, 2012), 2025) + doe = doi.replace(year=doi.year + rng.choice([5, 7, 10])) + W, H = 1000, 630 + img = Image.new("RGB", (W, H), (222, 240, 214)) + d = ImageDraw.Draw(img) + for y in range(0, H, 6): # faint background lines, not a security pattern + d.line([(0, y), (W, y)], fill=(214, 233, 207)) + d.text((240, 30), "PAKISTAN", font=font(46, True), fill=(30, 40, 50)) + d.text((520, 42), "National Identity Card", font=font(30, True), fill=(30, 40, 50)) + d.text((240, 88), "ISLAMIC REPUBLIC OF PAKISTAN", font=font(14, True), fill=(40, 50, 60)) + d.rectangle([760, 150, 960, 400], outline=(150, 160, 150), width=2) # photo placeholder (empty) + d.text((790, 265), "NO PHOTO", font=font(20), fill=(150, 160, 150)) + lab, val = font(19), font(31) + + def field(x, y, label, value): + d.text((x, y), label, font=lab, fill=(60, 70, 80)) + d.text((x + 8, y + 24), value, font=val, fill=(20, 20, 20)) + + field(270, 125, "Name", name) + field(270, 225, "Father Name", father) + field(270, 330, "Gender", "F" if female else "M") + field(400, 330, "Country of Stay", "Pakistan") + field(270, 410, "Identity Number", number) + field(570, 410, "Date of Birth", dob.strftime("%d.%m.%Y")) + field(270, 490, "Date of Issue", doi.strftime("%d.%m.%Y")) + field(570, 490, "Date of Expiry", doe.strftime("%d.%m.%Y")) + d.text((760, 560), "Holder's Signature", font=font(16), fill=(60, 70, 80)) + d.text((20, H - 32), STAMP, font=font(16, True), fill=(200, 60, 60)) + expected = { + "name": name, + "father_name": father, + "gender": "F" if female else "M", + "country_of_stay": "Pakistan", + "cnic_number": number, + "date_of_birth": dob.strftime("%d-%m-%Y"), + "date_of_issue": doi.strftime("%d-%m-%Y"), + "date_of_expiry": doe.strftime("%d-%m-%Y"), + } + return img, expected + + +def render_cnic_back(rng: random.Random) -> Tuple[Image.Image, Dict[str, str]]: + number = cnic_number(rng, rng.random() < 0.4) + W, H = 1000, 630 + img = Image.new("RGB", (W, H), (228, 242, 220)) + d = ImageDraw.Draw(img) + d.text((660, 30), number, font=font(34), fill=(20, 20, 20)) + d.rectangle([700, 100, 920, 320], outline=(40, 40, 40), width=3) # QR placeholder + d.text((720, 340), str(rng.randint(10**11, 10**12 - 1)), font=font(22), fill=(30, 30, 30)) + d.text((40, 420), "Registrar General of Pakistan", font=font(18), fill=(60, 60, 60)) + d.text((20, H - 32), STAMP, font=font(16, True), fill=(200, 60, 60)) + return img, {"cnic_number": number} + + +def render_certificate(rng: random.Random, level: str) -> Tuple[Image.Image, Dict]: + female = rng.random() < 0.5 + name, father = person(rng, female) + name, father = name.upper(), father.upper() + subjects = SUBJECTS_SSC if level == "matric" else SUBJECTS_HSSC + maxes = MAX_SSC if level == "matric" else MAX_HSSC + obtained = [rng.randint(int(m * 0.4), m) for m in maxes] + total_max, total_ob = sum(maxes), sum(obtained) + year = rng.randint(2015, 2025) + dob = rand_date(rng, year - 18, year - 15) + serial = str(rng.randint(10**6, 10**7 - 1)) + roll = str(rng.randint(10**5, 10**7 - 1)) + reg = str(rng.randint(10**9, 10**10 - 1)) + cert = f"{rng.randint(10**8, 10**9 - 1)}/{rng.randint(10**5, 10**6 - 1)}" + grade = rng.choice(["A1", "A", "B", "C"]) + group = rng.choice(["SCIENCE", "GENERAL"]) if level == "matric" else rng.choice(["PRE-MEDICAL", "PRE-ENGINEERING"]) + school = rng.choice(SCHOOLS) + title = ( + "SECONDARY SCHOOL CERTIFICATE EXAMINATION" + if level == "matric" + else "HIGHER SECONDARY SCHOOL CERTIFICATE EXAMINATION" + ) + award = "Secondary School Certificate" if level == "matric" else "Higher Secondary School Certificate" + + W, H = 1240, 1700 + img = Image.new("RGB", (W, H), (250, 250, 246)) + d = ImageDraw.Draw(img) + f, fb, fs = font(24), font(24, True), font(20) + d.text( + (W // 2, 70), + "FEDERAL BOARD OF INTERMEDIATE AND SECONDARY EDUCATION", + font=font(34), + fill=(20, 20, 20), + anchor="mm", + ) + d.text((W // 2, 115), "ISLAMABAD", font=font(32), fill=(20, 20, 20), anchor="mm") + d.text((90, 190), "Serial No.", font=f, fill=(30, 30, 30)) + d.text((230, 185), serial, font=font(28), fill=(170, 40, 40)) + d.text((760, 190), "Certificate No.", font=f, fill=(30, 30, 30)) + d.text((950, 190), cert, font=f, fill=(20, 20, 20)) + d.text((90, 260), "Roll No.", font=f, fill=(30, 30, 30)) + d.text((230, 260), roll, font=fb, fill=(20, 20, 20)) + d.text((760, 260), "Registration No.", font=f, fill=(30, 30, 30)) + d.text((970, 260), reg, font=f, fill=(20, 20, 20)) + d.text((90, 330), "Group", font=f, fill=(30, 30, 30)) + d.text((230, 330), group, font=f, fill=(20, 20, 20)) + d.text((760, 330), "Attempt(s)", font=f, fill=(30, 30, 30)) + d.text((970, 330), "FIRST", font=f, fill=(20, 20, 20)) + d.text((W // 2, 420), title, font=fb, fill=(20, 20, 20), anchor="mm") + d.text((W // 2, 455), f"ANNUAL {year}", font=fb, fill=(20, 20, 20), anchor="mm") + y = 520 + lines = [ + ("Certified that", name), + ("Son / Daughter of", father), + ("whose date of birth is", dob.strftime("%d-%m-%Y")), + ("has qualified for award of", award), + ("at the examination held in the month(s) of", "March / April as a Regular Candidate from"), + ] + for label, value in lines: + d.text((90, y), label, font=f, fill=(30, 30, 30)) + lw = d.textlength(label + " ", font=f) + d.text((90 + lw, y), value, font=fb if value.isupper() else f, fill=(20, 20, 20)) + y += 48 + d.text((90, y), school, font=f, fill=(20, 20, 20)) + y += 48 + d.text((90, y), f"as per statement of marks given below and has obtained grade {grade}", font=f, fill=(30, 30, 30)) + y += 70 + d.text((W // 2, y), "SUBJECT-WISE STATEMENT OF MARKS", font=fb, fill=(20, 20, 20), anchor="mm") + y += 40 + x_sn, x_sub, x_max, x_ob, x_end = 80, 170, 760, 960, 1160 + d.rectangle([x_sn, y, x_end, y + 70], outline=(20, 20, 20), width=2) + d.text((x_sn + 15, y + 22), "S.No.", font=fb, fill=(20, 20, 20)) + d.text((x_sub + 200, y + 22), "Subject(s)", font=fb, fill=(20, 20, 20)) + d.text((x_max + 50, y + 5), "MARKS", font=fb, fill=(20, 20, 20)) + d.text((x_max + 20, y + 38), "Maximum", font=fs, fill=(20, 20, 20)) + d.text((x_ob + 30, y + 38), "Obtained", font=fs, fill=(20, 20, 20)) + y += 70 + for i, (s, m, o) in enumerate(zip(subjects, maxes, obtained), 1): + d.rectangle([x_sn, y, x_end, y + 46], outline=(20, 20, 20), width=1) + d.text((x_sn + 25, y + 11), str(i), font=fs, fill=(20, 20, 20)) + d.text((x_sub + 10, y + 11), s, font=fs, fill=(20, 20, 20)) + d.text((x_max + 50, y + 11), f"{m:03d}", font=fs, fill=(20, 20, 20)) + d.text((x_ob + 50, y + 11), f"{o:03d}", font=fs, fill=(20, 20, 20)) + y += 46 + d.rectangle([x_sn, y, x_end, y + 50], outline=(20, 20, 20), width=2) + d.text((x_sub + 250, y + 12), "TOTAL", font=fb, fill=(20, 20, 20)) + d.text((x_max + 45, y + 12), str(total_max), font=fb, fill=(20, 20, 20)) + d.text((x_ob + 50, y + 12), str(total_ob), font=fb, fill=(20, 20, 20)) + y += 90 + d.text((90, y), "(Marks in words)", font=f, fill=(30, 30, 30)) + d.text((310, y), number_words(total_ob), font=fb, fill=(20, 20, 20)) + y += 60 + issued = rand_date(rng, year, year) + d.text( + (90, y), + f"Islamabad Dated {MONTHS[issued.month - 1]} {issued.day:02d}, {issued.year}", + font=f, + fill=(30, 30, 30), + ) + d.text((40, H - 40), STAMP, font=font(20, True), fill=(200, 60, 60)) + expected = { + "board": "FBISE", + "serial_number": serial, + "roll_number": roll, + "registration_number": reg, + "certificate_number": cert, + "group": group, + "year": str(year), + "grade": grade, + "student_name": name, + "father_name": father, + "date_of_birth": dob.strftime("%d-%m-%Y"), + "institute": school, + "max_marks": str(total_max), + "total_marks": str(total_ob), + "subjects": [[s, m, o] for s, m, o in zip(subjects, maxes, obtained)], + } + return img, expected + + +def render_degree(rng: random.Random) -> Tuple[Image.Image, Dict]: + female = rng.random() < 0.5 + name, father = person(rng, female) + rel = "d/o" if female else "s/o" + degree = rng.choice( + [ + "Bachelor of Science in Computer Science", + "Bachelor of Science in Electrical Engineering", + "Master of Business Administration", + "Bachelor of Science in Artificial Intelligence", + ] + ) + uni = rng.choice(["Example University of Technology", "Sample Institute of Engineering and Technology"]) + year = rng.randint(2015, 2025) + issued = rand_date(rng, year, year) + serial = str(rng.randint(10**5, 10**6 - 1)) + reg = f"EX/SP{year % 100 - 4:02d}-BCS-{rng.randint(1, 199):03d}" + cgpa = f"{rng.uniform(2.0, 4.0):.2f}" + W, H = 1400, 1000 + img = Image.new("RGB", (W, H), (252, 250, 240)) + d = ImageDraw.Draw(img) + d.text((80, 80), f"Serial No: {serial}", font=font(24), fill=(150, 40, 40)) + d.text((900, 80), f"Registration No: {reg}", font=font(22), fill=(30, 30, 30)) + d.text((W // 2, 200), uni, font=font(50, True), fill=(40, 40, 40), anchor="mm") + d.text((W // 2, 320), f"{name.upper()} {rel} {father.upper()}", font=font(32, True), fill=(20, 20, 20), anchor="mm") + d.text((W // 2, 390), "has been conferred upon the degree of", font=font(26), fill=(30, 30, 30), anchor="mm") + d.text((W // 2, 460), degree, font=font(38, True), fill=(20, 20, 20), anchor="mm") + d.text((W // 2, 530), f"with CGPA {cgpa}", font=font(26), fill=(30, 30, 30), anchor="mm") + d.text( + (180, 640), + f"Date of Issuance: {issued.day} {MONTHS[issued.month - 1]} {issued.year}", + font=font(26), + fill=(30, 30, 30), + ) + d.text((40, H - 40), STAMP, font=font(20, True), fill=(200, 60, 60)) + expected = { + "institution": uni, + "student_name": name.upper(), + "father_name": father.upper(), + "degree": degree, + "serial_number": serial, + "registration_number": reg, + "cgpa": cgpa, + "date_of_issue": issued.strftime("%d-%m-%Y"), + "year": str(issued.year), + } + return img, expected + + +# ----------------------------------------------------------- augmentations + + +def place_on_background(rng: random.Random, img: Image.Image, pad: float = 0.25) -> Image.Image: + W, H = img.size + bw, bh = int(W * (1 + pad)), int(H * (1 + pad)) + base = rng.choice([(90, 90, 100), (140, 120, 100), (200, 200, 205)]) + bg = Image.new("RGB", (bw, bh), base) + d = ImageDraw.Draw(bg) + for x in range(0, bw, 40): # striped cloth, like the real sample backgrounds + d.rectangle([x, 0, x + 18, bh], fill=tuple(max(0, c - 35) for c in base)) + bg.paste(img, ((bw - W) // 2, (bh - H) // 2)) + return bg + + +def augment(rng: random.Random, img: Image.Image, kind: str) -> Image.Image: + if kind == "clean": + return img + if kind == "rotated90": + return img.rotate(90, expand=True) + if kind == "rotated270": + return img.rotate(-90, expand=True) + if kind == "upside_down": + return img.rotate(180, expand=True) + if kind == "skewed": + return place_on_background(rng, img).rotate(rng.choice([-6, -4, 4, 6]), expand=True, fillcolor=(90, 90, 100)) + if kind == "photo": + out = place_on_background(rng, img).rotate(rng.uniform(-2, 2), expand=True, fillcolor=(90, 90, 100)) + arr = np.asarray(out).astype(np.float32) + h, w = arr.shape[:2] + grad = np.linspace(0.8, 1.1, w)[None, :, None] # uneven lighting + arr = np.clip(arr * grad + np.random.default_rng(rng.randint(0, 9999)).normal(0, 6, arr.shape), 0, 255) + return Image.fromarray(arr.astype(np.uint8)).filter(ImageFilter.GaussianBlur(0.8)) + if kind == "low_res": + w, h = img.size + return img.resize((w * 45 // 100, h * 45 // 100), Image.BILINEAR) + if kind == "blurry": + return img.filter(ImageFilter.GaussianBlur(1.6)) + if kind == "very_blurry": + return img.filter(ImageFilter.GaussianBlur(7)) + raise ValueError(kind) + + +def occlude(img: Image.Image, box: Tuple[int, int, int, int]) -> Image.Image: + out = img.copy() + ImageDraw.Draw(out).ellipse(box, fill=(70, 70, 160)) # an ink stamp over the field + return out + + +# ------------------------------------------------------------------- main + + +PLAN: List[Tuple[str, str]] = [ + ("cnic_front", "clean"), + ("cnic_front", "clean"), + ("cnic_front", "rotated90"), + ("cnic_front", "rotated270"), + ("cnic_front", "upside_down"), + ("cnic_front", "skewed"), + ("cnic_front", "photo"), + ("cnic_front", "photo"), + ("cnic_front", "low_res"), + ("cnic_front", "blurry"), + ("cnic_front", "very_blurry"), + ("cnic_front", "occluded_name"), + ("cnic_back", "clean"), + ("cnic_back", "photo"), + ("matric", "clean"), + ("matric", "rotated90"), + ("matric", "photo"), + ("matric", "low_res"), + ("intermediate", "clean"), + ("intermediate", "skewed"), + ("intermediate", "photo"), + ("degree", "clean"), + ("degree", "photo"), +] + + +def generate(out: Path, seed: int, count: int) -> Path: + rng = random.Random(seed) + out.mkdir(parents=True, exist_ok=True) + cases = [] + plan = (PLAN * (count // len(PLAN) + 1))[:count] + for i, (doc, kind) in enumerate(plan): + if doc == "cnic_front": + img, exp = render_cnic_front(rng) + dtype = "cnic" + elif doc == "cnic_back": + img, exp = render_cnic_back(rng) + dtype = "cnic" + elif doc in ("matric", "intermediate"): + img, exp = render_certificate(rng, doc) + dtype = doc + else: + img, exp = render_degree(rng) + dtype = "degree" + expect_missing: List[str] = [] + if kind == "occluded_name": + img = occlude(img, (250, 140, 640, 215)) + expect_missing = ["name"] + exp = {k: v for k, v in exp.items() if k != "name"} + kind_applied = "clean" + else: + kind_applied = kind + img = augment(rng, img, kind_applied) + name = f"{i:02d}_{doc}_{kind}.jpg" + img.save(out / name, quality=rng.choice([72, 80, 85])) + case = {"id": f"{i:02d}_{doc}_{kind}", "image": name, "doc_type": dtype, "tags": [doc, kind], "expected": exp} + if expect_missing: + case["expect_missing"] = expect_missing + if kind == "very_blurry": + case["tags"].append("unreadable") + cases.append(case) + labels = out / "labels.json" + labels.write_text( + json.dumps( + {"_note": "Synthetic data. Generated by benchmarks/synthetic/generate.py", "seed": seed, "cases": cases}, + indent=1, + ), + encoding="utf-8", + ) + return labels + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + ap.add_argument("--out", type=Path, default=DEFAULT_OUT) + ap.add_argument("--seed", type=int, default=2026) + ap.add_argument("--count", type=int, default=len(PLAN)) + args = ap.parse_args() + print(f"Wrote {generate(args.out, args.seed, args.count)}") + + +if __name__ == "__main__": + main() diff --git a/cli.py b/cli.py deleted file mode 100644 index f23d763..0000000 --- a/cli.py +++ /dev/null @@ -1,67 +0,0 @@ -""" -Command line interface for docling-pk. - -Real extraction is not wired in yet. Every command validates that the -given image path actually exists, then returns a placeholder result -with a non-zero exit code, so callers (and CI) can tell "not -implemented" apart from "succeeded", rather than a silent success on -input that was never read. -""" - -import json -import sys -from pathlib import Path -import typer - -app = typer.Typer(help="Extract structured data from Pakistani identity and education documents.") - -NOT_IMPLEMENTED_EXIT_CODE = 1 - - -def _placeholder_result(document_type: str) -> dict: - return { - "document_type": document_type, - "fields": {}, - "confidence": 0.0, - "warnings": ["not implemented yet, this is placeholder output"], - "raw_text": None, - } - - -def _run_placeholder(document_type: str, image_path: str) -> None: - path = Path(image_path) - if not path.exists(): - typer.echo(f"Error: image path does not exist: {image_path}", err=True) - raise typer.Exit(code=2) - - result = _placeholder_result(document_type) - typer.echo(json.dumps(result, indent=2)) - raise typer.Exit(code=NOT_IMPLEMENTED_EXIT_CODE) - - -@app.command() -def cnic(image_path: str = typer.Argument(..., help="Path to a CNIC image file.")): - """Extract fields from a CNIC image. Not implemented yet, exits non-zero.""" - _run_placeholder("cnic", image_path) - - -@app.command() -def matric(image_path: str = typer.Argument(..., help="Path to a Matric certificate image file.")): - """Extract fields from a Matric certificate image. Not implemented yet, exits non-zero.""" - _run_placeholder("matric", image_path) - - -@app.command() -def intermediate(image_path: str = typer.Argument(..., help="Path to an Intermediate certificate image file.")): - """Extract fields from an Intermediate certificate image. Not implemented yet, exits non-zero.""" - _run_placeholder("intermediate", image_path) - - -@app.command() -def degree(image_path: str = typer.Argument(..., help="Path to a Degree/Transcript image file.")): - """Extract fields from a Degree/Transcript image. Not implemented yet, exits non-zero.""" - _run_placeholder("degree", image_path) - - -if __name__ == "__main__": - app() \ No newline at end of file diff --git a/docling_pk/extractor.py b/docling_pk/extractor.py deleted file mode 100644 index e87a954..0000000 --- a/docling_pk/extractor.py +++ /dev/null @@ -1,92 +0,0 @@ -""" -Main entry point for docling-pk. extract() ties preprocessing, OCR, and -the correct document-type parser together into one DocumentResult. -""" - -from docling_pk.preprocessor import preprocess, TooBlurryError -from docling_pk.ocr import run_ocr -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") - -# CNIC needs Urdu for label text; certificates read better English-only, -# since the combined en+ur model introduces noise into English-heavy text. -LANGUAGES_BY_TYPE = { - "cnic": ("en", "ur"), - "matric": ("en",), - "intermediate": ("en",), -} - -PARSERS = { - "cnic": cnic, - "matric": matric, - "intermediate": intermediate, -} - - -def extract(image_path: str, document_type: str) -> DocumentResult: - """ - Extracts structured fields from a document image. - - Args: - image_path: path to the image file. - document_type: one of "cnic", "matric", "intermediate". For - matric/intermediate, the actual sub-type is confirmed - against the certificate's own title text after OCR, since - the two document types share a parser structure and the - title is the reliable way to tell them apart. - - Returns: - A DocumentResult with extracted fields, an overall confidence - score (averaged across all fields, including ones not found), - and any warnings, including a "too blurry" warning if the - image could not be reliably processed. - - Raises: - ValueError: if document_type is not one of the supported types, - or if the image file is not a valid image. - FileNotFoundError: if image_path does not exist. - """ - if document_type not in SUPPORTED_TYPES: - raise ValueError( - f"Unsupported document_type: {document_type!r}. " - f"Must be one of {SUPPORTED_TYPES}." - ) - - try: - image = preprocess(image_path) - except TooBlurryError as e: - return DocumentResult( - document_type=document_type, - fields={}, - confidence=0.0, - warnings=[str(e)], - raw_text=None, - ) - - languages = LANGUAGES_BY_TYPE[document_type] - raw_text, _ = run_ocr(image, languages=languages) - - if document_type in ("matric", "intermediate"): - actual_type = detect_document_type(raw_text) - parser = PARSERS[actual_type] - document_type = actual_type - else: - parser = PARSERS[document_type] - - fields = parser.extract(raw_text) - - warnings = [f"{name} not found" for name, result in fields.items() if result.value is None] - confidences = [result.confidence for result in fields.values()] - overall_confidence = sum(confidences) / len(confidences) if confidences else 0.0 - - return DocumentResult( - document_type=document_type, - fields=fields, - confidence=round(overall_confidence, 2), - warnings=warnings, - raw_text=raw_text, - ) \ No newline at end of file diff --git a/docling_pk/ocr.py b/docling_pk/ocr.py deleted file mode 100644 index 4c551a4..0000000 --- a/docling_pk/ocr.py +++ /dev/null @@ -1,65 +0,0 @@ -""" -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. - - Args: - image: single-channel numpy array from preprocessor.preprocess(). - languages: EasyOCR language list. Use ("en",) for English-only - documents like certificates. - - 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 - - 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 best_text, best_confidence diff --git a/docling_pk/parsers/__init__.py b/docling_pk/parsers/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/docling_pk/parsers/_federal_board.py b/docling_pk/parsers/_federal_board.py deleted file mode 100644 index afb5651..0000000 --- a/docling_pk/parsers/_federal_board.py +++ /dev/null @@ -1,172 +0,0 @@ -""" -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: - if re.search(r"higher\s+secondary\s+school\s+certificate", raw_text, re.IGNORECASE): - return "intermediate" - return "matric" diff --git a/docling_pk/parsers/cnic.py b/docling_pk/parsers/cnic.py deleted file mode 100644 index 7d59513..0000000 --- a/docling_pk/parsers/cnic.py +++ /dev/null @@ -1,147 +0,0 @@ -""" -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: 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. - """ - 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) - - return FieldResult(value=None, confidence=0.0) - - -def extract(raw_text: str) -> dict[str, FieldResult]: - """ - Extracts all CNIC fields from raw OCR text. Missing fields return - FieldResult(None, 0.0) rather than raising. - """ - lines = raw_text.splitlines() - - 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 { - "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), - } diff --git a/docling_pk/parsers/intermediate.py b/docling_pk/parsers/intermediate.py deleted file mode 100644 index cb12fc7..0000000 --- a/docling_pk/parsers/intermediate.py +++ /dev/null @@ -1,18 +0,0 @@ -""" -Intermediate (Higher Secondary School Certificate) extraction, Federal -Board Islamabad format. No date_of_birth field, this board does not -print it on the Intermediate certificate. -""" - -from docling_pk.schema import FieldResult -from docling_pk.parsers._federal_board import extract_common_fields - -FIELD_NAMES = [ - "serial_number", "certificate_number", "roll_number", "registration_number", - "group", "session_year", "grade", "name", "father_name", "institute", - "total_marks_possible", "total_marks_obtained", -] - - -def extract(raw_text: str) -> dict[str, FieldResult]: - return extract_common_fields(raw_text) diff --git a/docling_pk/parsers/matric.py b/docling_pk/parsers/matric.py deleted file mode 100644 index 9f6565c..0000000 --- a/docling_pk/parsers/matric.py +++ /dev/null @@ -1,21 +0,0 @@ -""" -Matric (Secondary School Certificate) extraction, Federal Board Islamabad -format. Adds date_of_birth on top of the shared fields, since this board -prints DOB on the Matric certificate but not on the Intermediate one. -""" - -from docling_pk.schema import FieldResult -from docling_pk.parsers._federal_board import extract_common_fields, DOB - -FIELD_NAMES = [ - "serial_number", "certificate_number", "roll_number", "registration_number", - "group", "session_year", "grade", "name", "father_name", "institute", - "date_of_birth", "total_marks_possible", "total_marks_obtained", -] - - -def extract(raw_text: str) -> dict[str, FieldResult]: - fields = extract_common_fields(raw_text) - match = DOB.search(raw_text) - fields["date_of_birth"] = FieldResult(value=match.group(1), confidence=0.8) if match else FieldResult(value=None, confidence=0.0) - return fields diff --git a/docling_pk/preprocessor.py b/docling_pk/preprocessor.py deleted file mode 100644 index 095b226..0000000 --- a/docling_pk/preprocessor.py +++ /dev/null @@ -1,101 +0,0 @@ -""" -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. 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 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. - - 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 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. - """ - 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, 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)) - - if coords.shape[0] < 50: - return gray - - angle = cv2.minAreaRect(coords)[-1] - if angle < -45: - angle = -(90 + angle) - else: - angle = -angle - - (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 diff --git a/docling_pk/schema.py b/docling_pk/schema.py deleted file mode 100644 index e7088c7..0000000 --- a/docling_pk/schema.py +++ /dev/null @@ -1,38 +0,0 @@ -""" -Output schema for docling-pk. - -Every parser (cnic, matric, intermediate, degree) returns a dict of -FieldResult objects, wrapped in a DocumentResult by the extractor. This -is the one shape every other module builds against, so a change here -affects the CLI, the tests, and every parser at once. -""" -from __future__ import annotations -from dataclasses import dataclass, field -from typing import Optional - - -@dataclass -class FieldResult: - """A single extracted field. value is None when the field could not - be reliably read, not a guess. confidence is 0.0 in that case.""" - value: Optional[str] - confidence: float - - -@dataclass -class DocumentResult: - """ - The full result of extracting one document. - - confidence is the average across all fields, including ones that - were not found, not just the ones that succeeded. An earlier - version of this averaged only the found fields, which reported a - misleadingly high score on a document that was actually missing - several fields. Averaging across all fields gives a lower, more - honest number, and is the version used throughout this project. - """ - document_type: str - fields: dict[str, FieldResult] - confidence: float - warnings: list[str] = field(default_factory=list) - raw_text: Optional[str] = None \ No newline at end of file diff --git a/docs/benchmark.md b/docs/benchmark.md new file mode 100644 index 0000000..2078bdb --- /dev/null +++ b/docs/benchmark.md @@ -0,0 +1,94 @@ +# Benchmark report (v1.0.0) + +## Method + +`benchmarks/run_benchmark.py` runs `extract()` on every case in a labels file +and compares each field to the hand-labelled value after light normalization +(case, spacing, punctuation; dates compared as `DD-MM-YYYY`). It reports: + +- **accuracy**: correct / all labelled fields (a missing value counts as wrong) +- **coverage**: fields that got any value +- **precision**: correct / returned values +- **ok-precision**: correct / values returned with status `ok`, the figure to use if a system auto-accepts `ok` fields +- **CER**: character error rate on free-text fields +- **subjects**: marks-table rows with subject, maximum and obtained marks all correct + +## Datasets + +| Set | Images | Fields | Where | +|---|---|---|---| +| Real photos | 16 (8 CNIC fronts, 2 CNIC backs, 2 Matric, 2 Intermediate, 1 degree, 1 transcript) | 146 + 30 marks rows | Private (personal data). Aggregates in [`benchmarks/results/real_samples_summary.json`](../benchmarks/results/real_samples_summary.json) | +| Synthetic | 23 (clean, rotated 90/180/270, skewed, photo-on-cloth, low-res, blurry, very blurry, occluded name, CNIC back, Matric, Intermediate, degree) | 213 + 53 marks rows | [`tests/fixtures/synthetic`](../tests/fixtures/synthetic), report in [`tests/benchmark_report.json`](../tests/benchmark_report.json) | + +Real-photo conditions: clean phone photos, 90/270° rotations, skew on striped +cloth, a plastic sleeve with glare, a small soft-focus card, motion blur, a +flatbed scan, and a digital certificate on micro-text security paper. + +## Results + +### Overall + +| Dataset / configuration | Accuracy | Coverage | Precision | ok-precision | Marks rows | s / doc | +|---|---|---|---|---|---|---| +| Real, v0.1 (original pipeline) | 34.9% | n/a | n/a | n/a | 0/30 | n/a | +| Real, v1 core (RapidOCR, CPU) | 94.4% | 97.2% | 97.1% | 98.5% | 30/30 | 3.7 | +| Real, v1 + `urdu` extra (EasyOCR verifier) | 94.4% | 97.9% | 96.4% | **99.2%** | 30/30 | 3.7 | +| Synthetic, v1 core (CPU only) | 96.2% | 96.2% | 100% | **100%** | 53/53 | 2.5 | +| Synthetic, v1 + `urdu` extra | 96.2% | 96.2% | 100% | **100%** | 53/53 | 2.6 | + +The full install's precision is slightly lower than the core's only because +it *attempts* the Urdu address (always flagged `low_confidence`); the core +install leaves it blank. + +### By document type (real photos) + +| Type | v0.1 | v1 | +|---|---|---| +| CNIC | 35.3% | 91.2% | +| Matric | 63.3% | 96.4% | +| Intermediate | 27.6% | 100% | +| Degree | 0% (unsupported) | 100% | +| Transcript | 0% (unsupported) | 90.0% | + +### CNIC fields (real photos, v1) + +| Field | Accuracy | Notes | +|---|---|---| +| cnic_number | 90% (9/10) | every returned value correct; the miss (motion blur) was left blank | +| date_of_birth / issue / expiry | 100% (8/8 each) | | +| gender | 100% | | +| name / father_name | 87.5% | misses: motion blur, soft focus | +| country_of_stay | 87.5% | | +| address (Urdu) | 0% exact, CER ≈ 0.49 | experimental, always `low_confidence` | + +Certificates: every field except one school name (a dropped space) was +correct, and all 30 subject rows (subject, maximum, obtained) were exact. + +## What moved the numbers + +Each step was measured on the real set (accuracy after the change): + +| Change | Accuracy | +|---|---| +| v0.1 baseline | 34.9% | +| Layout-aware parsers, new orientation scoring, quality warnings instead of rejection (EasyOCR) | 59.9% | +| RapidOCR engine + raised detector candidate cap + box-shape orientation | 80.3% | +| Cross-engine digit re-reads, garbled-number recovery | 83.8% | +| Positional/format fallbacks for unreadable labels, blackletter-tolerant degree patterns | 90.8% | +| Ink-only re-read for micro-text paper | 93.0% | +| Precision fixes (verifier on IDs, confidence floors) | 94.4% | + +## Caveats + +16 real images is a small sample; per-type figures on 1–2 documents are +indicative, not statistically strong. The synthetic set controls for privacy +and covers the brief's required conditions, but its layouts are simpler than +real cards. The most useful next step is more real, consented samples, +especially from Punjab, Sindh and KP boards. + +## Reproduce + +```bash +python benchmarks/run_benchmark.py tests/fixtures/synthetic/labels.json --out report.json +python benchmarks/run_benchmark.py path/to/private/labels.json --backend rapidocr +``` diff --git a/docs/design_doc.md b/docs/design_doc.md index 0c441fb..a71dd44 100644 --- a/docs/design_doc.md +++ b/docs/design_doc.md @@ -1,93 +1,179 @@ -# docling-pk Design Document - -## 1. Project Summary - -docling-pk is a Python library that reads Pakistani documents (CNIC, Matric, -Intermediate certificates) from a photo and returns structured data as JSON. -No cloud API needed. Built for KYC, admissions, and HR use cases in Pakistan. - -## 2. Problem Statement - -Document data entry in Pakistan is manual today. Banks, universities, and HR -teams type data from CNIC and certificate photos by hand. This is slow and -causes real errors. No open source tool exists for Pakistani document formats -specifically. docling-pk fills that gap. - -## 3. Technical Approach - -**Owner:** Abdul Moiz Muhammad - -### Stack - -- OCR: EasyOCR (chosen in the brief, supports Arabic script for Urdu labels, - no system install needed) -- Preprocessing: OpenCV (blur check, deskew, denoise, threshold) -- Parsing: regex based field extraction - -### What works (tested on 4 real CNIC photos) - -- Name, father name, CNIC number, all 3 dates: reliable -- Gender: works on some cards, fails on others (depends on print format) -- Address: not on the front of a Smart CNIC, out of scope unless back images - are added - -### Key fixes made during testing - -| Problem | Fix | -|---|---| -| Photo shot sideways | Try OCR at all 4 rotations, keep the best result | -| Blurry photo returns garbage | Detect blur early (Laplacian variance), reject with a clear message instead of guessing | -| Date labels and values not next to each other in OCR output | Match dates by format, assign by fixed order (birth, issue, expiry) | -| CNIC number separator misread (colon, underscore, backtick) | Normalize all variants to a dash before matching | -| 2x image upscaling (tested) | Made results worse, not better. Not used. | - -### Known limitations - -- Deskew fails when the background is cluttered (tested, confirmed on a - tilted photo against striped fabric). Works only with a plain background. -- Gender and multi-line names are not reliably extracted with the current - full page OCR plus regex method. -- A better approach would crop each field to its own region before OCR, - instead of reading the whole card at once. Being explored separately. - -## 4. Module Ownership - -All modules currently owned by Abdul Moiz Muhammad (solo). - -| Module | Purpose | +# docling-pk Design Document (v1.0) + +**Owner:** Abdul Moiz Muhammad (Lead, Research/Implementation and Integration/Evaluation roles) +**Status:** implemented in v1.0.0 + +## 1. Project summary + +docling-pk is a pip-installable Python library and CLI that turns a photo or +scan of a Pakistani document (CNIC, Matric or Intermediate certificate, +university degree or transcript) into structured, validated fields. Every +field comes with a confidence score, a status, and a plain-language reason +when it could not be read. It runs fully offline on a CPU and is meant as the +reusable foundation for KYC, admissions and HR pipelines in Pakistan. + +## 2. Problem statement + +Document data entry in Pakistan is manual. International OCR services return +unstructured text, do not know Pakistani layouts, and are not free or local. +The hard part is not reading characters. It is turning messy phone-photo OCR +output into the *right* value for each field and knowing when not to trust +it. A wrong CNIC digit changes a person's identity, and a wrong mark changes +an admission decision. + +## 3. Public API + +```python +extract(source, document_type="auto", *, backend="auto", urdu=True, verify=True, + gpu=None, auto_rotate=True, include_raw_text=True) -> DocumentResult +``` + +- `source`: path (image or PDF), bytes, NumPy array, PIL image, or a list of these (e.g. CNIC front + back). +- `DocumentResult`: `document_type`, `fields: dict[str, FieldResult]`, `confidence`, `warnings`, + `raw_text`, `tables` (marks), `metadata` (rotation, skew, quality, engines, timings), plus + `to_dict()`, `to_json()` and dict-style access matching the brief (`result["fields"]["name"]["value"]`). +- `FieldResult`: `value` (or `None`, never a guess), `confidence`, `status` + (`ok | low_confidence | invalid | not_found | not_applicable`), `reason`, `raw`, `as_date()`. +- Errors raise only for bad *calls*: `FileNotFoundError`, `ImageLoadError(ValueError)`, + `UnsupportedDocumentTypeError(ValueError)`. Bad *documents* never raise. +- CLI: `docling-pk extract | batch | info | --version`. + +The schema extends the brief's `FieldResult(value, confidence)` with `status` +and `reason`. The lab note's lesson was "record why a field failed, not just +that it did", and downstream systems need to tell "not printed on this side" +apart from "printed but unreadable". + +## 4. Architecture and module ownership + +| Module | Responsibility | |---|---| -| `preprocessor.py` | Load, blur check, deskew, denoise, threshold | -| `ocr.py` | EasyOCR wrapper with rotation correction | -| `parsers/cnic.py` | CNIC field extraction | -| `parsers/matric.py` | Matric extraction (not started) | -| `parsers/intermediate.py` | Intermediate extraction (not started) | -| `schema.py` | Output data structures | -| `extractor.py` | Main `extract()` function | -| `cli.py` | Command line interface | -| `pyproject.toml` | Packaging | - -## 5. Evaluation Plan - -- Test each document type against real sample photos: clean, blurry, rotated, - partially obscured. -- Track per-field success, not one overall accuracy number. Some fields - (gender, address) need separate tracking since they fail differently. -- Confidence score reflects extraction completeness across all fields, not - just the fields that succeeded. - -## 6. Known Risks - -- Blur and rotation handling calibrated on very few real samples so far. -- Matric and Intermediate parsers not started yet. -- Degree/Transcript scope is unclear: RoleGuide lists it, the Brief's - required feature list does not. Needs confirmation. - -## 7. Definition of Done (v1) - -- [ ] CNIC, Matric, Intermediate extraction working -- [ ] `extract()` function and CLI both working -- [ ] Confidence score and warnings on every result -- [ ] Blurry or unreadable images fail with a clear message, not a crash -- [ ] pytest suite, 90%+ coverage -- [ ] README with install steps, usage, and known limitations -- [ ] `pip install docling-pk` works in a clean Python 3.9+ environment \ No newline at end of file +| `extractor.py` | `extract()`: loading, routing, merging CNIC sides, overall confidence and warnings | +| `schema.py`, `errors.py` | Public data types and exceptions | +| `vision/image_io.py` | Paths (incl. non-ASCII Windows paths), bytes, arrays, PIL, PDF pages | +| `vision/quality.py` | Blur / exposure / glare / resolution measurements, as warnings | +| `vision/geometry.py` | Resizing, right-angle rotation, deskew from text-line angles, crops | +| `pipeline.py` | Orientation detection, deskew, OCR, micro-text filtering, ink-only variant, `Page.reread` | +| `ocr/` | `OCRBackend` interface; RapidOCR and EasyOCR adapters returning positioned `TextBox`es | +| `layout.py` | Fuzzy label matching and spatial queries (below, right of, lines) | +| `parsers/cnic.py`, `certificate.py`, `degree.py` | Per-document extraction | +| `validation/` | CNIC structure, dates, OCR digit look-alikes, marks and number words | +| `urdu.py` | Urdu code-point normalization and address lexicon correction | +| `cli.py` | Typer CLI | +| `benchmarks/` | Benchmark runner and synthetic data generator | + +## 5. Technical approach + +### 5.1 OCR engine: measured, not assumed + +The brief proposed EasyOCR. On the 16 real samples the same parsers scored +59.9% with EasyOCR and 80.3% with RapidOCR (PP-OCR v6 models on ONNX +Runtime) before engine-specific fixes. RapidOCR read a small, soft-focus CNIC +photo almost perfectly where EasyOCR returned noise. It is also ~370 MB +installed against ~2.5 GB+ for EasyOCR with PyTorch, and runs at about 2.5 s +per document on CPU. + +**Decision:** RapidOCR is the default and only required engine. EasyOCR is +the optional `urdu` extra, and it earns its place twice: + +1. **Urdu address.** RapidOCR's models do not cover Urdu script. +2. **Independent verification.** EasyOCR supports character allowlists, so it + re-reads CNIC numbers, dates and marks from tight crops with digits only. + Agreement between two *different* engines is much stronger evidence than + one engine agreeing with itself. With it, ok-precision on real photos goes + from 98.5% to 99.2%. + +Rejected alternatives: Tesseract needs a system install, which the brief +excludes. TrOCR-Urdu was trained on handwritten single lines. Qaari / +Qwen2-VL and Qwen2.5-VL are multi-GB downloads that need a GPU, and the +earlier experiments (kept outside the library) did not yield reliable Urdu +addresses; a generative model can also produce a fluent address that is not +on the card. UTRNet is a strong published Urdu recognizer, but it is licensed +CC BY-NC-SA (non-commercial), which is incompatible with an MIT library meant +for commercial KYC use. + +### 5.2 Preprocessing: what the data supported + +| Step | Finding | Decision | +|---|---|---| +| Adaptive threshold (brief's default) | Lowered EasyOCR confidence on every sample (lab note A/B test) | Not used | +| Laplacian blur *rejection* (threshold 35) | Rejected perfectly readable photos (a clean CNIC back scored 15.8) | Blur measured at a fixed resolution and reported as a **warning**; the OCR result decides | +| `minAreaRect` deskew over dark pixels | Returned −90° (a no-op) on 15/16 real photos; OpenCV ≥ 4.5 also changed the angle convention | Skew estimated from the OCR engine's own text-line polygons | +| 4-rotation trial scored by `confidence × count` with an en+ur reader | Urdu-script noise on a sideways read beat the upright read, so the cleanest CNIC photo scored 0/8 | Score = confidence × Latin characters + anchor-word bonus, scaled by the share of horizontal boxes (RapidOCR rotates tall crops itself, so text alone cannot reveal a sideways page). Upright is tried first and accepted early | +| Security-paper micro-text | Merged into real lines; RapidOCR's 1000-candidate cap silently dropped the top half of one certificate | Cap raised; small low-confidence boxes filtered; if many were filtered, an *ink-only* copy (Otsu cut-off, dark pixels keep their gray levels) is re-read and kept only if it scores higher | +| Urdu | Upscaling helped (line error rate 0.4 → 0.25), ink-only hurt | Urdu read at ≥ 2048 px, no binarization | + +### 5.3 Field extraction + +- **Positions, not reading order.** Parsers receive `TextBox(text, + confidence, polygon)`, not newline-joined text. A value is the box below or + beside its fuzzy-matched label. This removes the failure in the lab note, + where EasyOCR grouped all date labels apart from all date values. +- **CNIC dates by chronology.** With three distinct dates, birth < issue < + expiry assigns them regardless of layout. Label geometry is the fallback. +- **CNIC structure.** 13 digits, first digit a valid region code (1–8). The + last digit gives gender (odd = male); a printed gender that disagrees is + flagged, and an unreadable one is inferred with `low_confidence`. +- **Marks.** The marks table is read column-aligned to the "Maximum" and + "Obtained" headers, validated against the TOTAL row, the subject sum and + "Marks in words" (`words_to_number`, typo-tolerant), and digit-re-read if + inconsistent. Totals are `ok` only when two sources agree. +- **Fallbacks are always labelled.** Positional and format-based fallbacks + (unreadable labels, FBISE certificate-number format) return + `low_confidence` with a reason saying how the value was found. +- **Urdu.** Arabic letter forms are mapped to Urdu code points. Address tokens + are snapped to a lexicon of address words and districts by comparing + dot-less letter skeletons (most Nastaliq OCR errors are dot errors). Place + names need a stronger match so that noise is never turned into a real + district. + +### 5.4 Confidence + +A field's confidence starts from OCR recognition confidence. Agreeing reads +combine as `1 − Π(1 − cᵢ)`, disagreements multiply by 0.6, and failed +validation removes the value. Below 0.55 the status becomes +`low_confidence`. Document confidence is the mean over all *applicable* +fields, including missing ones, so a half-read document cannot score high +(kept from v0.1). + +## 6. Evaluation plan and data ethics + +- **Two datasets.** (a) 16 real photos (author-owned and publicly circulated + samples): rotated, skewed, sleeved, soft, motion-blurred, scanned and + watermarked. Hand-labelled, kept **outside the repository**, only aggregate + numbers published. (b) 23 synthetic fixtures generated by + `benchmarks/synthetic/generate.py`: fabricated data, stamped "SYNTHETIC + SPECIMEN", no emblems, photos or seals. These are committed and drive CI. + Real CNIC numbers must never be committed; this is a data-ethics + requirement, not only a technical one. +- **Metrics.** Per-field exact-match accuracy, coverage, precision, + *ok-precision*, character error rate for free text, marks-row accuracy, and + latency. +- **Targets.** The brief's 99% CNIC-number target is interpreted as *99% of + returned `ok` values correct*. Measured: 100% on both datasets (every + returned CNIC number was correct; the one miss was left blank). +- **Tests.** 211 unit tests on constructed OCR layouts (run without models, + 92% branch coverage, CI gate at 90%) plus 42 end-to-end tests with real OCR + on the synthetic fixtures. The key end-to-end test asserts that no field is + ever returned `ok` with a wrong value. + +## 7. Known risks and limitations + +See the README's Known limitations. In short: Urdu address quality, board +layouts beyond FBISE, old Urdu-only CNICs, severe blur, and a small real test +set (16 images). The benchmark makes the next round of samples cheap to +evaluate: add images and labels to a private labels file and run +`benchmarks/run_benchmark.py`. + +## 8. Definition of Done (v1) + +- [x] CNIC, Matric, Intermediate and Degree/Transcript extraction +- [x] `extract()` and CLI (`docling-pk extract --type cnic --output json`) +- [x] Confidence, status and reason on every field; warnings on every result +- [x] Graceful failure: partial results, no crash on messy input +- [x] Fixtures: clean, rotated, low-resolution, obscured field, unreadable, PDF +- [x] pytest suite, ≥ 90% coverage (92%), CI on Python 3.9–3.12 + Windows/macOS +- [x] Benchmark report (`tests/benchmark_report.json`, `docs/benchmark.md`) +- [x] README with installation, usage and known limitations +- [x] Docstrings with parameters, returns and examples on public functions +- [x] `pip install docling-pk` verified in a clean Python 3.9 virtualenv +- [ ] Publish to PyPI (release workflow ready; needs the PyPI trusted-publisher setup and a `v1.0.0` tag) diff --git a/pyproject.toml b/pyproject.toml index 81d3d25..b0db734 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,33 +1,103 @@ [build-system] -requires = ["setuptools>=68.0"] +requires = ["setuptools>=77"] build-backend = "setuptools.build_meta" [project] name = "docling-pk" -version = "0.1.0" -description = "Extract structured data from Pakistani identity and education documents" +dynamic = ["version"] +description = "Structured data extraction from Pakistani identity and education documents (CNIC, Matric, Intermediate, degrees)." readme = "README.md" requires-python = ">=3.9" -license = { text = "MIT" } -authors = [ - { name = "Abdul Moiz Muhammad" } +license = "MIT" +license-files = ["LICENSE"] +authors = [{ name = "Abdul Moiz Muhammad" }, { name = "INFERENCE Lab" }] +keywords = ["ocr", "cnic", "pakistan", "kyc", "document-ai", "information-extraction", "easyocr", "urdu"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Intended Audience :: Science/Research", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Scientific/Engineering :: Image Recognition", + "Topic :: Text Processing", + "Typing :: Typed", ] dependencies = [ - "opencv-python", - "numpy", - "easyocr", - "torch", - "typer", + "numpy>=1.21", + "opencv-python>=4.5", # same distribution RapidOCR requires, so only one cv2 is installed + "rapidocr>=3.0", + "onnxruntime>=1.16", + "rapidfuzz>=3.0", + "typer>=0.9", + "Pillow>=9.0", +] + +[project.optional-dependencies] +# EasyOCR (PyTorch) adds Urdu address reading and an independent second +# engine that cross-checks CNIC numbers, dates and IDs. +urdu = ["easyocr>=1.7"] +pdf = ["pymupdf>=1.23"] +all = ["docling-pk[urdu,pdf]"] +dev = [ + "pytest>=7", + "pytest-cov>=4", + "ruff>=0.5", + "mypy>=1.8", + "build>=1.0", + "twine>=5", ] [project.scripts] -docling-pk = "cli:app" +docling-pk = "docling_pk.cli:app" + +[project.urls] +Homepage = "https://github.com/Inference-LAB/docling-pk" +Repository = "https://github.com/Inference-LAB/docling-pk" +Issues = "https://github.com/Inference-LAB/docling-pk/issues" +Changelog = "https://github.com/Inference-LAB/docling-pk/blob/main/CHANGELOG.md" + +[tool.setuptools.dynamic] +version = { attr = "docling_pk.__version__" } [tool.setuptools.packages.find] -include = ["docling_pk*"] +where = ["src"] -[project.optional-dependencies] -dev = [ - "pytest", - "pytest-cov", -] \ No newline at end of file +[tool.setuptools.package-data] +docling_pk = ["py.typed"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-ra --strict-markers" +markers = [ + "ocr: needs the EasyOCR models (downloaded on first run); deselect with -m 'not ocr'", +] + +[tool.coverage.run] +source = ["docling_pk"] +branch = true + +[tool.coverage.report] +show_missing = true +skip_covered = false +exclude_lines = ["pragma: no cover", "if __name__ == .__main__.:", "raise NotImplementedError"] + +[tool.ruff] +line-length = 120 +target-version = "py39" +src = ["src", "tests"] + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "B", "UP", "SIM"] +# UP045/UP007: keep Optional[X]; Typer evaluates CLI annotations at runtime and +# "X | None" fails there on Python 3.9. +ignore = ["E501", "UP006", "UP007", "UP035", "UP045", "B008", "SIM108"] + +[tool.mypy] +python_version = "3.10" +ignore_missing_imports = true +warn_unused_ignores = true diff --git a/requirements.txt b/requirements.txt index 7f543a4..8c405ce 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,2 @@ -opencv-python -numpy -easyocr -torch -typer -pytest -pytest-cov \ No newline at end of file +# Development install. Dependencies are declared in pyproject.toml (single source of truth). +-e .[dev,pdf] diff --git a/src/docling_pk/__init__.py b/src/docling_pk/__init__.py new file mode 100644 index 0000000..2488d4b --- /dev/null +++ b/src/docling_pk/__init__.py @@ -0,0 +1,28 @@ +""" +docling-pk: structured data extraction from Pakistani identity and education +documents. + +Example: + >>> from docling_pk import extract + >>> result = extract("cnic.jpg", document_type="cnic") # doctest: +SKIP + >>> result.fields["cnic_number"].value # doctest: +SKIP + '35202-1234567-9' +""" + +__version__ = "1.0.0" + +from docling_pk.errors import DoclingPKError, ImageLoadError, UnsupportedDocumentTypeError # noqa: E402 +from docling_pk.extractor import SUPPORTED_TYPES, extract # noqa: E402 +from docling_pk.schema import DocumentResult, FieldResult, FieldStatus # noqa: E402 + +__all__ = [ + "extract", + "SUPPORTED_TYPES", + "DocumentResult", + "FieldResult", + "FieldStatus", + "DoclingPKError", + "ImageLoadError", + "UnsupportedDocumentTypeError", + "__version__", +] diff --git a/src/docling_pk/cli.py b/src/docling_pk/cli.py new file mode 100644 index 0000000..4129230 --- /dev/null +++ b/src/docling_pk/cli.py @@ -0,0 +1,197 @@ +""" +Command line interface. + + docling-pk extract cnic_front.jpg cnic_back.jpg --type cnic + docling-pk extract certificate.jpg --type auto --output table + docling-pk batch ./scans --type matric --out results.jsonl + docling-pk info + +Exit codes: 0 success, 1 the document was processed but no field could be +extracted, 2 bad input (missing file, unreadable image, unknown type). +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path +from typing import List, Optional + +import typer + +from docling_pk import __version__ + +app = typer.Typer( + help="Extract structured data from Pakistani identity and education documents.", + no_args_is_help=True, + add_completion=False, +) + +EXIT_NO_FIELDS = 1 +EXIT_BAD_INPUT = 2 + +IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png", ".bmp", ".tif", ".tiff", ".webp", ".pdf"} + + +def _version_callback(value: bool) -> None: + if value: + typer.echo(f"docling-pk {__version__}") + raise typer.Exit() + + +@app.callback() +def main( + version: bool = typer.Option( + False, "--version", callback=_version_callback, is_eager=True, help="Show version and exit." + ), +) -> None: + """docling-pk command line.""" + + +def _print_table(result) -> None: + from rich.console import Console + from rich.markup import escape + from rich.table import Table + + console = Console() + table = Table(title=f"{result.document_type} (confidence {result.confidence:.2f})") + table.add_column("field") + table.add_column("value") + table.add_column("conf", justify="right") + table.add_column("status") + colors = {"ok": "green", "low_confidence": "yellow", "not_applicable": "dim"} + for name, f in result.fields.items(): + c = colors.get(f.status.value, "red") + table.add_row( + name, "" if f.value is None else escape(f.value), f"{f.confidence:.2f}", f"[{c}]{f.status.value}[/{c}]" + ) + console.print(table) + rows = result.tables.get("subjects") + if rows: + sub = Table(title="subjects") + for col in ("subject", "max_marks", "obtained_marks"): + sub.add_column(col) + for r in rows: + sub.add_row(escape(str(r["subject"])), str(r["max_marks"]), str(r["obtained_marks"])) + console.print(sub) + for w in result.warnings: + console.print(f"[yellow]warning:[/yellow] {escape(w)}") + + +def _run(paths: List[Path], document_type: str, backend: str, urdu: bool, cpu: bool, raw: bool, verify: bool = True): + from docling_pk import extract + from docling_pk.errors import DoclingPKError + + try: + return extract( + [str(p) for p in paths] if len(paths) > 1 else str(paths[0]), + document_type=document_type, + backend=backend, + urdu=urdu, + verify=verify, + gpu=False if cpu else None, + include_raw_text=raw, + ) + except FileNotFoundError as exc: + typer.echo(f"Error: {exc}", err=True) + raise typer.Exit(code=EXIT_BAD_INPUT) from exc + except (DoclingPKError, ValueError) as exc: + typer.echo(f"Error: {exc}", err=True) + raise typer.Exit(code=EXIT_BAD_INPUT) from exc + + +@app.command() +def extract( + images: List[Path] = typer.Argument( + ..., help="Image or PDF file(s). For a CNIC, pass the front and back together." + ), + document_type: str = typer.Option("auto", "--type", "-t", help="cnic, matric, intermediate, degree or auto."), + output: str = typer.Option("json", "--output", "-o", help="json or table."), + out_file: Optional[Path] = typer.Option(None, "--out", help="Write JSON to this file instead of stdout."), + backend: str = typer.Option("auto", "--backend", "-b", help="OCR engine: auto, rapidocr or easyocr."), + urdu: bool = typer.Option( + True, "--urdu/--no-urdu", help="Read the Urdu address on CNIC backs (needs the urdu extra)." + ), + verify: bool = typer.Option( + True, "--verify/--no-verify", help="Cross-check key fields with a second OCR engine if installed." + ), + cpu: bool = typer.Option(False, "--cpu", help="Do not use a GPU even if one is available."), + raw: bool = typer.Option(False, "--raw", help="Include raw OCR text in the JSON output."), +) -> None: + """Extract fields from one document (one or more images of it).""" + if output not in ("json", "table"): + typer.echo("Error: --output must be 'json' or 'table'", err=True) + raise typer.Exit(code=EXIT_BAD_INPUT) + result = _run(images, document_type, backend, urdu, cpu, raw, verify) + if output == "table" and out_file is None: + _print_table(result) + else: + text = result.to_json(include_raw=raw) + if out_file: + out_file.write_text(text, encoding="utf-8") + typer.echo(f"Wrote {out_file}", err=True) + else: + sys.stdout.buffer.write(text.encode("utf-8") + b"\n") + sys.stdout.flush() + if not any(f.value is not None for f in result.fields.values()): + raise typer.Exit(code=EXIT_NO_FIELDS) + + +@app.command() +def batch( + folder: Path = typer.Argument(..., exists=True, file_okay=False, help="Folder of images/PDFs."), + document_type: str = typer.Option("auto", "--type", "-t", help="cnic, matric, intermediate, degree or auto."), + out_file: Path = typer.Option(Path("docling_pk_results.jsonl"), "--out", help="JSON Lines output file."), + backend: str = typer.Option("auto", "--backend", "-b", help="OCR engine: auto, rapidocr or easyocr."), + urdu: bool = typer.Option(True, "--urdu/--no-urdu"), + cpu: bool = typer.Option(False, "--cpu"), +) -> None: + """Extract every document in a folder (one file per document) to JSON Lines.""" + from docling_pk import extract as run_extract + + files = sorted(p for p in folder.iterdir() if p.suffix.lower() in IMAGE_SUFFIXES) + if not files: + typer.echo(f"Error: no images found in {folder}", err=True) + raise typer.Exit(code=EXIT_BAD_INPUT) + ok = 0 + with out_file.open("w", encoding="utf-8") as fh: + for i, path in enumerate(files, 1): + try: + result = run_extract( + str(path), + document_type=document_type, + backend=backend, + urdu=urdu, + gpu=False if cpu else None, + include_raw_text=False, + ) + record = {"file": path.name, **result.to_dict()} + ok += 1 + except Exception as exc: # keep going: one bad file must not stop a batch + record = {"file": path.name, "error": f"{type(exc).__name__}: {exc}"} + fh.write(json.dumps(record, ensure_ascii=False) + "\n") + typer.echo(f"[{i}/{len(files)}] {path.name}", err=True) + typer.echo(f"Processed {ok}/{len(files)} files -> {out_file}", err=True) + + +@app.command() +def info() -> None: + """Show version, installed OCR backends and GPU availability.""" + import importlib.util + + typer.echo(f"docling-pk {__version__}") + typer.echo(f"python {sys.version.split()[0]}") + for mod in ("easyocr", "rapidocr", "fitz"): + label = {"fitz": "pymupdf (pdf)"}.get(mod, mod) + typer.echo(f"{label:<14} {'installed' if importlib.util.find_spec(mod) else 'not installed'}") + try: + import torch + + gpu = torch.cuda.get_device_name(0) if torch.cuda.is_available() else "none (CPU mode)" + except Exception: + gpu = "not used (PyTorch not installed; RapidOCR runs on CPU)" + typer.echo(f"gpu {gpu}") + + +if __name__ == "__main__": # pragma: no cover + app() diff --git a/src/docling_pk/errors.py b/src/docling_pk/errors.py new file mode 100644 index 0000000..0a15f84 --- /dev/null +++ b/src/docling_pk/errors.py @@ -0,0 +1,22 @@ +"""Exceptions raised by docling-pk. + +Only problems with the *call* raise (missing file, undecodable data, unknown +document type). Problems with the *document* (blur, glare, an unreadable +field) never raise: they come back as warnings and per-field statuses on a +partial :class:`~docling_pk.schema.DocumentResult`. +""" + + +class DoclingPKError(Exception): + """Base class for all docling-pk errors.""" + + +class ImageLoadError(DoclingPKError, ValueError): + """The input exists but could not be decoded as an image or PDF. + + Subclasses ``ValueError`` so ``except ValueError`` keeps working. + """ + + +class UnsupportedDocumentTypeError(DoclingPKError, ValueError): + """``document_type`` is not one of the supported types.""" diff --git a/src/docling_pk/extractor.py b/src/docling_pk/extractor.py new file mode 100644 index 0000000..334d3ac --- /dev/null +++ b/src/docling_pk/extractor.py @@ -0,0 +1,281 @@ +""" +Main entry point: :func:`extract`. + +Ties together loading, image quality checks, page normalization (rotation, +deskew, OCR), document-type routing and the per-type parsers, and returns a +:class:`~docling_pk.schema.DocumentResult`. +""" + +from __future__ import annotations + +import re +import time +from typing import Dict, List, Optional, Sequence, Union + +from docling_pk import __version__ +from docling_pk.errors import UnsupportedDocumentTypeError +from docling_pk.ocr.base import OCRBackend +from docling_pk.parsers import certificate, cnic, degree +from docling_pk.pipeline import Page, prepare_page +from docling_pk.schema import DocumentResult, FieldResult, FieldStatus +from docling_pk.validation.cnic import find_cnic +from docling_pk.vision.image_io import ImageSource, load_pages +from docling_pk.vision.quality import assess + +#: Accepted ``document_type`` values (aliases map onto these). +SUPPORTED_TYPES = ("cnic", "matric", "intermediate", "degree") +_ALIASES = { + "auto": "auto", + "cnic": "cnic", + "nic": "cnic", + "id": "cnic", + "id_card": "cnic", + "snic": "cnic", + "matric": "matric", + "ssc": "matric", + "secondary": "matric", + "intermediate": "intermediate", + "inter": "intermediate", + "hssc": "intermediate", + "fsc": "intermediate", + "degree": "degree", + "transcript": "degree", + "university": "degree", +} + +BackendSpec = Union[str, OCRBackend] + + +def _make_backend(spec: BackendSpec, gpu: Optional[bool]) -> OCRBackend: + if isinstance(spec, OCRBackend): + return spec + name = str(spec).lower() + if name == "auto": + import importlib.util + + if importlib.util.find_spec("rapidocr") is not None: + name = "rapidocr" + elif importlib.util.find_spec("easyocr") is not None: + name = "easyocr" + else: # pragma: no cover - depends on environment + raise ImportError("No OCR engine installed: pip install docling-pk") + if name == "easyocr": + from docling_pk.ocr.easyocr_backend import EasyOCRBackend + + return EasyOCRBackend(("en",), gpu=gpu) + if name == "rapidocr": + from docling_pk.ocr.rapidocr_backend import RapidOCRBackend + + return RapidOCRBackend() + raise ValueError(f"Unknown OCR backend {spec!r}; use 'auto', 'rapidocr', 'easyocr' or an OCRBackend instance.") + + +def _easyocr_available() -> bool: + import importlib.util + + return importlib.util.find_spec("easyocr") is not None + + +def _urdu_backend(gpu: Optional[bool]) -> Optional[OCRBackend]: + if not _easyocr_available(): + return None + from docling_pk.ocr.easyocr_backend import EasyOCRBackend + + return EasyOCRBackend(("ur", "en"), gpu=gpu) + + +def _verifier(primary: OCRBackend, verify: bool, gpu: Optional[bool]) -> Optional[OCRBackend]: + """A second engine for re-reading critical fields, if one is installed.""" + if not verify or primary.name == "easyocr" or not _easyocr_available(): + return None + from docling_pk.ocr.easyocr_backend import EasyOCRBackend + + return EasyOCRBackend(("en",), gpu=gpu) + + +def normalize_document_type(document_type: str) -> str: + """Maps aliases (``"ssc"``, ``"fsc"``, ``"transcript"``...) to a canonical type. + + Raises: + UnsupportedDocumentTypeError: for unknown types. + """ + key = re.sub(r"[\s\-]+", "_", str(document_type).strip().lower()) + if key not in _ALIASES: + raise UnsupportedDocumentTypeError( + f"Unsupported document_type: {document_type!r}. Must be one of {('auto',) + SUPPORTED_TYPES}." + ) + return _ALIASES[key] + + +def classify(page: Page) -> Optional[str]: + """Guesses the document type from OCR text. Returns ``None`` if unsure.""" + text = page.text + level = certificate.detect_level(text) + if level and (certificate.detect_board(text) or re.search(r"marks|subject", text, re.I)): + return level + if re.search(r"identity\s*(?:card|number)|country\s*of\s*stay|holder", text, re.I): + return "cnic" + if re.search(r"university|transcript|degree|bachelor|master\s+of|cgpa", text, re.I): + return "degree" + if level: + return level + if find_cnic(text) and cnic.detect_side(page.boxes) != "unknown": + return "cnic" + return None + + +def _overall_confidence(fields: Dict[str, FieldResult]) -> float: + applicable = [f for f in fields.values() if f.status != FieldStatus.NOT_APPLICABLE] + if not applicable: + return 0.0 + return round(sum(f.confidence for f in applicable) / len(applicable), 4) + + +def _field_warnings(fields: Dict[str, FieldResult]) -> List[str]: + out = [] + for name, f in fields.items(): + if f.status in (FieldStatus.NOT_FOUND, FieldStatus.INVALID): + out.append(f"{name}: {f.reason or f.status.value}") + elif f.status == FieldStatus.LOW_CONFIDENCE: + out.append(f"{name}: low confidence ({f.reason or 'verify manually'})") + return out + + +def extract( + source: Union[ImageSource, Sequence[ImageSource]], + document_type: str = "auto", + *, + backend: BackendSpec = "auto", + urdu: bool = True, + verify: bool = True, + gpu: Optional[bool] = None, + auto_rotate: bool = True, + include_raw_text: bool = True, +) -> DocumentResult: + """Extracts structured fields from a Pakistani document image. + + Args: + source: Path to an image or PDF, raw bytes, a NumPy array, a PIL + image, or a list of these. For CNICs, pass ``[front, back]`` to + get the address (printed on the back) in the same result. + document_type: ``"cnic"``, ``"matric"``, ``"intermediate"``, + ``"degree"`` (also covers transcripts), or ``"auto"`` to detect it. + Aliases such as ``"ssc"``, ``"hssc"``, ``"fsc"`` are accepted. + Matric and intermediate are confirmed against the certificate + title, which wins if the two disagree (a warning says so). + backend: OCR engine: ``"auto"`` (default: RapidOCR if installed, + else EasyOCR), ``"rapidocr"``, ``"easyocr"`` (needs the ``urdu`` + extra) or any :class:`~docling_pk.ocr.base.OCRBackend`. + urdu: Read the Urdu address on CNIC backs (needs EasyOCR; its Urdu + model is downloaded on first use). + verify: Re-read high-stakes fields (CNIC number, dates, marks) with a + second, independent OCR engine when one is installed, and only + mark them ``ok`` when the engines agree. + gpu: Force GPU use on/off for EasyOCR. ``None`` auto-detects CUDA. + auto_rotate: Detect and fix 90/180/270 degree rotation and skew. + include_raw_text: Keep the full OCR text on ``result.raw_text``. + + Returns: + A :class:`~docling_pk.schema.DocumentResult`. Never raises for a bad + *document*: unreadable fields come back with ``value=None``, a status + and a reason, and the overall ``warnings`` list explains problems. + + Raises: + FileNotFoundError: ``source`` is a path that does not exist. + ImageLoadError: the file is not a decodable image or PDF (subclass of + ``ValueError``). + UnsupportedDocumentTypeError: unknown ``document_type`` (subclass of + ``ValueError``). + + Example: + >>> from docling_pk import extract + >>> result = extract("cnic_front.jpg", document_type="cnic") # doctest: +SKIP + >>> result.fields["cnic_number"].value # doctest: +SKIP + '35202-1234567-9' + >>> print(result.to_json()) # doctest: +SKIP + """ + requested = normalize_document_type(document_type) + started = time.perf_counter() + + sources = list(source) if isinstance(source, (list, tuple)) else [source] + images = [] + for s in sources: + images.extend(load_pages(s)) + + ocr = _make_backend(backend, gpu) + verifier = _verifier(ocr, verify, gpu) + warnings: List[str] = [] + quality = [] + pages: List[Page] = [] + for img in images: + q = assess(img) + quality.append(q.to_dict()) + for w in q.warnings: + if w not in warnings: + warnings.append(w) + pages.append(prepare_page(img, ocr, auto_rotate=auto_rotate, verifier=verifier)) + + doc_type = requested + if requested == "auto": + guesses = [classify(p) for p in pages] + doc_type = next((g for g in guesses if g), None) or "" + if not doc_type: + warnings.append("could not recognize the document type; pass document_type explicitly") + return DocumentResult( + document_type="unknown", + fields={}, + confidence=0.0, + warnings=warnings, + raw_text="\n\n".join(p.text for p in pages) if include_raw_text else None, + metadata={"version": __version__, "quality": quality}, + ) + + tables: Dict[str, list] = {} + metadata: Dict = { + "version": __version__, + "ocr_backend": ocr.name, + "verifier": verifier.name if verifier else None, + } + + if doc_type == "cnic": + urdu_ocr = _urdu_backend(gpu) if urdu else None + per_side, sides = [], [] + for p in pages: + f, side_warnings, m = cnic.extract(p, urdu_backend=urdu_ocr) + per_side.append(f) + sides.append(m["side"]) + warnings.extend(side_warnings) + fields = cnic.merge_sides(per_side) if len(per_side) > 1 else per_side[0] + metadata["sides"] = sides + if urdu and urdu_ocr is None and "back" in sides: + warnings.append('reading the Urdu address needs EasyOCR: pip install "docling-pk[urdu]"') + elif doc_type in ("matric", "intermediate"): + fields, parser_warnings, m, rows = certificate.extract(pages[0], level_hint=doc_type) + doc_type = m["level"] + warnings.extend(parser_warnings) + metadata.update(board=m["board"]) + if rows: + tables["subjects"] = rows + elif doc_type == "degree": + fields, parser_warnings, m = degree.extract(pages[0]) + warnings.extend(parser_warnings) + metadata.update(variant=m["variant"]) + else: # pragma: no cover - normalize_document_type guards this + raise UnsupportedDocumentTypeError(doc_type) + + warnings.extend(_field_warnings(fields)) + metadata["pages"] = [ + {"rotation_degrees": p.rotation * 90, "skew_degrees": round(p.skew, 2), "quality": q} + for p, q in zip(pages, quality) + ] + metadata["seconds"] = round(time.perf_counter() - started, 2) + + return DocumentResult( + document_type=doc_type, + fields=fields, + confidence=_overall_confidence(fields), + warnings=list(dict.fromkeys(warnings)), + raw_text="\n\n".join(p.text for p in pages) if include_raw_text else None, + tables=tables, + metadata=metadata, + ) diff --git a/src/docling_pk/layout.py b/src/docling_pk/layout.py new file mode 100644 index 0000000..346d220 --- /dev/null +++ b/src/docling_pk/layout.py @@ -0,0 +1,146 @@ +""" +Spatial helpers over OCR text boxes. + +The original parsers worked on newline-joined OCR text and assumed a value +always follows its label in reading order. Real EasyOCR output breaks that +assumption: on CNICs it reads all three date labels as one group and the +three date values as another, and the field-to-value mapping silently +depended on card layout. Working on positions instead of order removes that +whole class of failure: "the value is the text directly below its label, +in the same column" holds no matter how the OCR engine orders its output. +""" + +from __future__ import annotations + +import re +from typing import Iterable, List, Optional, Sequence + +from rapidfuzz import fuzz + +from docling_pk.ocr.base import TextBox + +_SPACE = re.compile(r"\s+") + + +def normalize_label(text: str) -> str: + """Lowercases and strips everything but ASCII letters, for label matching.""" + return re.sub(r"[^a-z]", "", text.lower()) + + +def label_score(text: str, label: str) -> float: + """Fuzzy similarity (0-100) between OCR text and an expected label. + + Uses the letters only, so "Date 0/ 8irth" still scores well against + "Date of Birth". + """ + a, b = normalize_label(text), normalize_label(label) + if not a or not b: + return 0.0 + return float(fuzz.ratio(a, b)) + + +def find_label( + boxes: Sequence[TextBox], + label: str, + threshold: float = 72.0, + exclude: Iterable[str] = (), +) -> Optional[TextBox]: + """Finds the box that best matches ``label``. + + A box can also *start* with the label (e.g. "Gender M"), which is scored + on the leading part of the box only. + + Args: + exclude: Other labels that must not match better than ``label`` + (prevents "Father Name" matching the "Name" label). + """ + best, best_score = None, threshold + target = normalize_label(label) + for b in boxes: + norm = normalize_label(b.text) + if not norm: + continue + score = label_score(b.text, label) + if len(norm) > len(target) + 2: + score = max(score, float(fuzz.ratio(norm[: len(target)], target)) - 5) + if score < best_score: + continue + if any(label_score(b.text, e) > score for e in exclude): + continue + best, best_score = b, score + return best + + +def boxes_below( + boxes: Sequence[TextBox], + anchor: TextBox, + max_gap: float = 3.0, + min_overlap: float = 0.0, + x_tolerance: float = 0.5, +) -> List[TextBox]: + """Boxes below ``anchor`` in roughly the same column, nearest first. + + Args: + max_gap: Maximum vertical gap, in multiples of the anchor's height. + x_tolerance: How far a box may start left of the anchor, in multiples + of the anchor height (values are often slightly left-aligned). + """ + h = max(anchor.height, 1.0) + out = [] + for b in boxes: + if b is anchor or b.cy <= anchor.cy + h * 0.5: + continue + if b.y0 - anchor.y1 > max_gap * h: + continue + left_ok = b.x0 >= anchor.x0 - x_tolerance * h * 4 + overlap = min(b.x1, anchor.x1 + h * 8) - max(b.x0, anchor.x0 - h) + if left_ok and overlap > min_overlap: + out.append(b) + return sorted(out, key=lambda b: (b.y0, b.x0)) + + +def boxes_right_of(boxes: Sequence[TextBox], anchor: TextBox, max_gap: float = 8.0) -> List[TextBox]: + """Boxes on the same text line to the right of ``anchor``, nearest first.""" + h = max(anchor.height, 1.0) + out = [ + b + for b in boxes + if b is not anchor + and b.x0 >= anchor.x1 - h * 1.0 + and b.cx > anchor.x1 + and abs(b.cy - anchor.cy) < h * 0.6 + and b.x0 - anchor.x1 < max_gap * h + ] + return sorted(out, key=lambda b: b.x0) + + +def group_lines(boxes: Sequence[TextBox], tolerance: float = 0.55) -> List[List[TextBox]]: + """Groups boxes into text lines (top to bottom, each line left to right).""" + lines: List[List[TextBox]] = [] + for b in sorted(boxes, key=lambda b: b.cy): + for line in lines: + ref = line[-1] + h = max(min(ref.height, b.height), 1.0) + if abs(ref.cy - b.cy) < h * tolerance: + line.append(b) + break + else: + lines.append([b]) + return [sorted(line, key=lambda b: b.x0) for line in lines] + + +def reading_order_text(boxes: Sequence[TextBox]) -> str: + """Joins boxes into plain text, one visual line per output line.""" + return "\n".join(" ".join(b.text for b in line) for line in group_lines(boxes)) + + +def clean_spaces(text: str) -> str: + return _SPACE.sub(" ", text).strip() + + +def ascii_ratio(text: str) -> float: + """Fraction of characters that are printable ASCII letters or digits.""" + t = text.replace(" ", "") + if not t: + return 0.0 + return sum(1 for c in t if c.isascii() and c.isalnum()) / len(t) diff --git a/src/docling_pk/ocr/__init__.py b/src/docling_pk/ocr/__init__.py new file mode 100644 index 0000000..7292b1e --- /dev/null +++ b/src/docling_pk/ocr/__init__.py @@ -0,0 +1,5 @@ +"""OCR backends.""" + +from docling_pk.ocr.base import OCRBackend, TextBox + +__all__ = ["OCRBackend", "TextBox"] diff --git a/src/docling_pk/ocr/base.py b/src/docling_pk/ocr/base.py new file mode 100644 index 0000000..48c20f8 --- /dev/null +++ b/src/docling_pk/ocr/base.py @@ -0,0 +1,130 @@ +""" +OCR backend interface. + +Parsers never see an OCR library directly. They receive a list of +:class:`TextBox` objects (text + confidence + position), which is what makes +layout-aware extraction possible: the original pipeline flattened OCR output +into plain text lines and then guessed which line belonged to which label, +which broke whenever EasyOCR grouped labels and values separately. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import List, Optional, Sequence, Tuple + +import numpy as np + +Point = Tuple[float, float] + + +@dataclass(frozen=True) +class TextBox: + """One detected piece of text and where it is on the page. + + Coordinates are in pixels of the image passed to the backend, with the + origin at the top-left corner. + """ + + text: str + confidence: float + polygon: Tuple[Point, ...] + + @classmethod + def from_polygon(cls, text: str, confidence: float, polygon: Sequence[Sequence[float]]) -> TextBox: + pts = tuple((float(p[0]), float(p[1])) for p in polygon) + return cls(text=text, confidence=float(confidence), polygon=pts) + + @classmethod + def from_rect(cls, text: str, confidence: float, x0: float, y0: float, x1: float, y1: float) -> TextBox: + return cls(text, float(confidence), ((x0, y0), (x1, y0), (x1, y1), (x0, y1))) + + @property + def x0(self) -> float: + return min(p[0] for p in self.polygon) + + @property + def x1(self) -> float: + return max(p[0] for p in self.polygon) + + @property + def y0(self) -> float: + return min(p[1] for p in self.polygon) + + @property + def y1(self) -> float: + return max(p[1] for p in self.polygon) + + @property + def cx(self) -> float: + return (self.x0 + self.x1) / 2 + + @property + def cy(self) -> float: + return (self.y0 + self.y1) / 2 + + @property + def width(self) -> float: + return self.x1 - self.x0 + + @property + def height(self) -> float: + return self.y1 - self.y0 + + def translated(self, dx: float, dy: float) -> TextBox: + return TextBox(self.text, self.confidence, tuple((x + dx, y + dy) for x, y in self.polygon)) + + def scaled(self, s: float) -> TextBox: + return TextBox(self.text, self.confidence, tuple((x * s, y * s) for x, y in self.polygon)) + + def to_dict(self) -> dict: + return { + "text": self.text, + "confidence": round(self.confidence, 4), + "box": [round(self.x0), round(self.y0), round(self.x1), round(self.y1)], + } + + +class OCRBackend: + """Base class for OCR engines. + + Subclasses implement :meth:`read` (detect and recognize every text region + on a page) and may override :meth:`recognize` (read one pre-cropped text + line, optionally restricted to a character allowlist) when the engine + supports it more efficiently than a full page read. + """ + + #: Short identifier reported in ``DocumentResult.metadata``. + name: str = "base" + + def read(self, image: np.ndarray) -> List[TextBox]: + """Detects and recognizes all text on ``image`` (BGR or grayscale).""" + raise NotImplementedError + + def recognize(self, image: np.ndarray, allowlist: Optional[str] = None) -> Tuple[str, float]: + """Recognizes the text of a single cropped line. + + The default implementation runs a full :meth:`read` on the crop and + joins the results left to right. ``allowlist`` is a hint that + backends may ignore. + + Returns: + ``(text, confidence)``; ``("", 0.0)`` when nothing was read. + """ + boxes = sorted(self.read(image), key=lambda b: b.x0) + if not boxes: + return "", 0.0 + text = " ".join(b.text for b in boxes) + conf = sum(b.confidence for b in boxes) / len(boxes) + return text, conf + + +def as_bgr(image: np.ndarray) -> np.ndarray: + """Returns a 3-channel uint8 copy of ``image`` suitable for any backend.""" + import cv2 + + if image.ndim == 2: + return cv2.cvtColor(image, cv2.COLOR_GRAY2BGR) + if image.shape[2] == 4: + return cv2.cvtColor(image, cv2.COLOR_BGRA2BGR) + return image diff --git a/src/docling_pk/ocr/easyocr_backend.py b/src/docling_pk/ocr/easyocr_backend.py new file mode 100644 index 0000000..8f99fb2 --- /dev/null +++ b/src/docling_pk/ocr/easyocr_backend.py @@ -0,0 +1,84 @@ +"""EasyOCR backend (the default engine, per the project brief).""" + +from __future__ import annotations + +import threading +from typing import Dict, List, Optional, Sequence, Tuple + +import numpy as np + +from docling_pk.ocr.base import OCRBackend, TextBox, as_bgr + +_READERS: Dict[Tuple[Tuple[str, ...], bool], object] = {} +_LOCK = threading.Lock() + + +def _gpu_available() -> bool: + try: + import torch + + return bool(torch.cuda.is_available()) + except Exception: # pragma: no cover - torch import problems + return False + + +def get_reader(languages: Sequence[str] = ("en",), gpu: Optional[bool] = None): + """Returns a cached ``easyocr.Reader``. + + Creating a reader loads two neural networks from disk (and downloads them + on first use), so readers are created once per process and per language + set, then reused. + """ + try: + import easyocr + except ImportError as exc: # pragma: no cover - depends on environment + raise ImportError('The EasyOCR backend needs the urdu extra: pip install "docling-pk[urdu]"') from exc + + use_gpu = _gpu_available() if gpu is None else gpu + key = (tuple(languages), use_gpu) + with _LOCK: + if key not in _READERS: + _READERS[key] = easyocr.Reader(list(languages), gpu=use_gpu, verbose=False) + return _READERS[key] + + +class EasyOCRBackend(OCRBackend): + """Wraps :class:`easyocr.Reader`. + + Args: + languages: EasyOCR language codes. ``("en",)`` for English documents; + add ``"ur"`` only where Urdu text must be read, since the combined + model injects Arabic-script noise into English words. + gpu: Force GPU on or off. ``None`` (default) uses CUDA if available. + """ + + name = "easyocr" + + def __init__(self, languages: Sequence[str] = ("en",), gpu: Optional[bool] = None): + self.languages = tuple(languages) + self.gpu = gpu + + @property + def reader(self): + return get_reader(self.languages, self.gpu) + + def read(self, image: np.ndarray) -> List[TextBox]: + results = self.reader.readtext(as_bgr(image), detail=1, paragraph=False) + return [TextBox.from_polygon(text, conf, box) for box, text, conf in results if text.strip()] + + def recognize(self, image: np.ndarray, allowlist: Optional[str] = None) -> Tuple[str, float]: + import cv2 + + gray = image if image.ndim == 2 else cv2.cvtColor(as_bgr(image), cv2.COLOR_BGR2GRAY) + h, w = gray.shape[:2] + results = self.reader.recognize( + gray, + horizontal_list=[[0, w, 0, h]], + free_list=[], + allowlist=allowlist, + detail=1, + ) + if not results: + return "", 0.0 + _, text, conf = results[0] + return text, float(conf) diff --git a/src/docling_pk/ocr/rapidocr_backend.py b/src/docling_pk/ocr/rapidocr_backend.py new file mode 100644 index 0000000..6a10f49 --- /dev/null +++ b/src/docling_pk/ocr/rapidocr_backend.py @@ -0,0 +1,54 @@ +"""RapidOCR backend (PP-OCR models on ONNX Runtime). + +The default engine, installed with docling-pk. Much smaller than the +EasyOCR + PyTorch stack, runs fast on CPU, and was more accurate on the +project's real-sample benchmark. English/Latin only as configured here. +""" + +from __future__ import annotations + +import threading +from typing import List, Optional, Tuple + +import numpy as np + +from docling_pk.ocr.base import OCRBackend, TextBox, as_bgr + +_ENGINE = None +_LOCK = threading.Lock() + + +def _get_engine(): + global _ENGINE + with _LOCK: + if _ENGINE is None: + try: + from rapidocr import RapidOCR + except ImportError as exc: # pragma: no cover - depends on environment + raise ImportError( + "RapidOCR is missing; reinstall docling-pk: pip install --force-reinstall docling-pk" + ) from exc + # max_candidates: the default (1000) silently drops every region past + # the 1000th contour. Certificates printed on micro-text security + # paper exceed it, and the dropped regions were the whole top half + # of the page (name, roll number, board) in testing. + _ENGINE = RapidOCR(params={"Global.log_level": "critical", "Det.max_candidates": 8000}) + return _ENGINE + + +class RapidOCRBackend(OCRBackend): + """Wraps ``rapidocr.RapidOCR``.""" + + name = "rapidocr" + + def read(self, image: np.ndarray) -> List[TextBox]: + out = _get_engine()(as_bgr(image), use_det=True, use_cls=False, use_rec=True) + if out is None or out.boxes is None or out.txts is None: + return [] + return [TextBox.from_polygon(t, s, b) for b, t, s in zip(out.boxes, out.txts, out.scores) if t and t.strip()] + + def recognize(self, image: np.ndarray, allowlist: Optional[str] = None) -> Tuple[str, float]: + out = _get_engine()(as_bgr(image), use_det=False, use_cls=False, use_rec=True) + if out is None or not out.txts: + return "", 0.0 + return str(out.txts[0]), float(out.scores[0]) diff --git a/src/docling_pk/parsers/__init__.py b/src/docling_pk/parsers/__init__.py new file mode 100644 index 0000000..01afc2e --- /dev/null +++ b/src/docling_pk/parsers/__init__.py @@ -0,0 +1 @@ +"""Per-document-type field parsers.""" diff --git a/src/docling_pk/parsers/base.py b/src/docling_pk/parsers/base.py new file mode 100644 index 0000000..d3f055f --- /dev/null +++ b/src/docling_pk/parsers/base.py @@ -0,0 +1,156 @@ +"""Shared helpers for document parsers.""" + +from __future__ import annotations + +import re +from typing import Dict, Iterable, List, Optional, Sequence + +from docling_pk.layout import ascii_ratio, boxes_below, boxes_right_of, clean_spaces +from docling_pk.ocr.base import TextBox +from docling_pk.schema import FieldResult, FieldStatus + +#: Below this confidence a found value is reported as LOW_CONFIDENCE. +LOW_CONFIDENCE = 0.55 + +_NAME_CHARS = re.compile(r"[^A-Za-z .'\-]") + + +def make_field( + value: Optional[str], + confidence: float, + raw: Optional[str] = None, + reason: Optional[str] = None, + low: float = LOW_CONFIDENCE, +) -> FieldResult: + """Builds a FieldResult, downgrading weak values to LOW_CONFIDENCE.""" + if value is None: + return FieldResult.missing(reason or "not found", raw=raw) + status = FieldStatus.OK + if confidence < low: + status = FieldStatus.LOW_CONFIDENCE + reason = reason or f"OCR confidence {confidence:.2f} is low; please verify" + return FieldResult(value=value, confidence=confidence, status=status, reason=reason, raw=raw) + + +def combine_confidence(*confs: float) -> float: + """Confidence that agreeing independent reads are all wrong is the product + of their error rates; this returns 1 minus that product.""" + err = 1.0 + for c in confs: + err *= 1.0 - max(0.0, min(1.0, c)) + return 1.0 - err + + +def clean_person_name(text: str) -> Optional[str]: + """Normalizes an OCR'd person name, or returns ``None`` if it is not one. + + Keeps letters, spaces, dots, apostrophes and hyphens; requires at least + two letters and rejects anything containing digits. + + Example: + >>> clean_person_name(" Ali Hassan Khan. ") + 'Ali Hassan Khan' + >>> clean_person_name("35202") is None + True + """ + if re.search(r"[0-9]", text): + return None + t = clean_spaces(_NAME_CHARS.sub(" ", text)).strip(" .-'") + if sum(c.isalpha() for c in t) < 2: + return None + return t + + +_GLUED = re.compile(r"[A-Za-z]{11,}") + + +def respace(page, box: Optional[TextBox], text: str) -> str: + """Restores dropped word spaces ("SAUDIARABIA") using the verifier engine. + + Only runs when the text contains a suspiciously long unbroken word and a + second engine is configured. The verifier's spacing is adopted only if + its letters are identical to the original, so this can fix spacing but + can never change a character. + """ + if box is None or page is None or getattr(page, "verifier", None) is None or not _GLUED.search(text): + return text + other, _ = page.reread(box, pad_x=0.15, pad_y=0.15) + + def letters(t: str) -> str: + return re.sub(r"[^A-Za-z0-9]", "", t).upper() + + if other and letters(other) == letters(text) and other.count(" ") > text.count(" "): + return clean_spaces(other.upper() if text.isupper() else other) + return text + + +def value_near_label( + boxes: Sequence[TextBox], + label: TextBox, + accept, + label_text: str = "", + max_gap: float = 3.0, + skip: Iterable[TextBox] = (), +) -> Optional[TextBox]: + """First box below (or right of) ``label`` whose text passes ``accept``. + + Also handles a value printed in the same box as its label ("Gender M"). + """ + skip_set = set(id(b) for b in skip) + if label_text: + rest = label.text + lt = re.sub(r"[^a-z]", "", label_text.lower()) + letters = 0 + for i, ch in enumerate(rest): + if ch.isalpha(): + letters += 1 + if letters >= len(lt): + rest = rest[i + 1 :] + break + else: + rest = "" + rest = rest.strip(" :.-") + if rest and accept(rest): + return TextBox(rest, label.confidence, label.polygon) + for b in boxes_right_of(boxes, label, max_gap=4.0) + boxes_below(boxes, label, max_gap=max_gap): + if id(b) in skip_set: + continue + if accept(b.text): + return b + return None + + +def is_label_like(text: str, labels: Sequence[str]) -> bool: + """True if ``text`` is (a fragment of) one of the printed labels. + + Fragments matter: OCR often splits "Country of Stay" into "Country" and + "Of Stay", and the fragment must not be taken as a value. + """ + from rapidfuzz import fuzz + + from docling_pk.layout import label_score, normalize_label + + norm = normalize_label(text) + for lab in labels: + if label_score(text, lab) >= 72: + return True + target = normalize_label(lab) + if len(norm) >= 4 and len(norm) < len(target) and fuzz.partial_ratio(norm, target) >= 90: + return True + return False + + +def latin_text(text: str, min_ratio: float = 0.8) -> bool: + return ascii_ratio(text) >= min_ratio + + +def fields_template(names: Sequence[str], reason: str = "not found") -> Dict[str, FieldResult]: + return {n: FieldResult.missing(reason) for n in names} + + +def not_applicable(reason: str) -> FieldResult: + return FieldResult.missing(reason, status=FieldStatus.NOT_APPLICABLE) + + +def first(items: List, default=None): + return items[0] if items else default diff --git a/src/docling_pk/parsers/certificate.py b/src/docling_pk/parsers/certificate.py new file mode 100644 index 0000000..632a757 --- /dev/null +++ b/src/docling_pk/parsers/certificate.py @@ -0,0 +1,552 @@ +""" +Matric (SSC) and Intermediate (HSSC) board certificate extraction. + +Verified on real Federal Board (FBISE) certificates, both phone photos and a +digital copy. Label vocabulary also covers the wording used by the Punjab, +Khyber Pakhtunkhwa, Sindh and Balochistan boards, so their certificates are +parsed on a best-effort basis; the board is always reported in +``metadata["board"]`` so callers can decide how far to trust it. + +The marks table is read spatially: the header row gives the x-position of +the "Maximum" and "Obtained" columns, and every row between the header and +the TOTAL row contributes ``(subject, max_marks, obtained_marks)``. The table +is then validated three ways (column sums equal the TOTAL row, obtained never +exceeds maximum, the total matches "Marks in words"), and numeric cells are +re-read with a digit-only allowlist when validation fails. +""" + +from __future__ import annotations + +import re +from typing import Dict, List, Optional, Sequence, Tuple + +from docling_pk.layout import ( + ascii_ratio, + boxes_right_of, + clean_spaces, + find_label, + group_lines, + label_score, + normalize_label, +) +from docling_pk.ocr.base import TextBox +from docling_pk.parsers.base import clean_person_name, combine_confidence, make_field, respace +from docling_pk.pipeline import Page +from docling_pk.schema import FieldResult, FieldStatus +from docling_pk.validation.dates import find_dates +from docling_pk.validation.marks import check_subjects, words_to_number +from docling_pk.validation.text import digits_only, fix_digits + +FIELD_NAMES = [ + "student_name", + "father_name", + "date_of_birth", + "roll_number", + "registration_number", + "serial_number", + "certificate_number", + "board", + "exam", + "year", + "session", + "group", + "institute", + "grade", + "total_marks", + "max_marks", +] + +#: (canonical board name, regex over the page text). Order matters: the +#: Federal Board title also contains generic words used by other boards. +BOARDS: Sequence[Tuple[str, str]] = ( + ("FBISE", r"federal\s*board"), + ("AKU-EB", r"aga\s*khan\s*university\s*examination"), + ("BIEK Karachi", r"board\s*of\s*intermediate\s*education\W*karachi"), + ("BSEK Karachi", r"board\s*of\s*secondary\s*education\W*karachi"), +) +BOARD_CITIES = ( + "Lahore", + "Gujranwala", + "Faisalabad", + "Multan", + "Rawalpindi", + "Sargodha", + "Bahawalpur", + "Dera Ghazi Khan", + "D.G. Khan", + "Sahiwal", + "Peshawar", + "Mardan", + "Abbottabad", + "Swat", + "Kohat", + "Bannu", + "Malakand", + "Dera Ismail Khan", + "D.I. Khan", + "Hyderabad", + "Sukkur", + "Larkana", + "Mirpurkhas", + "Quetta", + "Mirpur", +) + +SSC_TITLE = re.compile(r"(? Optional[str]: + """Identifies the issuing board from the page text, e.g. ``"FBISE"``.""" + for name, pattern in BOARDS: + if re.search(pattern, text, re.I): + return name + if re.search(r"board\s*of\s*intermediate\s*(?:and|&)\s*secondary\s*education", text, re.I): + for city in BOARD_CITIES: + if re.search(re.escape(city).replace(r"\ ", r"\s*"), text, re.I): + return f"BISE {city}" + return "BISE" + return None + + +def detect_level(text: str) -> Optional[str]: + """``"intermediate"``, ``"matric"`` or ``None`` from the certificate title.""" + if HSSC_TITLE.search(text): + return "intermediate" + if SSC_TITLE.search(text): + return "matric" + return None + + +# ------------------------------------------------------------- labelled fields + + +def _label_any(boxes: Sequence[TextBox], labels: Sequence[str], threshold: float = 75) -> Optional[Tuple[TextBox, str]]: + best = None + for lab in labels: + b = find_label(boxes, lab, threshold=threshold) + if b is not None: + s = label_score(b.text, lab) + if best is None or s > best[2]: + best = (b, lab, s) + return (best[0], best[1]) if best else None + + +def _after_label(text: str, label: str) -> str: + """Text remaining in a box after the letters of ``label``.""" + target = len(normalize_label(label)) + seen = 0 + for i, ch in enumerate(text): + if ch.isascii() and ch.isalpha(): + seen += 1 + if seen >= target: + return text[i + 1 :].strip(" :.-_") + return "" + + +def _numeric_field(page: Page, labels: Sequence[str]) -> FieldResult: + hit = _label_any(page.boxes, labels) + if hit is None: + return FieldResult.missing(f"'{labels[0]}' label not found") + label, lab = hit + candidates: List[Tuple[str, TextBox]] = [] + rest = _after_label(label.text, lab) + if rest: + candidates.append((rest, label)) + for b in boxes_right_of(page.boxes, label, max_gap=12): + candidates.append((b.text, b)) + # Values printed slightly above or below the label line (FBISE prints + # the serial number raised, in red). + h = max(label.height, 1.0) + near = [ + b + for b in page.boxes + if b is not label + and b.x0 >= label.x1 - h + and b.x0 - label.x1 < 10 * h + and 0.6 * h <= abs(b.cy - label.cy) < 1.6 * h + ] + for b in sorted(near, key=lambda b: abs(b.cy - label.cy)): + candidates.append((b.text, b)) + for text, box in candidates: + token = fix_digits(text, 0.5).strip() + m = re.search(r"[0-9][0-9/\-]{3,}[0-9]", token) + if not m: + continue + value = m.group(0) + allow = "0123456789/-" if "/" in value or "-" in value else "0123456789" + if box is not label: + rtext, rconf = page.reread(box, allowlist=allow) + rm = re.search(r"[0-9][0-9/\-]{3,}[0-9]", rtext) + if rm and rm.group(0) == value: + return make_field(value, combine_confidence(box.confidence, rconf), raw=text) + if rm and rconf > box.confidence + 0.2: + f = make_field(rm.group(0), rconf * 0.7, raw=text, reason="OCR reads disagree; please verify") + f.status = FieldStatus.LOW_CONFIDENCE + return f + return make_field(value, box.confidence, raw=text) + return FieldResult.missing(f"no number next to '{lab}'") + + +def _name_after( + page: Page, labels: Sequence[str], stop_before: Optional[TextBox] = None +) -> Tuple[FieldResult, Optional[TextBox]]: + hit = _label_any(page.boxes, labels, threshold=78) + if hit is None: + return FieldResult.missing(f"'{labels[0]}' label not found"), None + label, lab = hit + cands: List[Tuple[str, TextBox]] = [] + rest = _after_label(label.text, lab) + if rest: + cands.append((rest, label)) + cands += [(b.text, b) for b in boxes_right_of(page.boxes, label, max_gap=20)] + for text, box in cands: + if SKIP_IN_NAMES.search(text) or ascii_ratio(text) < 0.8: + continue + name = clean_person_name(respace(page, box if box is not label else None, text)) + if name and len(name) >= 3: + return make_field(name.upper(), box.confidence, raw=text), label + return FieldResult.missing(f"no name next to '{lab}'"), label + + +def _name_on_line_above(page: Page, anchor: TextBox) -> FieldResult: + """Fallback for a garbled "Certified that" label: the trailing run of + uppercase words on the line directly above the father-name line.""" + lines = group_lines(page.boxes) + for i, line in enumerate(lines): + if anchor in line and i > 0: + prev = lines[i - 1] + text = " ".join(b.text for b in prev) + m = re.search(r"((?:[A-Z][A-Z.'\-]+\s?){1,6})\s*$", text) + if m: + name = clean_person_name(m.group(1)) + if name and len(name) >= 3 and not SKIP_IN_NAMES.search(name): + f = make_field( + name.upper(), + min(b.confidence for b in prev) * 0.8, + raw=text, + reason="label unreadable; located by position", + ) + f.status = FieldStatus.LOW_CONFIDENCE + return f + return FieldResult.missing("'Certified that' label not found") + + +def _institute(page: Page) -> FieldResult: + """Institution line: FBISE prints it on the line after "... Candidate from".""" + lines = group_lines(page.boxes) + for i, line in enumerate(lines): + text = " ".join(b.text for b in line) + if re.search(r"candidate\s*from|regular\s*candidate|private\s*candidate", text, re.I): + rest = re.split(r"from", text, flags=re.I)[-1].strip(" :.") + if len(rest) > 8 and not re.search(r"candidate", rest, re.I): + conf = min(b.confidence for b in line) + return make_field(clean_spaces(rest).upper(), conf, raw=text) + if i + 1 < len(lines): + nxt = lines[i + 1] + t = clean_spaces(" ".join(respace(page, b, b.text) for b in nxt)) + if len(t) > 5 and not re.search(r"statement|grade|marks", t, re.I): + conf = sum(b.confidence for b in nxt) / len(nxt) + return make_field(t.upper().strip(" ."), conf, raw=t) + hit = _label_any(page.boxes, INSTITUTE_LABELS[1:], threshold=85) + if hit: + label, lab = hit + right = boxes_right_of(page.boxes, label, max_gap=20) + if right: + t = clean_spaces(" ".join(b.text for b in right)) + return make_field(t.upper(), min(b.confidence for b in right), raw=t) + return FieldResult.missing("institution not found") + + +# ------------------------------------------------------------------ marks table + + +def _int(text: str) -> Optional[int]: + d = digits_only(text) + return int(d) if d and len(d) <= 4 else None + + +def _numbers_in(line: Sequence[TextBox]) -> List[TextBox]: + out = [] + for b in line: + t = b.text.strip() + if re.fullmatch(r"[0-9OoIlSB|]{2,4}", t) or (re.fullmatch(r"[0-9]{1,4}", t)): + out.append(b) + return out + + +def _column_x(boxes: Sequence[TextBox], words: Sequence[str]) -> Optional[float]: + hit = _label_any(boxes, words, threshold=75) + return hit[0].cx if hit else None + + +def extract_marks(page: Page) -> Tuple[List[Dict], Dict[str, FieldResult], List[str]]: + """Reads the subject-wise marks table. + + Returns: + ``(rows, totals, warnings)`` where rows are dicts with ``subject``, + ``max_marks`` and ``obtained_marks`` and totals holds ``total_marks`` + and ``max_marks`` FieldResults. + """ + boxes = page.boxes + warnings: List[str] = [] + max_x = _column_x(boxes, ("Maximum", "Max Marks", "Total Marks")) + obt_x = _column_x(boxes, ("Obtained", "Marks Obtained", "Obtained Marks")) + header = _label_any(boxes, ("Subject(s)", "Subjects", "Subject"), threshold=78) + total_label = _label_any(boxes, ("TOTAL", "Grand Total"), threshold=85) + + lines = group_lines(boxes) + top = header[0].y1 if header else None + bottom = total_label[0].cy if total_label else None + + rows: List[Dict] = [] + raw_rows: List[Tuple[str, Optional[int], Optional[int], List[TextBox]]] = [] + for line in lines: + cy = sum(b.cy for b in line) / len(line) + if top is not None and cy <= top: + continue + if bottom is not None and cy >= bottom - 2: + continue + nums = _numbers_in(line) + words = [b for b in line if b not in nums and re.search(r"[A-Za-z]{3,}", b.text)] + if not words or not nums: + continue + subject = clean_spaces(" ".join(b.text for b in words)) + subject = re.sub(r"^[0-9]+\s*[.)]?\s*", "", subject).strip(" .:") + if re.search(r"marks in words|islamabad|dated|grade|statement", subject, re.I): + continue + mx, ob = _assign_columns(nums, max_x, obt_x) + raw_rows.append((subject.upper(), mx, ob, nums)) + + total_max = total_ob = None + total_conf = 0.0 + if total_label is not None: + tl = total_label[0] + same = [b for b in boxes if abs(b.cy - tl.cy) < max(tl.height, 10) * 0.8 and b is not tl] + nums = _numbers_in(same) + total_max, total_ob = _assign_columns(nums, max_x, obt_x) + total_conf = min((b.confidence for b in nums), default=0.0) + + words_total = None + for line in lines: + t = " ".join(b.text for b in line) + if re.search(r"marks\s*in\s*words", t, re.I): + words_total = words_to_number(re.split(r"words\W*", t, flags=re.I)[-1]) + break + + sub_rows = [(r[0], r[1], r[2]) for r in raw_rows] + ok, problems = check_subjects(sub_rows, total_max, total_ob) + if not ok and raw_rows: + raw_rows = _repair_rows(page, raw_rows, max_x, obt_x) + sub_rows = [(r[0], r[1], r[2]) for r in raw_rows] + ok, problems = check_subjects(sub_rows, total_max, total_ob) + + # Resolve totals using every independent source. + sum_ob = sum(r[2] or 0 for r in sub_rows) if sub_rows and all(r[2] is not None for r in sub_rows) else None + sum_max = sum(r[1] or 0 for r in sub_rows) if sub_rows and all(r[1] is not None for r in sub_rows) else None + sources = [v for v in (total_ob, words_total, sum_ob) if v is not None] + totals: Dict[str, FieldResult] = {} + if sources: + best = max(set(sources), key=sources.count) + agreeing = sources.count(best) + conf = {1: 0.6, 2: 0.95, 3: 0.99}[min(agreeing, 3)] + if total_ob is not None and best == total_ob: + conf = max(conf, total_conf) if agreeing > 1 else min(conf, max(total_conf, 0.4)) + f = make_field(str(best), conf) + if agreeing == 1 and len(sources) > 1: + f.status = FieldStatus.LOW_CONFIDENCE + f.reason = "table total, marks-in-words and subject sum disagree" + warnings.append("marks total could not be confirmed by a second source") + totals["total_marks"] = f + else: + totals["total_marks"] = FieldResult.missing("total marks not found") + + mx_sources = [v for v in (total_max, sum_max) if v is not None] + if mx_sources: + if len(mx_sources) == 2 and mx_sources[0] == mx_sources[1]: + totals["max_marks"] = make_field(str(mx_sources[0]), 0.95) + else: + # Prefer the printed TOTAL row; flag it unless confirmed. + best = total_max if total_max is not None else mx_sources[0] + f = make_field(str(best), 0.5) + f.status = FieldStatus.LOW_CONFIDENCE + f.reason = "maximum marks could not be confirmed by the subject rows" + totals["max_marks"] = f + else: + totals["max_marks"] = FieldResult.missing("maximum marks not found") + + for subject, mx, ob in sub_rows: + rows.append({"subject": subject, "max_marks": mx, "obtained_marks": ob}) + if rows and not ok: + warnings.append("marks table failed validation: " + "; ".join(problems[:3])) + if not rows: + warnings.append("marks table not found") + return rows, totals, warnings + + +def _assign_columns( + nums: List[TextBox], max_x: Optional[float], obt_x: Optional[float] +) -> Tuple[Optional[int], Optional[int]]: + vals = [(b, _int(b.text)) for b in nums] + vals = [(b, v) for b, v in vals if v is not None] + if not vals: + return None, None + if max_x is not None and obt_x is not None: + mx = min(vals, key=lambda t: abs(t[0].cx - max_x)) + ob = min(vals, key=lambda t: abs(t[0].cx - obt_x)) + if mx[0] is ob[0]: + if abs(mx[0].cx - max_x) < abs(mx[0].cx - obt_x): + return mx[1], None + return None, ob[1] + return mx[1], ob[1] + if len(vals) >= 2: + vals.sort(key=lambda t: t[0].cx) + return vals[-2][1], vals[-1][1] + return None, None + + +def _repair_rows(page: Page, rows, max_x, obt_x): + """Re-reads numeric cells with a digit allowlist.""" + fixed = [] + for subject, mx, ob, nums in rows: + new_nums = [] + for b in nums: + text, conf = page.reread(b, allowlist="0123456789", pad_x=0.4) + new_nums.append(TextBox(text or b.text, conf or b.confidence, b.polygon)) + mx2, ob2 = _assign_columns(new_nums, max_x, obt_x) + fixed.append((subject, mx2 if mx2 is not None else mx, ob2 if ob2 is not None else ob, nums)) + return fixed + + +# ------------------------------------------------------------------------- main + + +def extract(page: Page, level_hint: Optional[str] = None) -> Tuple[Dict[str, FieldResult], List[str], Dict, List[Dict]]: + """Extracts a board certificate. + + Args: + level_hint: ``"matric"`` or ``"intermediate"`` from the caller; the + certificate title overrides it when they disagree. + + Returns: + ``(fields, warnings, metadata, subject_rows)``. + """ + text = page.text + warnings: List[str] = [] + fields: Dict[str, FieldResult] = {} + + level = detect_level(text) + if level and level_hint and level != level_hint: + warnings.append(f"requested {level_hint} but the certificate title says {level}; parsed as {level}") + level = level or level_hint or "matric" + board = detect_board(text) + board_conf = 0.9 + if board is None and CERT_NO_FORMAT.search(text) and re.search(r"islamabad", text, re.I): + # Title line unreadable, but FBISE's certificate-number format and its + # Islamabad header are both present. + board, board_conf = "FBISE", 0.7 + fields["board"] = make_field(board, board_conf) if board else FieldResult.missing("issuing board not recognized") + if board and board != "FBISE": + warnings.append(f"{board} layout has not been verified on real samples; check fields carefully") + + for name, labels in NUMERIC_LABELS.items(): + fields[name] = _numeric_field(page, labels) + if fields["certificate_number"].value is None: + m = CERT_NO_FORMAT.search(text) + if m: + f = make_field(m.group(0), 0.7, raw=m.group(0), reason="label unreadable; matched by number format") + fields["certificate_number"] = f + + fields["student_name"], name_label = _name_after(page, NAME_LABELS) + fields["father_name"], father_label = _name_after(page, FATHER_LABELS) + if fields["student_name"].value is None and father_label is not None: + fields["student_name"] = _name_on_line_above(page, father_label) + + dob = None + for line in group_lines(page.boxes): + t = " ".join(b.text for b in line) + if re.search(r"date\s*of\s*birth|d\.?o\.?b", t, re.I): + ds = find_dates(t) + if ds: + dob = make_field(ds[0], min(b.confidence for b in line), raw=t) + break + fields["date_of_birth"] = dob or FieldResult.missing("date of birth not printed or not found") + + m = SESSION.search(text) + if m: + year = fix_digits(m.group(2), 0.0) + fields["session"] = make_field(m.group(1).upper(), 0.9) + fields["year"] = make_field(year, 0.9) + else: + fields["session"] = FieldResult.missing("examination session not found") + fields["year"] = FieldResult.missing("examination year not found") + fields["exam"] = make_field("SSC" if level == "matric" else "HSSC", 0.9) + + gm = GROUPS.search(text) + grp_label = _label_any(page.boxes, ("Group",), threshold=85) + group_val = None + if grp_label is not None: + right = boxes_right_of(page.boxes, grp_label[0], max_gap=12) + rest = _after_label(grp_label[0].text, "Group") + cand = rest or (right[0].text if right else "") + cand = re.sub(r"^[a-z]+(?=[A-Z])", "", cand.strip()) + if cand and GROUPS.search(cand): + group_val = make_field( + clean_spaces(cand).upper(), + (right[0].confidence if right and not rest else grp_label[0].confidence), + raw=cand, + ) + if group_val is None and gm: + group_val = make_field(clean_spaces(gm.group(1)).upper(), 0.6) + fields["group"] = group_val or FieldResult.missing("group not found") + + gr = None + for line in group_lines(page.boxes): + t = " ".join(b.text for b in line) + g = GRADE.search(t) + if g: + gr = make_field(g.group(1).replace(" ", "").upper(), min(b.confidence for b in line), raw=t) + break + fields["grade"] = gr or FieldResult.missing("grade not found (often printed in a font OCR misreads)") + + fields["institute"] = _institute(page) + + rows, totals, mw = extract_marks(page) + fields.update(totals) + warnings.extend(mw) + + meta = {"board": board, "level": level} + ordered = {n: fields.get(n, FieldResult.missing("not found")) for n in FIELD_NAMES} + return ordered, warnings, meta, rows diff --git a/src/docling_pk/parsers/cnic.py b/src/docling_pk/parsers/cnic.py new file mode 100644 index 0000000..3fc26d8 --- /dev/null +++ b/src/docling_pk/parsers/cnic.py @@ -0,0 +1,618 @@ +""" +CNIC (Computerized National Identity Card) extraction. + +Handles the front and back of the current card designs (2012+ CNIC and +Smart NICOP/SNIC, green layout with English labels). Field strategy: + +* **Positions, not order.** Each value is the text box directly below (or + beside) its label, located by fuzzy label matching, so it does not matter + in what order the OCR engine emits boxes. +* **Formats, then cross-checks.** The CNIC number and dates are matched by + format, re-read from a tight crop with a digit-only allowlist, and reported + as ``ok`` only when the reads agree or validation passes. +* **Dates are assigned chronologically** when all three are present: + birth < issue < expiry is true of every valid card regardless of how the + layout scrambles labels relative to values (the failure documented in the + original lab note). Label geometry is the fallback. +* **Gender is cross-checked** against the last CNIC digit (odd = male, + even = female). When the printed letter is unreadable, the inferred value + is returned with a lower confidence and a reason that says it was inferred. + +The address is printed only on the back, in Urdu (Nastaliq). It is read with +an Urdu-capable recognizer when one is configured, and always reported as +``low_confidence``: Nastaliq OCR quality is not yet good enough to trust +without review. +""" + +from __future__ import annotations + +import datetime as _dt +import re +from typing import Dict, List, Optional, Sequence, Tuple + +from docling_pk.layout import ascii_ratio, clean_spaces, find_label, group_lines +from docling_pk.ocr.base import OCRBackend, TextBox +from docling_pk.parsers.base import ( + clean_person_name, + combine_confidence, + is_label_like, + make_field, + not_applicable, + value_near_label, +) +from docling_pk.pipeline import Page +from docling_pk.schema import FieldResult, FieldStatus +from docling_pk.validation.cnic import check_cnic, find_cnic, gender_from_cnic +from docling_pk.validation.dates import find_dates, parse_date, to_date + +FRONT_FIELDS = [ + "name", + "father_name", + "gender", + "country_of_stay", + "cnic_number", + "date_of_birth", + "date_of_issue", + "date_of_expiry", +] +BACK_FIELDS = ["address", "permanent_address"] +FIELD_NAMES = FRONT_FIELDS + BACK_FIELDS + +LABELS = { + "name": "Name", + "father_name": "Father Name", + "husband_name": "Husband Name", + "gender": "Gender", + "country_of_stay": "Country of Stay", + "cnic_number": "Identity Number", + "date_of_birth": "Date of Birth", + "date_of_issue": "Date of Issue", + "date_of_expiry": "Date of Expiry", +} +ALL_LABEL_TEXTS = list(LABELS.values()) + [ + "Holder's Signature", + "National Identity Card", + "Islamic Republic of Pakistan", + "Pakistan", +] + +CNIC_ALLOWLIST = "0123456789-" +DATE_ALLOWLIST = "0123456789." + +#: Fixed sentence printed on every card back ("if found, drop in a letter box"). +_LOST_CARD_HINT = ("گمشدہ", "کارڈ", "لیٹر", "بکس") +_PRESENT_LABEL = ("موجودہ", "موجوده") +_PERMANENT_LABEL = ("مستقل",) + + +def detect_side(boxes: Sequence[TextBox]) -> str: + """Returns ``"front"``, ``"back"`` or ``"unknown"`` for a CNIC page.""" + front_hits = sum( + 1 + for key in ("father_name", "gender", "cnic_number", "date_of_birth", "date_of_issue", "date_of_expiry") + if find_label(boxes, LABELS[key], threshold=78) is not None + ) + joined = " ".join(b.text for b in boxes) + if front_hits >= 2 or re.search(r"national\s*identity", joined, re.I): + return "front" + urdu = sum(1 for b in boxes if ascii_ratio(b.text) < 0.3 and len(b.text) > 3) + if find_cnic(joined) and (urdu >= 2 or re.search(r"registrar", joined, re.I)): + return "back" + if find_cnic(joined): + return "front" if find_dates(joined) else "back" + return "unknown" + + +# --------------------------------------------------------------------- numbers + + +def _cnic_candidates(page: Page) -> List[Tuple[str, float, TextBox]]: + out = [] + for b in page.boxes: + n = find_cnic(b.text) + if n: + out.append((n, b.confidence, b)) + if not out: + # Number split across two boxes ("35202-" "1234567-1"): try line text. + from docling_pk.layout import group_lines + + for line in group_lines(page.boxes): + for i in range(len(line)): + for j in range(i + 2, min(i + 4, len(line) + 1)): + part = line[i:j] + joined = "".join(b.text for b in part) + n = find_cnic(joined) + if not n: + continue + conf = min(b.confidence for b in part) + out.append( + ( + n, + conf, + TextBox.from_rect( + joined, + conf, + min(b.x0 for b in part), + min(b.y0 for b in part), + max(b.x1 for b in part), + max(b.y1 for b in part), + ), + ) + ) + break + return out + + +def _reread_candidates(page: Page) -> List[Tuple[str, float, TextBox]]: + """Digit-heavy boxes whose first read was garbled ("35202 /2345671"): + re-read them with a digit allowlist and keep any that now validate.""" + out = [] + for b in page.boxes: + digits = sum(c.isdigit() for c in b.text) + if digits < 9 or find_dates(b.text): + continue + text, conf = page.reread(b, allowlist=CNIC_ALLOWLIST) + n = find_cnic(text) + if n and check_cnic(n).valid: + out.append((n, conf * 0.85, b)) + return out + + +def extract_cnic_number(page: Page) -> Tuple[FieldResult, Optional[TextBox]]: + """Finds the CNIC number and confirms it with a digit-only re-read.""" + cands = _cnic_candidates(page) + rescued = False + if not cands: + cands = _reread_candidates(page) + rescued = bool(cands) + if not cands: + return FieldResult.missing("no 13-digit CNIC number pattern found"), None + if rescued: + number, conf, box = cands[0] + f = make_field(number, conf, raw=box.text, reason="first read was garbled; recovered by a digit-only re-read") + f.status = FieldStatus.LOW_CONFIDENCE + return f, box + label = find_label(page.boxes, LABELS["cnic_number"]) + if label is not None: + cands.sort(key=lambda c: abs(c[2].cy - label.cy) + abs(c[2].cx - label.cx) * 0.2) + number, conf, box = cands[0] + + reread_text, reread_conf = page.reread(box, allowlist=CNIC_ALLOWLIST) + second = find_cnic(reread_text) + check = check_cnic(number) + if second == number: + conf = combine_confidence(conf, reread_conf) + reason = None + elif second and check_cnic(second).valid and not check.valid: + number, conf, reason = second, reread_conf * 0.8, "first read failed validation; used digit-only re-read" + elif second: + # Reads disagree: keep the more confident one but flag it. + if reread_conf > conf: + number, conf = second, reread_conf + conf *= 0.6 + reason = "two OCR reads of the CNIC number disagree; please verify" + else: + reason = None + check = check_cnic(number) + if not check.valid: + return FieldResult(None, 0.0, FieldStatus.INVALID, "; ".join(check.problems), raw=box.text), box + f = make_field(check.number, conf, raw=box.text, reason=reason) + if reason and f.status == FieldStatus.OK: + f.status = FieldStatus.LOW_CONFIDENCE + return f, box + + +# ----------------------------------------------------------------------- dates + + +def _date_boxes(page: Page) -> List[Tuple[str, float, TextBox]]: + out = [] + for b in page.boxes: + for d in find_dates(b.text): + out.append((d, b.confidence, b)) + return out + + +def _reread_date(page: Page, value: str, conf: float, box: TextBox) -> Tuple[str, float, Optional[str]]: + text, rconf = page.reread(box, allowlist=DATE_ALLOWLIST) + second = parse_date(text) + if second == value: + return value, combine_confidence(conf, rconf), None + if second is None: + return value, conf, None + if rconf > conf: + return second, rconf * 0.6, "two OCR reads of this date disagree; please verify" + return value, conf * 0.6, "two OCR reads of this date disagree; please verify" + + +def extract_dates(page: Page) -> Dict[str, FieldResult]: + """Extracts birth, issue and expiry dates. + + With three distinct dates the chronological order decides the mapping; + otherwise each date label claims the nearest date box below it. + """ + names = ("date_of_birth", "date_of_issue", "date_of_expiry") + found = _date_boxes(page) + out: Dict[str, FieldResult] = {} + assigned: Dict[str, Tuple[str, float, TextBox]] = {} + + unique: Dict[str, Tuple[str, float, TextBox]] = {} + for d, c, b in found: + if d not in unique or c > unique[d][1]: + unique[d] = (d, c, b) + + if len(unique) >= 3: + ordered = sorted(unique.values(), key=lambda t: to_date(t[0]) or _dt.date.min) + if len(ordered) > 3: + # Keep the three closest to their labels; extra dates are noise. + ordered = sorted(ordered, key=lambda t: -t[1])[:3] + ordered.sort(key=lambda t: to_date(t[0]) or _dt.date.min) + for name, item in zip(names, ordered): + assigned[name] = item + else: + used = set() + for name in names: + label = find_label(page.boxes, LABELS[name], threshold=70) + if label is None: + continue + best = None + for d, c, b in found: + if id(b) in used or b.cy < label.cy - label.height * 0.5: + continue + dist = abs(b.cy - label.cy) + abs(b.x0 - label.x0) * 0.5 + if best is None or dist < best[0]: + best = (dist, (d, c, b)) + if best is not None: + assigned[name] = best[1] + used.add(id(best[1][2])) + + for name in names: + if name not in assigned: + out[name] = FieldResult.missing(f"{LABELS[name].lower()} not found") + continue + value, conf, box = assigned[name] + value, conf, reason = _reread_date(page, value, conf, box) + out[name] = make_field(value, conf, raw=box.text, reason=reason) + if reason: + out[name].status = FieldStatus.LOW_CONFIDENCE + return out + + +def check_date_logic(fields: Dict[str, FieldResult]) -> List[str]: + """Plausibility checks across CNIC dates; returns warnings.""" + warnings = [] + dob = fields["date_of_birth"].as_date() + doi = fields["date_of_issue"].as_date() + doe = fields["date_of_expiry"].as_date() + if dob and doi and not dob < doi: + warnings.append("date of birth is not before date of issue; dates may be misread") + if doi and doe and not doi < doe: + warnings.append("date of issue is not before date of expiry; dates may be misread") + for name in ("date_of_birth", "date_of_issue", "date_of_expiry"): + if warnings and fields[name].status == FieldStatus.OK: + fields[name].status = FieldStatus.LOW_CONFIDENCE + fields[name].reason = "dates failed the birth < issue < expiry check" + return warnings + + +# ----------------------------------------------------------------- text fields + + +def _name_value(text: str) -> bool: + return ascii_ratio(text) >= 0.8 and clean_person_name(text) is not None and not is_label_like(text, ALL_LABEL_TEXTS) + + +def extract_names(page: Page) -> Dict[str, FieldResult]: + boxes = page.boxes + out: Dict[str, FieldResult] = {} + father_label = find_label(boxes, LABELS["father_name"], exclude=[LABELS["name"]]) + husband_label = None + if father_label is None: + husband_label = find_label(boxes, LABELS["husband_name"], exclude=[LABELS["name"]]) + guardian_label = father_label or husband_label + name_label = find_label( + boxes, LABELS["name"], threshold=80, exclude=[LABELS["father_name"], LABELS["husband_name"]] + ) + + skip = [guardian_label] if guardian_label else [] + if name_label is not None: + b = value_near_label(boxes, name_label, _name_value, "Name", max_gap=2.5, skip=skip) + if b is not None and (guardian_label is None or b.cy < guardian_label.cy): + out["name"] = make_field(clean_person_name(b.text), b.confidence, raw=b.text) + if guardian_label is not None: + lt = "Father Name" if father_label else "Husband Name" + b = value_near_label(boxes, guardian_label, _name_value, lt, max_gap=2.5) + if b is not None: + f = make_field(clean_person_name(b.text), b.confidence, raw=b.text) + if husband_label is not None: + f.reason = "card prints Husband Name in this position" + out["father_name"] = f + if "name" not in out or "father_name" not in out: + _names_by_position(page, out) + out.setdefault("name", FieldResult.missing("name label or value not found")) + out.setdefault("father_name", FieldResult.missing("father name label or value not found")) + return out + + +def _names_by_position(page: Page, out: Dict[str, FieldResult]) -> None: + """Fallback when labels are unreadable: on the card front the holder's + name and the father's name are the first two Latin-script name lines + below the header and above the gender / identity-number rows.""" + boxes = page.boxes + header = find_label(boxes, "National Identity Card", threshold=70) or find_label(boxes, "PAKISTAN", threshold=80) + stops = [b for b in boxes if find_cnic(b.text) or find_dates(b.text)] + for key in ("gender", "country_of_stay", "cnic_number"): + lab = find_label(boxes, LABELS[key], threshold=70) + if lab is not None: + stops.append(lab) + top = header.y1 if header is not None else 0.0 + bottom = min((b.y0 for b in stops), default=float("inf")) + names = [ + b + for b in sorted(boxes, key=lambda b: b.cy) + if top - 2 <= b.y0 + and b.y1 <= bottom + 2 + and _name_value(b.text) + and len(re.sub(r"[^A-Za-z]", "", b.text)) >= 5 + and b.confidence >= 0.6 + ] + taken = {f.raw for f in out.values()} + names = [b for b in names if b.text not in taken] + # Stay in the label column: values start near the left edge of the field + # labels, not under the photo (which may carry other printed text). + found_labels = [ + find_label(boxes, LABELS[k], threshold=70) for k in ("father_name", "gender", "cnic_number", "date_of_issue") + ] + column: List[TextBox] = [b for b in found_labels if b is not None] + if column: + left = min(b.x0 for b in column) + unit = sorted(b.height for b in column)[len(column) // 2] + names = [b for b in names if left - 3 * unit <= b.x0 <= left + 4 * unit] + father = out.get("father_name") + if "name" not in out and father is not None and father.value is not None: + fbox = next((b for b in boxes if b.text == father.raw), None) + if fbox is not None: # the holder's name is printed above the father's + names = [b for b in names if b.cy < fbox.cy] + if names: + # Values are printed larger than labels; garbled labels ("Fitay Home" + # for "Father Name") would otherwise pass as names. + tallest = max(b.height for b in names) + names = [b for b in names if b.height >= 0.75 * tallest] + if "name" in out: + anchor = out["name"] + names = [b for b in names if b.text != anchor.raw] + slots = [k for k in ("name", "father_name") if k not in out] + for key, b in zip(slots, names): + f = make_field( + clean_person_name(b.text), + b.confidence * 0.85, + raw=b.text, + reason="label unreadable; located by position on the card", + ) + f.status = FieldStatus.LOW_CONFIDENCE + out[key] = f + + +def extract_gender(page: Page, cnic_number: Optional[str]) -> FieldResult: + boxes = page.boxes + inferred = gender_from_cnic(cnic_number) + label = find_label(boxes, LABELS["gender"]) + + def accept(t: str) -> bool: + return re.fullmatch(r"\s*[MFX]\s*[.|]?\s*", t.upper()) is not None + + printed: Optional[TextBox] = None + if label is not None: + printed = value_near_label(boxes, label, accept, "Gender", max_gap=2.5) + if printed is None: + # Standalone M/F box anywhere above the identity number row. + num_label = find_label(boxes, LABELS["cnic_number"]) + for b in boxes: + if accept(b.text) and (num_label is None or b.cy < num_label.cy): + printed = b + break + + if printed is not None: + value = printed.text.strip().upper()[0] + if inferred and value != "X" and value != inferred: + return make_field( + value, + printed.confidence * 0.5, + raw=printed.text, + reason=f"printed gender {value} disagrees with CNIC number (suggests {inferred})", + ) + conf = printed.confidence + if inferred == value: + conf = combine_confidence(conf, 0.9) + return make_field(value, conf, raw=printed.text) + + if inferred: + f = make_field(inferred, 0.75, reason="gender not legible; inferred from last digit of CNIC number") + f.status = FieldStatus.LOW_CONFIDENCE + return f + return FieldResult.missing("gender value not found") + + +def extract_country(page: Page) -> FieldResult: + label = find_label(page.boxes, LABELS["country_of_stay"]) + if label is None: + return FieldResult.missing("country of stay label not found") + + def accept(t: str) -> bool: + t = t.strip() + return ( + ascii_ratio(t) >= 0.9 + and len(re.sub(r"[^A-Za-z]", "", t)) >= 3 + and not is_label_like(t, list(LABELS.values())) + ) + + b = value_near_label(page.boxes, label, accept, "Country of Stay", max_gap=2.5) + if b is None: + return FieldResult.missing("country of stay value not found") + value = clean_spaces(re.sub(r"[^A-Za-z ]", "", b.text)).title() + return make_field(value, b.confidence, raw=b.text) + + +# -------------------------------------------------------------------- back side + + +URDU_WORK_SIDE = 2048 + + +def _is_label_token(token: str, label: str) -> bool: + from rapidfuzz import fuzz + + from docling_pk.urdu import _RASM + + t = token.strip(":،. ") + return len(t) >= 3 and fuzz.ratio(t.translate(_RASM), label.translate(_RASM)) >= 70 + + +def _strip_address_label(text: str) -> str: + """Drops a leading "موجودہ پتہ :" / "مستقل پتہ :" label from a line.""" + tokens = text.split(" ") + for i, tok in enumerate(tokens[:4]): + if ":" in tok or _is_label_token(tok, "پتہ"): + rest = " ".join(tokens[i + 1 :]).lstrip(": ") + if rest: + return rest + return text + + +def extract_address(page: Page, urdu_backend: Optional[OCRBackend]) -> Dict[str, FieldResult]: + """Reads the present and permanent addresses (Urdu) from a card back. + + The card back is re-read at a higher resolution with an Urdu recognizer, + lines are ordered right to left, the two blocks are split at the + "مستقل پتہ" (permanent address) label (or, failing that, at the largest + vertical gap), and each block is normalized to standard Urdu code points + and lexicon-corrected (see :mod:`docling_pk.urdu`). + """ + from docling_pk.urdu import correct_address_tokens + + if urdu_backend is None: + reason = 'address is printed in Urdu; install Urdu OCR with pip install "docling-pk[urdu]"' + return {"address": FieldResult.missing(reason), "permanent_address": FieldResult.missing(reason)} + + from docling_pk.vision.geometry import resize_long_side + + image, _ = resize_long_side(page.image, max(URDU_WORK_SIDE, max(page.image.shape[:2]))) + boxes = urdu_backend.read(image) + urdu = [b for b in boxes if ascii_ratio(b.text) < 0.6 or re.search(r"[A-Z]{1,2}-?[0-9]", b.text)] + urdu = [b for b in urdu if not find_cnic(b.text) and not re.fullmatch(r"[0-9\s]{8,}", b.text)] + + lines = [] + for line in group_lines(urdu): + line = sorted(line, key=lambda b: -b.x1) # Urdu reads right to left + text = clean_spaces(" ".join(b.text for b in line)) + if sum(text.count(w) for w in _LOST_CARD_HINT) >= 2 or "گمشد" in text: + continue + lines.append((line, text)) + + split = None + for i, (_, text) in enumerate(lines): + if i > 0 and any(_is_label_token(t, "مستقل") for t in text.split(" ")[:3]): + split = i + break + if split is None and len(lines) >= 3: + gaps = [(lines[i + 1][0][0].y0 - max(b.y1 for b in lines[i][0]), i + 1) for i in range(len(lines) - 1)] + gap, idx = max(gaps) + heights = sorted(b.height for ln, _ in lines for b in ln) + if gap > heights[len(heights) // 2] * 0.8: + split = idx + + blocks = {"address": lines[:split] if split else lines, "permanent_address": lines[split:] if split else []} + out = {} + for key, block in blocks.items(): + parts = [_strip_address_label(t) for _, t in block] + parts = [clean_spaces(p.replace(":", " ")).strip(" :،,") for p in parts] + parts = [p for p in parts if len(p) >= 3] + if not parts: + reason = "Urdu address text not detected" + if key == "permanent_address" and blocks["address"]: + reason = "permanent address not separately printed or not detected" + out[key] = FieldResult.missing(reason) + continue + confs = [b.confidence for line, _ in block for b in line] + value = correct_address_tokens(" ".join(parts)) + out[key] = FieldResult( + value=value, + confidence=min(sum(confs) / len(confs), 0.5), + status=FieldStatus.LOW_CONFIDENCE, + reason="Urdu (Nastaliq) OCR is experimental; verify the address manually", + raw=" | ".join(t for _, t in block), + ) + return out + + +# ------------------------------------------------------------------------- main + + +def extract(page: Page, urdu_backend: Optional[OCRBackend] = None) -> Tuple[Dict[str, FieldResult], List[str], Dict]: + """Extracts CNIC fields from a prepared page. + + Returns: + ``(fields, warnings, metadata)``. ``fields`` always contains every + name in :data:`FIELD_NAMES`; fields not printed on the detected side + are ``not_applicable``. + """ + side = detect_side(page.boxes) + warnings: List[str] = [] + meta = {"side": side} + fields: Dict[str, FieldResult] = {} + + number, _ = extract_cnic_number(page) + fields["cnic_number"] = number + + if side == "back": + for n in FRONT_FIELDS: + if n != "cnic_number": + fields[n] = not_applicable("printed on the front of the card") + fields.update(extract_address(page, urdu_backend)) + else: + if side == "unknown": + warnings.append("could not confirm this is a CNIC; check document_type") + fields.update(extract_names(page)) + fields.update(extract_dates(page)) + warnings.extend(check_date_logic(fields)) + fields["gender"] = extract_gender(page, number.value) + fields["country_of_stay"] = extract_country(page) + for n in BACK_FIELDS: + fields[n] = not_applicable("printed on the back of the card; pass the back image too") + + ordered = {n: fields.get(n, FieldResult.missing("not found")) for n in FIELD_NAMES} + return ordered, warnings, meta + + +def merge_sides(results: Sequence[Dict[str, FieldResult]]) -> Dict[str, FieldResult]: + """Merges per-side field dicts (front + back) into one. + + For each field, a found value beats a missing one and higher confidence + wins. If both sides read a CNIC number and they differ, the result is + flagged. + """ + merged: Dict[str, FieldResult] = {} + for fields in results: + for k, f in fields.items(): + cur = merged.get(k) + if cur is None or cur.value is None and f.value is not None: + merged[k] = f + elif cur.value is not None and f.value is not None: + if k == "cnic_number" and cur.value != f.value: + best = cur if cur.confidence >= f.confidence else f + merged[k] = FieldResult( + best.value, + best.confidence * 0.5, + FieldStatus.LOW_CONFIDENCE, + f"front and back CNIC numbers differ ({cur.value} vs {f.value})", + best.raw, + ) + elif k == "cnic_number": + merged[k] = FieldResult( + cur.value, combine_confidence(cur.confidence, f.confidence), cur.status, cur.reason, cur.raw + ) + elif f.confidence > cur.confidence: + merged[k] = f + elif cur.status == FieldStatus.NOT_APPLICABLE and f.status != FieldStatus.NOT_APPLICABLE: + merged[k] = f + return merged diff --git a/src/docling_pk/parsers/degree.py b/src/docling_pk/parsers/degree.py new file mode 100644 index 0000000..a15d8b1 --- /dev/null +++ b/src/docling_pk/parsers/degree.py @@ -0,0 +1,217 @@ +""" +University degree certificate and transcript extraction. + +Degree and transcript layouts vary by university far more than board +certificates do, so this parser relies on the phrases that recur across +Pakistani universities rather than on positions: " s/o ", +"Bachelor/Master/Doctor of ...", "Registration No", "CGPA", "First +Division", "Date of Issue". Verified on COMSATS University Islamabad +documents; other universities are best-effort. +""" + +from __future__ import annotations + +import re +from typing import Dict, List, Tuple + +from docling_pk.layout import clean_spaces, group_lines +from docling_pk.parsers.base import clean_person_name, combine_confidence, make_field +from docling_pk.pipeline import Page +from docling_pk.schema import FieldResult, FieldStatus +from docling_pk.validation.dates import find_dates + +FIELD_NAMES = [ + "student_name", + "father_name", + "degree", + "institution", + "campus", + "registration_number", + "serial_number", + "cgpa", + "division", + "date_of_birth", + "date_of_issue", + "year", +] + +_RELATION = r"(?:s|d|w)\s*[/\\|.]?\s*o\b|son\s+of|daughter\s+of|wife\s+of" +NAME_PAIR = re.compile( + rf"(?:certified\s+that\s+|that\s+)?([A-Z][A-Za-z.'\- ]{{2,60}}?)\s+(?:{_RELATION})\s+([A-Z][A-Za-z.'\- ]{{2,60}}?)" + r"(?=\s*$|\s*[,(]|\s+(?:registration|reg\.?|roll|of\b|has\b|department|enrol|bearing))", + re.I | re.M, +) +DEGREE = re.compile( + r"\b((?:Bachelor|Master|Doctor)\s+of\s+[A-Z][A-Za-z&,()]*(?:\s+(?:in|of|and|&|\(|[A-Z])[A-Za-z&,()]*){0,8}" + # The abbreviation must end at a word boundary, or "MAHMOOD" reads as "MA" + "HMOOD". + r"|(?:BS|MS|MPhil|M\.Phil|PhD|Ph\.D|BBA|MBA|BSc|B\.Sc|MSc|M\.Sc|BE|B\.E|MBBS|LLB|BA|MA)\b\.?\s*(?:\(Hons\)\s*)?(?:in\s+)?[A-Z][A-Za-z&]+(?:\s+[A-Z][A-Za-z&]+){0,5})", +) +INSTITUTION = re.compile( + r"\b([A-Z][A-Za-z&.\-]+(?:\s+[A-Z][A-Za-z&.\-]+){0,5}\s+University(?:\s+of\s+[A-Z][A-Za-z&]+(?:\s+(?:and\s+)?[A-Z][A-Za-z&]+){0,3})?(?:,?\s+(?:Islamabad|Lahore|Karachi|Peshawar|Quetta|Multan|Faisalabad|Rawalpindi))?" + r"|University\s+of\s+[A-Z][A-Za-z&]+(?:\s+(?:and\s+)?[A-Z][A-Za-z&]+){0,4}" + r"|[A-Z][A-Za-z&]+(?:\s+[A-Z][A-Za-z&]+){0,4}\s+Institute\s+of\s+[A-Z][A-Za-z&]+(?:\s+(?:and\s+)?[A-Z][A-Za-z&]+){0,3})" +) +_NO = r"(?:no|n[o0]|[0-9a-z]o|number|#)" # blackletter "No" is often read as "3o"/"2o" +REGISTRATION = re.compile( + rf"(?:registration|reg(?:d)?\.?|enrol(?:l)?ment)\s*{_NO}?\.?\s*[:\-]?\s*" + r"([A-Z0-9]{2,}[/\-][A-Z0-9/\-]{3,}(?:\s?[/\-]\s?[A-Z0-9]{2,}){0,2}|[0-9]{6,})", + re.I, +) +SERIAL = re.compile(rf"serial\s*{_NO}\.?\s*[:\-]?\s*([0-9]{{4,}})", re.I) +CGPA = re.compile(r"\bC\.?\s?G\.?\s?P\.?\s?A\.?\s*[:\-]?\s*([0-4][.,][0-9]{1,2})\b", re.I) +DIVISION = re.compile(r"\b(first|second|third|1st|2nd|3rd)\s+division\b", re.I) +CAMPUS = re.compile(r"(?:from|of|at)\s+([A-Z][A-Za-z]+(?:\s+[A-Z][A-Za-z]+)?)\s+Campus", re.I) +TRANSCRIPT = re.compile(r"transcript|semester|course\s*code|credit\s*hours", re.I) + + +_UNIVERSITY_TYPO = re.compile(r"\bUni[a-z]ersit[yv]\b|\bUnivers[il1]ty\b", re.I) + + +def fix_common_words(text: str) -> str: + """Repairs OCR misreads of words the patterns depend on ("Unibersity").""" + return _UNIVERSITY_TYPO.sub("University", text) + + +def detect_variant(text: str) -> str: + """``"transcript"`` or ``"degree"``.""" + return "transcript" if TRANSCRIPT.search(text) else "degree" + + +def _flat(page: Page) -> Tuple[str, float]: + lines = group_lines(page.boxes) + text = "\n".join(" ".join(b.text for b in line) for line in lines) + confs = [b.confidence for b in page.boxes] or [0.0] + return text, sum(confs) / len(confs) + + +def _conf_for(page: Page, snippet: str, default: float) -> float: + """Mean confidence of the boxes whose text overlaps ``snippet``.""" + words = set(w.lower() for w in re.findall(r"[A-Za-z0-9]{3,}", snippet)) + hits = [b.confidence for b in page.boxes if words & set(w.lower() for w in re.findall(r"[A-Za-z0-9]{3,}", b.text))] + return sum(hits) / len(hits) if hits else default + + +def _verify_id(page: Page, value: str, conf: float, raw: str) -> FieldResult: + """Re-reads an alphanumeric ID with the verifier engine, if configured. + + IDs such as ``UNI/SP22-BCS-001/ISB`` have no checksum, and I/l/1 and + I/H confusions are common. If an independent engine reads the same box + differently, the value is kept but downgraded to ``low_confidence``. + """ + if page.verifier is None: + return make_field(value, conf, raw=raw) + head = value.split("/")[0][:4] + box = next((b for b in page.boxes if head and head.lower() in b.text.lower().replace(" ", "")), None) + if box is None: + return make_field(value, conf, raw=raw) + other, oconf = page.reread(box, pad_x=0.1, pad_y=0.15) + norm = lambda t: re.sub(r"[^A-Z0-9]", "", t.upper()) # noqa: E731 + first = norm(value)[: max(len(norm(head)), 4)] + if first and first in norm(other): + return make_field(value, combine_confidence(conf, oconf * 0.5), raw=raw) + f = make_field(value, conf * 0.6, raw=raw, reason=f"second OCR engine read '{other.strip()}'; please verify") + f.status = FieldStatus.LOW_CONFIDENCE + return f + + +def extract(page: Page) -> Tuple[Dict[str, FieldResult], List[str], Dict]: + """Extracts a degree certificate or transcript. + + Returns: + ``(fields, warnings, metadata)``. + """ + text, mean_conf = _flat(page) + text = fix_common_words(text) + lines = [clean_spaces(line) for line in text.splitlines() if line.strip()] + one_line = clean_spaces(" ".join(lines)) + fields: Dict[str, FieldResult] = {} + warnings: List[str] = [] + variant = detect_variant(text) + + m = next((NAME_PAIR.search(ln) for ln in lines if NAME_PAIR.search(ln)), None) or NAME_PAIR.search(one_line) + if m: + name, father = clean_person_name(m.group(1)), clean_person_name(m.group(2)) + name = re.sub(r"^(?:certified\s+that|that)\s+", "", name or "", flags=re.I) or None + c = _conf_for(page, m.group(0), mean_conf) + fields["student_name"] = make_field(name.upper() if name else None, c, raw=m.group(1)) + fields["father_name"] = make_field(father.upper() if father else None, c, raw=m.group(2)) + else: + fields["student_name"] = FieldResult.missing("no ' s/o ' phrase found") + fields["father_name"] = FieldResult.missing("no ' s/o ' phrase found") + + m = next((DEGREE.search(ln) for ln in lines if DEGREE.search(ln)), None) + if m: + deg = clean_spaces(re.split(r"\s+(?:on|from|with|taught|at|,)\s", m.group(1) + " ")[0]) + fields["degree"] = make_field(deg, _conf_for(page, deg, mean_conf), raw=m.group(1)) + else: + fields["degree"] = FieldResult.missing("degree title not found") + + m = next((INSTITUTION.search(ln) for ln in lines if INSTITUTION.search(ln)), None) + fields["institution"] = ( + make_field(clean_spaces(m.group(1)), _conf_for(page, m.group(1), mean_conf), raw=m.group(1)) + if m + else FieldResult.missing("institution name not found") + ) + m = CAMPUS.search(one_line) + fields["campus"] = ( + make_field(m.group(1).title(), _conf_for(page, m.group(1), mean_conf)) + if m + else FieldResult.missing("campus not printed") + ) + + m = next((REGISTRATION.search(ln) for ln in lines if REGISTRATION.search(ln)), None) + if m and m.group(1).endswith(("-", "/")): + # Wrapped onto the next line ("CIIT/SP22-" / "BAI-010/ATK"). + idx = next(i for i, ln in enumerate(lines) if m.group(1) in ln) + if idx + 1 < len(lines): + cont = re.match(r"\s*([A-Z0-9/\-]{3,})", lines[idx + 1], re.I) + if cont: + m = REGISTRATION.search(m.string.replace(m.group(1), m.group(1) + cont.group(1))) + if m: + reg = re.sub(r"\s+", "", m.group(1)).upper() + reg = re.split(r"(?<=[A-Z0-9])(?=DEPARTMENT|DEPT)", reg)[0] + fields["registration_number"] = _verify_id(page, reg, _conf_for(page, m.group(1), mean_conf), m.group(1)) + else: + fields["registration_number"] = FieldResult.missing("registration number not found") + + m = SERIAL.search(one_line) + fields["serial_number"] = ( + make_field(m.group(1), _conf_for(page, m.group(1), mean_conf)) + if m + else FieldResult.missing("serial number not found") + ) + + m = CGPA.search(one_line) + fields["cgpa"] = ( + make_field(m.group(1).replace(",", "."), _conf_for(page, m.group(0), mean_conf)) + if m + else FieldResult.missing("CGPA not printed or not found") + ) + m = DIVISION.search(one_line) + fields["division"] = ( + make_field(m.group(1).title() + " Division", 0.8) if m else FieldResult.missing("division not printed") + ) + + dob = None + issue = None + for line in group_lines(page.boxes): + t = " ".join(b.text for b in line) + ds = find_dates(t) + if not ds: + continue + c = min(b.confidence for b in line) + if re.search(r"birth", t, re.I) and dob is None: + dob = make_field(ds[0], c, raw=t) + elif re.search(r"issu|date\s*[:\-]|dated|given", t, re.I) and issue is None: + issue = make_field(ds[0], c, raw=t) + fields["date_of_birth"] = dob or FieldResult.missing("date of birth not printed") + fields["date_of_issue"] = issue or FieldResult.missing("date of issue not found") + if issue is not None: + fields["year"] = make_field((issue.value or "")[-4:] or None, issue.confidence) + else: + years = re.findall(r"\b(?:19|20)[0-9]{2}\b", one_line) + fields["year"] = make_field(years[-1], 0.5) if years else FieldResult.missing("year not found") + + meta = {"variant": variant} + ordered = {n: fields.get(n, FieldResult.missing("not found")) for n in FIELD_NAMES} + return ordered, warnings, meta diff --git a/src/docling_pk/pipeline.py b/src/docling_pk/pipeline.py new file mode 100644 index 0000000..482e24d --- /dev/null +++ b/src/docling_pk/pipeline.py @@ -0,0 +1,274 @@ +""" +Page normalization: from an arbitrary photo to an upright, deskewed page with +positioned OCR text boxes. + +Steps: + +1. Scale to a working resolution (large phone photos are slow to OCR and + gain nothing; tiny images lose characters). +2. Pick the right-angle orientation (0/90/180/270) by reading a low-res copy + with an *English-only* reader and scoring how much of the output is + confident Latin text. The original pipeline scored ``confidence x + detection_count`` with an English+Urdu reader; on a sideways card the Urdu + model produced dozens of confident one-character Arabic-script fragments, + so a sideways read beat the correct upright one and the cleanest CNIC + photo in the sample set extracted 0 of 8 fields. +3. Correct residual skew from text-line angles. +4. Full-resolution OCR, producing :class:`~docling_pk.ocr.base.TextBox` es. + +The result, :class:`Page`, also supports re-reading a single region +(``Page.reread``), which parsers use to get a second, character-restricted +read of high-stakes fields such as the CNIC number. +""" + +from __future__ import annotations + +import re +import time +from dataclasses import dataclass, field +from typing import Dict, List, Optional, Sequence, Tuple + +import cv2 +import numpy as np + +from docling_pk.layout import ascii_ratio, reading_order_text +from docling_pk.ocr.base import OCRBackend, TextBox +from docling_pk.vision.geometry import ( + crop_box, + estimate_skew, + resize_long_side, + rotate_bound, + rotate_right_angle, +) + +#: Long side used for orientation probing. +PROBE_SIDE = 960 +#: Long side for the main OCR pass. Upscales small images, downsizes huge ones. +WORK_SIDE = 1800 +#: Skew (degrees) below which no rotation is applied. +MIN_SKEW = 1.0 + +#: Words that only read correctly when the page is upright. Matching any of +#: them strongly confirms an orientation. +ANCHOR_WORDS = ( + "pakistan", + "national", + "identity", + "card", + "name", + "father", + "gender", + "country", + "birth", + "issue", + "expiry", + "federal", + "board", + "education", + "certificate", + "secondary", + "intermediate", + "roll", + "registration", + "serial", + "university", + "transcript", + "degree", + "bachelor", + "subject", + "marks", + "total", + "islamabad", + "lahore", + "certified", + "signature", +) +_WORD = re.compile(r"[A-Za-z]{3,}") + + +@dataclass +class Page: + """An upright, deskewed page image and its OCR text boxes.""" + + image: np.ndarray + boxes: List[TextBox] + backend: OCRBackend + rotation: int = 0 + skew: float = 0.0 + verifier: Optional[OCRBackend] = None + timings: Dict[str, float] = field(default_factory=dict) + + @property + def text(self) -> str: + """All text in reading order.""" + return reading_order_text(self.boxes) + + def reread( + self, + box: TextBox, + allowlist: Optional[str] = None, + pad_x: float = 0.3, + pad_y: float = 0.25, + min_height: int = 48, + backend: Optional[OCRBackend] = None, + ) -> Tuple[str, float]: + """Re-recognizes the text inside ``box`` from a padded, upscaled crop. + + When a ``verifier`` backend is configured (a second, independent OCR + engine), it does the re-read: agreement between two different engines + is far stronger evidence than the same engine agreeing with itself. + + Args: + allowlist: Restrict output characters (e.g. ``"0123456789-"``). + min_height: Crops shorter than this are upscaled to it; the + recognizers were trained on text roughly this tall. + """ + crop = crop_box(self.image, box, pad_x=pad_x, pad_y=pad_y) + if crop.size == 0: + return "", 0.0 + h = crop.shape[0] + if h < min_height: + s = min_height / float(h) + crop = cv2.resize(crop, None, fx=s, fy=s, interpolation=cv2.INTER_CUBIC) + engine = backend or self.verifier or self.backend + return engine.recognize(crop, allowlist=allowlist) + + +def latin_score(boxes: Sequence[TextBox]) -> float: + """How strongly a set of OCR boxes looks like correctly oriented text. + + Sums, over boxes, confidence x number of Latin letters/digits, counting + only boxes that are mostly ASCII. Each recognized anchor word adds a large + bonus, because a word like "PAKISTAN" essentially never appears when the + image is upside down or sideways. + + The sum is then scaled by the share of multi-character boxes that are + wider than tall. Some engines (RapidOCR) rotate tall crops before + recognizing them, so they read sideways text correctly and the text + alone cannot tell a sideways page from an upright one; the box shapes + can. + """ + score = 0.0 + shaped = horizontal = 0 + for b in boxes: + t = b.text.strip() + if len(t) >= 3: + shaped += 1 + horizontal += b.width >= b.height + if ascii_ratio(t) < 0.7: + continue + n = sum(1 for c in t if c.isascii() and c.isalnum()) + score += b.confidence * n + for w in _WORD.findall(t.lower()): + if w in ANCHOR_WORDS and b.confidence > 0.3: + score += 25.0 + if shaped: + score *= horizontal / shaped + return score + + +def drop_noise_boxes(boxes: Sequence[TextBox]) -> List[TextBox]: + """Removes boxes that look like security-paper micro-text. + + Board certificates are printed on paper covered in tiny repeated text + ("FEDERAL BOARD OF INTERMEDIATE..."). OCR engines read it as dozens of + low-confidence junk boxes that interleave with real lines and break + line grouping. Real text on a document is printed at a consistent size, + so boxes much smaller than the confident text *and* themselves + unconfident are dropped. Confident small text (e.g. "ISLAMIC REPUBLIC + OF PAKISTAN" on a CNIC) is kept. + """ + ref = sorted(b.height for b in boxes if b.confidence >= 0.9 and len(b.text.strip()) >= 3) + if len(ref) < 3: + return list(boxes) + median = ref[len(ref) // 2] + return [b for b in boxes if not (b.height < 0.6 * median and b.confidence < 0.8)] + + +def suppress_light_ink(image: np.ndarray) -> np.ndarray: + """Whitens everything lighter than the dark ink. + + Security micro-text and background guilloche patterns are printed in + light ink; the data is printed in dark ink. The cut-off is chosen per + image with Otsu's method. Unlike binarization, pixels darker than the + cut-off keep their gray levels, so character edges stay anti-aliased the + way OCR models expect. + """ + gray = image if image.ndim == 2 else cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) + thresh, _ = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) + out = gray.copy() + out[out > thresh] = 255 + return cv2.cvtColor(out, cv2.COLOR_GRAY2BGR) + + +def detect_orientation(image: np.ndarray, backend: OCRBackend) -> Tuple[int, Dict[int, float]]: + """Returns the counter-clockwise quarter turns that make ``image`` upright. + + Upright is tried first; the other three orientations are only read when + the upright read is not already convincing, so already-upright scans pay + for one low-resolution OCR pass, not four. + """ + probe, _ = resize_long_side(image, PROBE_SIDE) + scores: Dict[int, float] = {} + scores[0] = latin_score(backend.read(probe)) + if scores[0] >= 150.0: + return 0, scores + for k in (1, 3, 2): + scores[k] = latin_score(backend.read(rotate_right_angle(probe, k))) + best = max(scores, key=lambda k: scores[k]) + return best, scores + + +def prepare_page( + image: np.ndarray, + backend: OCRBackend, + orientation_backend: Optional[OCRBackend] = None, + auto_rotate: bool = True, + verifier: Optional[OCRBackend] = None, +) -> Page: + """Runs orientation, deskew and full OCR on a BGR image. + + Args: + backend: Backend for the main read. + orientation_backend: Backend used for orientation probing; must be an + English/Latin reader. Defaults to ``backend``. + verifier: Optional second engine used by :meth:`Page.reread`. + auto_rotate: Set ``False`` to trust the input orientation. + """ + timings: Dict[str, float] = {} + t0 = time.perf_counter() + work, _ = resize_long_side(image, WORK_SIDE) + + rotation = 0 + if auto_rotate: + rotation, _ = detect_orientation(work, orientation_backend or backend) + work = rotate_right_angle(work, rotation) + timings["orientation"] = time.perf_counter() - t0 + + t1 = time.perf_counter() + boxes = backend.read(work) + skew = estimate_skew(boxes) + if abs(skew) >= MIN_SKEW: + work = rotate_bound(work, -skew) + boxes = backend.read(work) + kept = drop_noise_boxes(boxes) + dropped = len(boxes) - len(kept) + boxes = kept + if dropped >= max(10, 0.2 * (len(boxes) + dropped)): + # Heavy micro-text background: try an ink-only copy, keep the better read. + variant = suppress_light_ink(work) + alt = drop_noise_boxes(backend.read(variant)) + if latin_score(alt) > latin_score(boxes): + work, boxes = variant, alt + timings["ink_only_variant"] = 1.0 + timings["ocr"] = time.perf_counter() - t1 + + return Page( + image=work, + boxes=boxes, + backend=backend, + rotation=rotation, + skew=skew, + verifier=verifier, + timings=timings, + ) diff --git a/docling_pk/__init__.py b/src/docling_pk/py.typed similarity index 100% rename from docling_pk/__init__.py rename to src/docling_pk/py.typed diff --git a/src/docling_pk/schema.py b/src/docling_pk/schema.py new file mode 100644 index 0000000..e8347cc --- /dev/null +++ b/src/docling_pk/schema.py @@ -0,0 +1,211 @@ +""" +Output schema for docling-pk. + +Every parser returns a dict of :class:`FieldResult` objects, which the +extractor wraps in a :class:`DocumentResult`. This is the one shape every +other module builds against: the CLI, the benchmark, and the tests all +consume it, so changes here are public API changes. + +Two access styles are supported on purpose. Attribute access is the typed, +IDE-friendly way:: + + result.fields["cnic_number"].value + +Dictionary access mirrors the JSON output exactly, which is what the +project brief's examples use:: + + result["fields"]["cnic_number"]["value"] +""" + +from __future__ import annotations + +import datetime as _dt +import json +from dataclasses import dataclass, field +from enum import Enum +from typing import Any, Dict, Iterator, List, Optional, Tuple + + +class FieldStatus(str, Enum): + """Why a field has the value it has. + + The status exists because a field can fail for different reasons on + different documents, and callers need to tell them apart: a field that + is genuinely not printed on this side of the card needs a different + response than one that is printed but unreadable. + """ + + OK = "ok" + """Extracted and passed every validation check that applies to it.""" + + LOW_CONFIDENCE = "low_confidence" + """Extracted, but OCR confidence or a cross-check was weak. Review it.""" + + INVALID = "invalid" + """Text was found but failed format validation. ``value`` is ``None`` and + the offending text is kept in ``raw``.""" + + NOT_FOUND = "not_found" + """The field should be on this document but could not be located.""" + + NOT_APPLICABLE = "not_applicable" + """The field is not printed on this document variant (for example the + address on the front of a Smart CNIC).""" + + +@dataclass +class FieldResult: + """A single extracted field. + + Attributes: + value: Normalized value, or ``None`` when the field could not be read + reliably. docling-pk never returns a guess in ``value``. + confidence: 0.0 to 1.0. Combines OCR recognition confidence with + validation outcomes; 0.0 whenever ``value`` is ``None``. + status: A :class:`FieldStatus` explaining the outcome. + reason: Human-readable explanation for any status other than ``OK``. + raw: The OCR text the value was derived from, before normalization. + + Example: + >>> f = FieldResult("35202-1234567-9", 0.97) + >>> f.ok + True + """ + + value: Optional[str] + confidence: float + status: FieldStatus = FieldStatus.OK + reason: Optional[str] = None + raw: Optional[str] = None + + def __post_init__(self) -> None: + self.confidence = round(max(0.0, min(1.0, float(self.confidence))), 4) + if self.value is None: + self.confidence = 0.0 + if self.status in (FieldStatus.OK, FieldStatus.LOW_CONFIDENCE): + self.status = FieldStatus.NOT_FOUND + + @classmethod + def missing( + cls, + reason: str, + status: FieldStatus = FieldStatus.NOT_FOUND, + raw: Optional[str] = None, + ) -> FieldResult: + """Builds an empty field that records *why* it is empty.""" + return cls(value=None, confidence=0.0, status=status, reason=reason, raw=raw) + + @property + def ok(self) -> bool: + """``True`` if a value was extracted (whatever its confidence).""" + return self.value is not None + + def as_date(self) -> Optional[_dt.date]: + """Parses a ``DD-MM-YYYY`` value into a :class:`datetime.date`. + + Returns: + The date, or ``None`` if the value is missing or not a date. + + Example: + >>> FieldResult("14-02-2004", 0.9).as_date() + datetime.date(2004, 2, 14) + """ + if not self.value: + return None + try: + return _dt.datetime.strptime(self.value, "%d-%m-%Y").date() + except ValueError: + return None + + def to_dict(self, include_raw: bool = False) -> Dict[str, Any]: + """Serializes to plain JSON-compatible types.""" + out: Dict[str, Any] = { + "value": self.value, + "confidence": self.confidence, + "status": self.status.value, + } + if self.reason: + out["reason"] = self.reason + if include_raw and self.raw is not None: + out["raw"] = self.raw + return out + + +@dataclass +class DocumentResult: + """The full result of extracting one document. + + Attributes: + document_type: Canonical type, e.g. ``"cnic"``, ``"matric"``. + fields: Field name to :class:`FieldResult`. The key set is fixed per + document type, so callers can rely on every key being present. + confidence: Overall document confidence. Averaged across *all* + expected fields, including missing ones, so a document with + several unreadable fields cannot report a high score. + warnings: Non-fatal issues, written for an end user to read. + raw_text: Full OCR text in reading order, for debugging. + tables: Tabular data such as the subject-wise marks statement. + metadata: Processing details: detected variant, board, rotation + applied, image quality scores, OCR backend, timings. + + Example: + >>> r = DocumentResult("cnic", {"cnic_number": FieldResult("35202-1234567-9", 0.9)}, 0.9) + >>> r["fields"]["cnic_number"]["value"] + '35202-1234567-9' + """ + + document_type: str + fields: Dict[str, FieldResult] + confidence: float + warnings: List[str] = field(default_factory=list) + raw_text: Optional[str] = None + tables: Dict[str, List[Dict[str, Any]]] = field(default_factory=dict) + metadata: Dict[str, Any] = field(default_factory=dict) + + def get(self, name: str) -> Optional[str]: + """Shortcut for ``fields[name].value``; ``None`` for unknown names.""" + f = self.fields.get(name) + return f.value if f is not None else None + + @property + def missing_fields(self) -> List[str]: + """Names of fields that have no value.""" + return [k for k, v in self.fields.items() if v.value is None] + + def to_dict(self, include_raw: bool = False) -> Dict[str, Any]: + """Serializes to plain JSON-compatible types. + + Args: + include_raw: Also include ``raw_text`` and per-field ``raw`` OCR + text. Off by default because raw OCR output can contain + personal data that is not part of any extracted field. + """ + out: Dict[str, Any] = { + "document_type": self.document_type, + "fields": {k: v.to_dict(include_raw) for k, v in self.fields.items()}, + "confidence": self.confidence, + "warnings": list(self.warnings), + } + if self.tables: + out["tables"] = self.tables + if self.metadata: + out["metadata"] = self.metadata + if include_raw: + out["raw_text"] = self.raw_text + return out + + def to_json(self, include_raw: bool = False, indent: Optional[int] = 2) -> str: + """Serializes to a JSON string (UTF-8 safe, Urdu kept readable).""" + return json.dumps(self.to_dict(include_raw), ensure_ascii=False, indent=indent) + + # Mapping-style access so result["fields"]["name"]["value"] works. + def __getitem__(self, key: str) -> Any: + if key == "raw_text": + return self.raw_text + return self.to_dict(include_raw=True)[key] + + def keys(self) -> Iterator[str]: + return iter(self.to_dict(include_raw=True).keys()) + + +BBox = Tuple[int, int, int, int] diff --git a/src/docling_pk/urdu.py b/src/docling_pk/urdu.py new file mode 100644 index 0000000..9158ee0 --- /dev/null +++ b/src/docling_pk/urdu.py @@ -0,0 +1,294 @@ +""" +Urdu text normalization and address post-correction. + +OCR models trained on Arabic script often emit Arabic letter forms where +Urdu uses its own code points (``ي`` for ``ی``, ``ك`` for ``ک``, ``ه`` for +``ہ``) and scatter diacritics that are not printed on CNICs. Normalizing to +standard Urdu code points makes the output searchable and comparable. + +Address post-correction snaps individual OCR tokens to a lexicon of words +that recur in Pakistani addresses (house, street, tehsil, district...) and to +district / tehsil names, but only when the match is close and unambiguous. +Tokens not near any lexicon entry (house numbers, locality names) are left +exactly as read, so the correction can only fix words, never invent them. +""" + +from __future__ import annotations + +import re +import unicodedata +from typing import List + +from rapidfuzz import fuzz + +_CHAR_MAP = str.maketrans( + { + "ي": "ی", # Arabic yeh -> Farsi/Urdu yeh + "ى": "ی", # alef maksura + "ك": "ک", # Arabic kaf -> keheh + "ه": "ہ", # Arabic heh -> heh goal + "ة": "ۃ", # teh marbuta -> Urdu teh marbuta goal + "ؤ": "ؤ", + "٫": "،", + ",": "،", + } +) +_DIACRITICS = re.compile("[ً-ٰٟۖ-ۭ]") +_TATWEEL = "ـ" + +#: Words that recur in Pakistani addresses as printed on CNICs. +ADDRESS_WORDS = ( + "مکان", + "نمبر", + "محلہ", + "گلی", + "اسٹریٹ", + "سٹریٹ", + "روڈ", + "کالونی", + "سیکٹر", + "بلاک", + "فیز", + "ٹاؤن", + "گاؤں", + "موضع", + "ڈاکخانہ", + "تحصیل", + "ضلع", + "کینٹ", + "شہر", + "چک", + "بستی", + "نزد", + "مین", + "بازار", + "سوسائٹی", + "ہاؤسنگ", + "اسکیم", + "فلیٹ", + "منزل", + "اپارٹمنٹ", + "قصبہ", + "یونین", + "کونسل", + "وارڈ", + "پتہ", + "موجودہ", + "مستقل", + "ڈاک", + "خانہ", + "ہاؤس", + "پلاٹ", + "مارکیٹ", + "مسجد", + "چوک", + "کوٹ", + "پورہ", + "آباد", + "گارڈن", + "ایونیو", + "انکلیو", +) + +#: Districts and frequently printed tehsils / cities. +PLACES = ( + "راولپنڈی", + "اسلام آباد", + "لاہور", + "کراچی", + "پشاور", + "کوئٹہ", + "ملتان", + "فیصل آباد", + "گوجرانوالہ", + "سیالکوٹ", + "سرگودھا", + "بہاولپور", + "ڈیرہ غازی خان", + "ساہیوال", + "اٹک", + "چکوال", + "جہلم", + "گجرات", + "منڈی بہاؤالدین", + "حافظ آباد", + "شیخوپورہ", + "ننکانہ صاحب", + "قصور", + "اوکاڑہ", + "پاکپتن", + "وہاڑی", + "خانیوال", + "لودھراں", + "مظفر گڑھ", + "لیہ", + "راجن پور", + "رحیم یار خان", + "بہاولنگر", + "جھنگ", + "ٹوبہ ٹیک سنگھ", + "چنیوٹ", + "خوشاب", + "میانوالی", + "بھکر", + "نارووال", + "حیدرآباد", + "سکھر", + "لاڑکانہ", + "میرپور خاص", + "نوابشاہ", + "ٹھٹھہ", + "بدین", + "دادو", + "جیکب آباد", + "شکارپور", + "خیرپور", + "گھوٹکی", + "سانگھڑ", + "عمرکوٹ", + "تھرپارکر", + "مردان", + "صوابی", + "نوشہرہ", + "چارسدہ", + "کوہاٹ", + "بنوں", + "ڈیرہ اسماعیل خان", + "ایبٹ آباد", + "مانسہرہ", + "ہری پور", + "سوات", + "دیر", + "چترال", + "بونیر", + "شانگلہ", + "ملاکنڈ", + "کرک", + "لکی مروت", + "ٹانک", + "ہنگو", + "گلگت", + "اسکردو", + "مظفرآباد", + "میرپور", + "کوٹلی", + "باغ", + "راولاکوٹ", + "گوادر", + "تربت", + "خضدار", + "سبی", + "ژوب", + "لورالائی", + "چمن", + "پشین", + "ٹیکسلا", + "واہ", + "حسن ابدال", + "فتح جنگ", + "پنڈی گھیب", + "جنڈ", + "گوجر خان", + "کہوٹہ", + "مری", + "کلر سیداں", + "تلہ گنگ", + "سوہاوہ", + "کامونکی", + "مریدکے", + "رائیونڈ", + "ڈسکہ", + "سمبڑیال", + "پسرور", + "شکرگڑھ", + "وزیر آباد", + "کھاریاں", + "لالہ موسیٰ", + "سرائے عالمگیر", + "دینہ", +) + +_SINGLE_WORDS: List[str] = sorted(set(ADDRESS_WORDS) | {w for p in PLACES for w in p.split() if len(w) >= 3}) +_LEXICON_SET = set(_SINGLE_WORDS) +_ADDRESS_WORD_SET = set(ADDRESS_WORDS) + +#: Letters that differ only by dots map to one dotless "skeleton" class. +#: Most Nastaliq OCR errors are dot errors (خ read as ح, ٹ as ت, ڑ as ر), +#: so comparing skeletons catches them while plain edit distance does not. +_RASM = str.maketrans( + { + "ب": "ٮ", + "پ": "ٮ", + "ت": "ٮ", + "ٹ": "ٮ", + "ث": "ٮ", + "ن": "ٮ", + "ی": "ٮ", + "ئ": "ٮ", + "ج": "ح", + "چ": "ح", + "خ": "ح", + "ڈ": "د", + "ذ": "د", + "ڑ": "ر", + "ز": "ر", + "ژ": "ر", + "ش": "س", + "ض": "ص", + "ظ": "ط", + "غ": "ع", + "ق": "ف", + "گ": "ک", + } +) +_SKELETONS = [w.translate(_RASM) for w in _SINGLE_WORDS] + + +def _score(token: str, idx: int) -> float: + word = _SINGLE_WORDS[idx] + return max(fuzz.ratio(token, word), fuzz.ratio(token.translate(_RASM), _SKELETONS[idx]) - 4) + + +def normalize_urdu(text: str) -> str: + """Maps Arabic letter forms to Urdu code points and strips diacritics. + + Example: + >>> normalize_urdu("محلّه نوري") + 'محلہ نوری' + """ + t = unicodedata.normalize("NFC", text).translate(_CHAR_MAP) + t = _DIACRITICS.sub("", t).replace(_TATWEEL, "") + # A trailing heh goal followed by hamza variants is common OCR noise. + return re.sub(r"\s+", " ", t).strip() + + +def correct_address_tokens(text: str, min_score: float = 75.0) -> str: + """Snaps OCR'd Urdu address tokens to known address words and place names. + + A token is replaced only if it is at least 3 letters long, is not already + a lexicon word, and its best lexicon match scores ``min_score`` or more + and clearly beats the runner-up. + + Example: + >>> correct_address_tokens("خصیل کامونکی ضع گوجرانوالا") + 'تحصیل کامونکی ضلع گوجرانوالہ' + """ + out = [] + for tok in normalize_urdu(text).split(" "): + core = tok.strip("،۔:.-") + if core in _LEXICON_SET or re.search(r"[A-Za-z0-9]", core) or len(core) < 2: + out.append(tok) + continue + scored = sorted(((_score(core, i), i) for i in range(len(_SINGLE_WORDS))), reverse=True) + (score, idx), runner = scored[0], (scored[1][0] if len(scored) > 1 else 0.0) + best = _SINGLE_WORDS[idx] + threshold = min_score if len(core) >= 4 else 80.0 + if best not in _ADDRESS_WORD_SET: + # Place names are only accepted on a strong match: snapping noise + # onto a real district name would invent data. + threshold = max(threshold, 85.0) + if score >= threshold and score - runner >= 5: + out.append(tok.replace(core, best)) + else: + out.append(tok) + return " ".join(out) diff --git a/src/docling_pk/validation/__init__.py b/src/docling_pk/validation/__init__.py new file mode 100644 index 0000000..1127535 --- /dev/null +++ b/src/docling_pk/validation/__init__.py @@ -0,0 +1 @@ +"""Format validation and cross-checks.""" diff --git a/src/docling_pk/validation/cnic.py b/src/docling_pk/validation/cnic.py new file mode 100644 index 0000000..eafbe12 --- /dev/null +++ b/src/docling_pk/validation/cnic.py @@ -0,0 +1,125 @@ +""" +CNIC number validation. + +A CNIC number has 13 digits printed as ``XXXXX-XXXXXXX-X``. Two structural +facts are used to cross-check OCR output: + +* The first digit encodes the region of registration (1 Khyber + Pakhtunkhwa, 2 former FATA, 3 Punjab, 4 Sindh, 5 Balochistan, + 6 Islamabad, 7 Gilgit-Baltistan, 8 Azad Jammu & Kashmir in practice). + 0 and 9 do not occur. +* The last digit encodes gender: odd for male, even for female. + +Neither is a checksum, so a substituted middle digit cannot be detected. +That is why CNIC digits are also re-read from a tight crop with a digit-only +allowlist and compared across reads before being reported as ``ok``. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import Optional + +from docling_pk.validation.text import fix_digits + +REGION_CODES = { + "1": "Khyber Pakhtunkhwa", + "2": "Federally Administered Tribal Areas", + "3": "Punjab", + "4": "Sindh", + "5": "Balochistan", + "6": "Islamabad Capital Territory", + "7": "Gilgit-Baltistan", + "8": "Azad Jammu and Kashmir", +} + +_SEP = r"[\s\-_.:,`~'–—=]{0,3}" +#: 5-7-1 digit groups with tolerant separators, after look-alike correction. +CNIC_GROUPED = re.compile(rf"(? bool: + return self.number is not None and not self.problems + + +def format_cnic(digits: str) -> str: + """Formats 13 digits as ``XXXXX-XXXXXXX-X``.""" + return f"{digits[:5]}-{digits[5:12]}-{digits[12]}" + + +def find_cnic(text: str) -> Optional[str]: + """Finds the first CNIC-shaped number in ``text``. + + Tolerates the separator misreads seen in real OCR output (``_ : . `` and + spaces) and letter-for-digit confusions (``O`` for ``0`` etc.). + + Example: + >>> find_cnic("Identity Number 35202-1234567.1") + '35202-1234567-1' + >>> find_cnic("no number here") is None + True + """ + for token_text in (text, fix_digits(text, 0.6)): + m = CNIC_GROUPED.search(token_text) + if m: + return format_cnic(m.group(1) + m.group(2) + m.group(3)) + m = CNIC_PLAIN.search(token_text) + if m: + return format_cnic(m.group(1)) + m = CNIC_DASH_ANCHORED.search(fix_digits(text, 0.6)) + if m: + return format_cnic(m.group(1) + m.group(2) + m.group(3)) + return None + + +def check_cnic(number: Optional[str]) -> CNICCheck: + """Validates a formatted CNIC number's structure. + + Example: + >>> check_cnic("35202-1234567-9").valid + True + >>> check_cnic("95202-1234567-9").problems + ('first digit 9 is not a valid region code',) + """ + if not number: + return CNICCheck(None, None, None, ("missing",)) + digits = re.sub(r"[^0-9]", "", number) + if len(digits) != 13: + return CNICCheck(None, None, None, (f"expected 13 digits, got {len(digits)}",)) + problems = [] + region = REGION_CODES.get(digits[0]) + if region is None: + problems.append(f"first digit {digits[0]} is not a valid region code") + return CNICCheck(format_cnic(digits), region, digits[-1], tuple(problems)) + + +def gender_from_cnic(number: Optional[str]) -> Optional[str]: + """Infers gender from the last digit: odd is ``"M"``, even is ``"F"``. + + Example: + >>> gender_from_cnic("35202-1234567-9") + 'M' + """ + if not number: + return None + digits = re.sub(r"[^0-9]", "", number) + if len(digits) != 13: + return None + return "M" if int(digits[-1]) % 2 == 1 else "F" diff --git a/src/docling_pk/validation/dates.py b/src/docling_pk/validation/dates.py new file mode 100644 index 0000000..f6adfe7 --- /dev/null +++ b/src/docling_pk/validation/dates.py @@ -0,0 +1,136 @@ +""" +Date parsing for the formats printed on Pakistani documents. + +All dates are normalized to ``DD-MM-YYYY`` (the format used in the project +brief and familiar to Pakistani users). ``FieldResult.as_date()`` converts +that to :class:`datetime.date` when a real date object is needed. + +Formats seen on real documents: + +* ``14.02.2004`` CNIC (OCR also produces ``14,02.2004`` and ``14.02 2004``) +* ``14-02-2004`` board certificates (date of birth) +* ``14-Feb-2004`` university transcripts +* ``February 22, 2022`` / ``Feb 01, 2024`` certificate issue dates +* ``20th July 2026`` degree issuance dates +""" + +from __future__ import annotations + +import datetime as _dt +import re +from typing import List, Optional + +from rapidfuzz import process + +from docling_pk.validation.text import fix_digits + +MONTHS = { + "january": 1, + "february": 2, + "march": 3, + "april": 4, + "may": 5, + "june": 6, + "july": 7, + "august": 8, + "september": 9, + "october": 10, + "november": 11, + "december": 12, +} +_MONTH_KEYS = list(MONTHS) + [m[:3] for m in MONTHS] + ["sept"] + +MIN_YEAR, MAX_YEAR = 1900, 2100 + +_NUMERIC = re.compile( + r"(? Optional[int]: + w = word.lower().strip(".") + if w in MONTHS: + return MONTHS[w] + if len(w) < 3: + return None + match = process.extractOne(w, _MONTH_KEYS, score_cutoff=75) + if not match: + return None + key = match[0] + if key == "sept": + return 9 + for name, num in MONTHS.items(): + if name.startswith(key[:3]): + return num + return None # pragma: no cover + + +def _build(day: int, month: int, year: int) -> Optional[str]: + if not (MIN_YEAR <= year <= MAX_YEAR): + return None + try: + _dt.date(year, month, day) + except ValueError: + return None + return f"{day:02d}-{month:02d}-{year:04d}" + + +def parse_date(text: str) -> Optional[str]: + """Parses the first date in ``text`` into ``DD-MM-YYYY``. + + Returns ``None`` if no valid calendar date is found (``31.02.2003`` is + rejected rather than silently corrected). + + Example: + >>> parse_date("Date of Birth 14.02.2004") + '14-02-2004' + >>> parse_date("Islamabad Dated February 22, 2022") + '22-02-2022' + >>> parse_date("20th July 2026") + '20-07-2026' + """ + found = find_dates(text) + return found[0] if found else None + + +def find_dates(text: str) -> List[str]: + """All valid dates in ``text``, in order of appearance, as ``DD-MM-YYYY``.""" + hits = [] + for m in _NUMERIC.finditer(text): + d, mo, y = (fix_digits(g, 0.0) for g in m.groups()) + try: + value = _build(int(d), int(mo), int(y)) + except ValueError: + value = None + if value: + hits.append((m.start(), value)) + for m in _DAY_MON_YEAR.finditer(text): + month = _month_number(m.group(2)) + if month: + value = _build(int(m.group(1)), month, int(m.group(3))) + if value: + hits.append((m.start(), value)) + for m in _MON_DAY_YEAR.finditer(text): + month = _month_number(m.group(1)) + if month: + value = _build(int(m.group(2)), month, int(m.group(3))) + if value: + hits.append((m.start(), value)) + hits.sort() + out: List[str] = [] + for _, v in hits: + if v not in out: + out.append(v) + return out + + +def to_date(value: Optional[str]) -> Optional[_dt.date]: + """Converts a ``DD-MM-YYYY`` string to :class:`datetime.date`.""" + if not value: + return None + try: + return _dt.datetime.strptime(value, "%d-%m-%Y").date() + except ValueError: + return None diff --git a/src/docling_pk/validation/marks.py b/src/docling_pk/validation/marks.py new file mode 100644 index 0000000..1b46e04 --- /dev/null +++ b/src/docling_pk/validation/marks.py @@ -0,0 +1,135 @@ +""" +Marks-table validation for board certificates. + +Board certificates carry two independent copies of the total: the TOTAL row +of the marks table and the "Marks in words" line. They also carry per-subject +marks that must add up to the total. Together these give a free consistency +check that catches most single-digit OCR errors in the numbers that matter +most for admissions. +""" + +from __future__ import annotations + +import re +from typing import Iterable, Optional, Sequence, Tuple + +from rapidfuzz import process + +_UNITS = { + "zero": 0, + "one": 1, + "two": 2, + "three": 3, + "four": 4, + "five": 5, + "six": 6, + "seven": 7, + "eight": 8, + "nine": 9, + "ten": 10, + "eleven": 11, + "twelve": 12, + "thirteen": 13, + "fourteen": 14, + "fifteen": 15, + "sixteen": 16, + "seventeen": 17, + "eighteen": 18, + "nineteen": 19, +} +_TENS = { + "twenty": 20, + "thirty": 30, + "forty": 40, + "fourty": 40, + "fifty": 50, + "sixty": 60, + "seventy": 70, + "eighty": 80, + "ninety": 90, +} +_SCALES = {"hundred": 100, "thousand": 1000} +_VOCAB = list(_UNITS) + list(_TENS) + list(_SCALES) +_IGNORED = {"and", "only", "marks"} + + +def _canonical_word(word: str) -> Optional[str]: + w = word.lower() + if w in _UNITS or w in _TENS or w in _SCALES: + return w + if w in _IGNORED or len(w) < 3: + return None + hit = process.extractOne(w, _VOCAB, score_cutoff=80) + return hit[0] if hit else None + + +def words_to_number(text: str) -> Optional[int]: + """Converts English number words to an integer, tolerating OCR typos. + + Example: + >>> words_to_number("Seven Hundred Eighty Seven") + 787 + >>> words_to_number("EIGHT HUNDRED EIGHTY-TWO") + 882 + >>> words_to_number("Five Hundred Fiffy Four") + 554 + >>> words_to_number("no numbers here") is None + True + """ + words = [w for w in re.split(r"[^A-Za-z]+", text) if w] + total, current, seen = 0, 0, False + for raw in words: + w = _canonical_word(raw) + if w is None: + continue + seen = True + if w in _UNITS: + current += _UNITS[w] + elif w in _TENS: + current += _TENS[w] + elif w == "hundred": + current = max(current, 1) * 100 + elif w == "thousand": + total += max(current, 1) * 1000 + current = 0 + return total + current if seen else None + + +def check_subjects( + rows: Sequence[Tuple[str, Optional[int], Optional[int]]], + total_max: Optional[int], + total_obtained: Optional[int], +) -> Tuple[bool, list]: + """Checks subject rows against the totals. + + Args: + rows: ``(subject, max_marks, obtained_marks)`` tuples. + + Returns: + ``(consistent, problems)``. ``consistent`` is ``True`` only if every + row is complete, no obtained mark exceeds its maximum, and both + column sums equal the printed totals. + """ + problems = [] + if not rows: + return False, ["no subject rows found"] + for name, mx, ob in rows: + if mx is None or ob is None: + problems.append(f"{name}: incomplete row") + elif ob > mx: + problems.append(f"{name}: obtained {ob} exceeds maximum {mx}") + if problems: + return False, problems + sum_max = sum(r[1] for r in rows) # type: ignore[misc] + sum_ob = sum(r[2] for r in rows) # type: ignore[misc] + if total_max is not None and sum_max != total_max: + problems.append(f"maximum marks add up to {sum_max}, total says {total_max}") + if total_obtained is not None and sum_ob != total_obtained: + problems.append(f"obtained marks add up to {sum_ob}, total says {total_obtained}") + return not problems, problems + + +def agree(values: Iterable[Optional[int]]) -> Optional[int]: + """Returns the value if all non-``None`` values are equal, else ``None``.""" + vals = {v for v in values if v is not None} + return vals.pop() if len(vals) == 1 else None diff --git a/src/docling_pk/validation/text.py b/src/docling_pk/validation/text.py new file mode 100644 index 0000000..722765a --- /dev/null +++ b/src/docling_pk/validation/text.py @@ -0,0 +1,76 @@ +"""Character-level cleanup for OCR output in numeric contexts.""" + +from __future__ import annotations + +import re + +#: Letters OCR engines commonly produce in place of digits. Only applied to +#: tokens that are already mostly digits, never to free text. +DIGIT_CONFUSIONS = str.maketrans( + { + "O": "0", + "o": "0", + "Q": "0", + "D": "0", + "U": "0", + "I": "1", + "l": "1", + "|": "1", + "i": "1", + "L": "1", + "!": "1", + "J": "1", + "]": "1", + "[": "1", + "Z": "2", + "z": "2", + "S": "5", + "s": "5", + "$": "5", + "G": "6", + "b": "6", + "T": "7", + "B": "8", + "g": "9", + "q": "9", + } +) + +#: Arabic-Indic and Extended Arabic-Indic digits (Urdu uses the extended set). +_EASTERN_DIGITS = str.maketrans("٠١٢٣٤٥٦٧٨٩۰۱۲۳۴۵۶۷۸۹", "01234567890123456789") + +_ASCII_DIGIT = re.compile(r"[0-9]") + + +def eastern_to_ascii_digits(text: str) -> str: + """Converts Arabic-Indic / Urdu digits to ASCII digits. + + Done explicitly and up front so the rest of the code can match with + ``[0-9]``: Python's ``\\d`` and ``int()`` silently accept Unicode digits, + which would let a garbled read parse as a different number. + """ + return text.translate(_EASTERN_DIGITS) + + +def fix_digits(token: str, min_digit_ratio: float = 0.5) -> str: + """Maps letter look-alikes to digits inside a mostly-numeric token. + + Example: + >>> fix_digits("6543O1") + '654301' + >>> fix_digits("Name") + 'Name' + """ + token = eastern_to_ascii_digits(token) + core = re.sub(r"[\s\-._,:/]", "", token) + if not core: + return token + ratio = len(_ASCII_DIGIT.findall(core)) / len(core) + if ratio < min_digit_ratio: + return token + return token.translate(DIGIT_CONFUSIONS) + + +def digits_only(text: str) -> str: + """Keeps ASCII digits only (after look-alike correction).""" + return re.sub(r"[^0-9]", "", fix_digits(text, 0.0)) diff --git a/src/docling_pk/vision/__init__.py b/src/docling_pk/vision/__init__.py new file mode 100644 index 0000000..814bf6f --- /dev/null +++ b/src/docling_pk/vision/__init__.py @@ -0,0 +1 @@ +"""Image loading, quality checks and geometry.""" diff --git a/src/docling_pk/vision/geometry.py b/src/docling_pk/vision/geometry.py new file mode 100644 index 0000000..6be98bc --- /dev/null +++ b/src/docling_pk/vision/geometry.py @@ -0,0 +1,103 @@ +""" +Geometric normalization: resizing, right-angle rotation and skew correction. + +Skew is estimated from the OCR engine's own text-line polygons rather than +from dark pixels. The original ``_deskew`` used ``cv2.minAreaRect`` over +every dark pixel in the frame; on real photos it returned -90 degrees for +15 of 16 samples (a no-op) because the background, the photo and the +document edges dominate the pixel set, and on OpenCV >= 4.5 the angle +convention changed so the correction formula was wrong anyway. Text lines +are exactly the structure we want horizontal, and the OCR detector already +finds them while ignoring backgrounds such as striped fabric. +""" + +from __future__ import annotations + +import math +from typing import Sequence, Tuple + +import cv2 +import numpy as np + +from docling_pk.ocr.base import TextBox + + +def resize_long_side(image: np.ndarray, target: int) -> Tuple[np.ndarray, float]: + """Scales ``image`` so its long side equals ``target``. + + Returns: + ``(resized, scale)`` where ``scale`` maps original to resized pixels. + """ + h, w = image.shape[:2] + scale = target / float(max(h, w)) + if abs(scale - 1.0) < 1e-3: + return image, 1.0 + interp = cv2.INTER_AREA if scale < 1 else cv2.INTER_CUBIC + return cv2.resize(image, None, fx=scale, fy=scale, interpolation=interp), scale + + +def rotate_right_angle(image: np.ndarray, quarter_turns_ccw: int) -> np.ndarray: + """Rotates by a multiple of 90 degrees counter-clockwise (lossless).""" + k = quarter_turns_ccw % 4 + if k == 0: + return image + codes = {1: cv2.ROTATE_90_COUNTERCLOCKWISE, 2: cv2.ROTATE_180, 3: cv2.ROTATE_90_CLOCKWISE} + return cv2.rotate(image, codes[k]) + + +def rotate_bound(image: np.ndarray, angle_deg: float) -> np.ndarray: + """Rotates counter-clockwise by ``angle_deg``, enlarging the canvas so no + corner is cropped. Border pixels are replicated, not black, so the OCR + detector does not see a hard artificial edge.""" + if abs(angle_deg) < 0.05: + return image + h, w = image.shape[:2] + m = cv2.getRotationMatrix2D((w / 2.0, h / 2.0), angle_deg, 1.0) + cos, sin = abs(m[0, 0]), abs(m[0, 1]) + nw, nh = int(h * sin + w * cos), int(h * cos + w * sin) + m[0, 2] += nw / 2.0 - w / 2.0 + m[1, 2] += nh / 2.0 - h / 2.0 + return cv2.warpAffine(image, m, (nw, nh), flags=cv2.INTER_CUBIC, borderMode=cv2.BORDER_REPLICATE) + + +def estimate_skew(boxes: Sequence[TextBox], min_aspect: float = 2.5) -> float: + """Estimates document skew in degrees from text-line polygons. + + Only clearly horizontal-ish, elongated boxes vote; the result is the + confidence-weighted median of their top-edge angles. Positive means the + text rises to the right; ``rotate_bound(image, -skew)`` levels it. + Returns 0.0 when there is not enough evidence. + """ + angles, weights = [], [] + for b in boxes: + if len(b.polygon) < 4: + continue + (x0, y0), (x1, y1) = b.polygon[0], b.polygon[1] + dx, dy = x1 - x0, y1 - y0 + length = math.hypot(dx, dy) + if length < 1 or b.height < 1 or length / max(b.height, 1.0) < min_aspect: + continue + a = math.degrees(math.atan2(-dy, dx)) + if abs(a) > 30: + continue + angles.append(a) + weights.append(length * max(b.confidence, 0.05)) + if len(angles) < 3: + return 0.0 + order = np.argsort(angles) + sorted_angles = np.asarray(angles)[order] + w = np.asarray(weights)[order] + cum = np.cumsum(w) + return float(sorted_angles[np.searchsorted(cum, cum[-1] / 2.0)]) + + +def crop_box(image: np.ndarray, box: TextBox, pad_x: float = 0.25, pad_y: float = 0.25) -> np.ndarray: + """Crops a text box with padding proportional to its height.""" + h, w = image.shape[:2] + py = box.height * pad_y + px = box.height * pad_x + x0 = int(max(0, math.floor(box.x0 - px))) + x1 = int(min(w, math.ceil(box.x1 + px))) + y0 = int(max(0, math.floor(box.y0 - py))) + y1 = int(min(h, math.ceil(box.y1 + py))) + return image[y0:y1, x0:x1] diff --git a/src/docling_pk/vision/image_io.py b/src/docling_pk/vision/image_io.py new file mode 100644 index 0000000..e29066d --- /dev/null +++ b/src/docling_pk/vision/image_io.py @@ -0,0 +1,138 @@ +""" +Loading documents into images. + +Accepts file paths (images or PDFs), raw bytes, NumPy arrays and PIL images, +so the library works the same whether the caller has a file on disk, an +upload from a web form, or a frame already in memory. + +Paths are read with ``np.fromfile`` + ``cv2.imdecode`` rather than +``cv2.imread`` because ``cv2.imread`` silently returns ``None`` for paths +with non-ASCII characters on Windows (for example a folder named in Urdu), +which would surface as a misleading "not a valid image" error. +""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any, List, Union + +import cv2 +import numpy as np + +from docling_pk.errors import ImageLoadError + +ImageSource = Union[str, "os.PathLike[str]", bytes, bytearray, np.ndarray, Any] + +PDF_SUFFIXES = {".pdf"} +PDF_RENDER_DPI = 200 + + +def load_pages(source: ImageSource, max_pages: int = 10) -> List[np.ndarray]: + """Loads a document as a list of BGR ``uint8`` images, one per page. + + Args: + source: A path to an image or PDF, the file's bytes, a NumPy array + (grayscale, BGR or BGRA) or a ``PIL.Image.Image``. + max_pages: Upper bound on PDF pages rendered. + + Raises: + FileNotFoundError: If ``source`` is a path that does not exist. + ImageLoadError: If the data cannot be decoded as an image or PDF. + ``ImageLoadError`` is a ``ValueError`` subclass. + """ + if isinstance(source, np.ndarray): + return [_normalize_array(source)] + + if _is_pil_image(source): + rgb = np.asarray(source.convert("RGB")) # type: ignore[union-attr] + return [cv2.cvtColor(rgb, cv2.COLOR_RGB2BGR)] + + if isinstance(source, (bytes, bytearray)): + data = bytes(source) + if data[:5] == b"%PDF-": + return _render_pdf(data, max_pages) + return [_decode(np.frombuffer(data, dtype=np.uint8), "")] + + if isinstance(source, (str, os.PathLike)): + path = Path(source) + if not path.exists(): + raise FileNotFoundError(f"Image not found: {path}") + if not path.is_file(): + raise ImageLoadError(f"Not a file: {path}") + if path.suffix.lower() in PDF_SUFFIXES: + return _render_pdf(path.read_bytes(), max_pages) + return [_decode(np.fromfile(str(path), dtype=np.uint8), str(path))] + + raise TypeError( + f"Unsupported input type {type(source).__name__}; pass a path, bytes, a NumPy array or a PIL image." + ) + + +def load_image(source: ImageSource) -> np.ndarray: + """Loads the first page of ``source`` as a BGR ``uint8`` image. + + Example: + >>> img = load_image(np.zeros((10, 10), dtype=np.uint8)) + >>> img.shape + (10, 10, 3) + """ + return load_pages(source, max_pages=1)[0] + + +def _decode(buffer: np.ndarray, label: str) -> np.ndarray: + img = cv2.imdecode(buffer, cv2.IMREAD_COLOR) if buffer.size else None + if img is None: + raise ImageLoadError( + f"Could not read image: {label}. Check the file is a valid PNG, JPEG, BMP, TIFF or WebP image (or a PDF)." + ) + return img + + +def _normalize_array(arr: np.ndarray) -> np.ndarray: + if arr.size == 0: + raise ImageLoadError("Empty image array.") + if arr.dtype != np.uint8: + if np.issubdtype(arr.dtype, np.floating) and arr.max() <= 1.0: + arr = arr * 255.0 + arr = np.clip(arr, 0, 255).astype(np.uint8) + if arr.ndim == 2: + return cv2.cvtColor(arr, cv2.COLOR_GRAY2BGR) + if arr.ndim == 3 and arr.shape[2] == 1: + return cv2.cvtColor(arr[:, :, 0], cv2.COLOR_GRAY2BGR) + if arr.ndim == 3 and arr.shape[2] == 4: + return cv2.cvtColor(arr, cv2.COLOR_BGRA2BGR) + if arr.ndim == 3 and arr.shape[2] == 3: + return np.ascontiguousarray(arr) + raise ImageLoadError(f"Unsupported image array shape {arr.shape}.") + + +def _is_pil_image(obj: Any) -> bool: + try: + from PIL import Image + except ImportError: # pragma: no cover - PIL ships with easyocr + return False + return isinstance(obj, Image.Image) + + +def _render_pdf(data: bytes, max_pages: int) -> List[np.ndarray]: + try: + import fitz # PyMuPDF + except ImportError as exc: + raise ImportError('Reading PDFs needs PyMuPDF: pip install "docling-pk[pdf]"') from exc + try: + doc = fitz.open(stream=data, filetype="pdf") + except Exception as exc: + raise ImageLoadError(f"Could not open PDF: {exc}") from exc + pages = [] + zoom = PDF_RENDER_DPI / 72.0 + with doc: + for i, page in enumerate(doc): + if i >= max_pages: + break + pix = page.get_pixmap(matrix=fitz.Matrix(zoom, zoom), alpha=False) + arr = np.frombuffer(pix.samples, dtype=np.uint8).reshape(pix.height, pix.width, pix.n) + pages.append(cv2.cvtColor(arr, cv2.COLOR_RGB2BGR if pix.n == 3 else cv2.COLOR_GRAY2BGR)) + if not pages: + raise ImageLoadError("PDF has no pages.") + return pages diff --git a/src/docling_pk/vision/quality.py b/src/docling_pk/vision/quality.py new file mode 100644 index 0000000..d8a2414 --- /dev/null +++ b/src/docling_pk/vision/quality.py @@ -0,0 +1,98 @@ +""" +Image quality signals. + +These produce *warnings*, not rejections. The original pipeline rejected any +image whose Laplacian variance was below 35, a threshold calibrated on four +photos. Laplacian variance depends heavily on resolution and content, and on +real samples that gate rejected perfectly readable photos (a clean CNIC back +scored 15.8) while accepting worse ones. Measuring at a fixed working +resolution and reporting instead of rejecting lets the OCR result decide +whether the image was usable. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Dict, List + +import cv2 +import numpy as np + +#: Long side, in pixels, at which sharpness is measured. +SHARPNESS_REFERENCE_SIDE = 1000 + +SHARPNESS_WARN = 60.0 +DARK_WARN = 60.0 +BRIGHT_WARN = 225.0 +GLARE_WARN = 0.04 +MIN_LONG_SIDE = 640 + + +@dataclass +class QualityReport: + """Image quality measurements and the warnings they triggered.""" + + sharpness: float + brightness: float + contrast: float + glare_fraction: float + width: int + height: int + warnings: List[str] = field(default_factory=list) + + def to_dict(self) -> Dict[str, float]: + return { + "sharpness": round(self.sharpness, 1), + "brightness": round(self.brightness, 1), + "contrast": round(self.contrast, 1), + "glare_fraction": round(self.glare_fraction, 4), + "width": self.width, + "height": self.height, + } + + +def _to_gray(image: np.ndarray) -> np.ndarray: + return image if image.ndim == 2 else cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) + + +def sharpness(image: np.ndarray) -> float: + """Variance of the Laplacian at a fixed reference resolution.""" + gray = _to_gray(image) + h, w = gray.shape[:2] + scale = SHARPNESS_REFERENCE_SIDE / max(h, w) + if abs(scale - 1.0) > 0.05: + interp = cv2.INTER_AREA if scale < 1 else cv2.INTER_CUBIC + gray = cv2.resize(gray, None, fx=scale, fy=scale, interpolation=interp) + return float(cv2.Laplacian(gray, cv2.CV_64F).var()) + + +def assess(image: np.ndarray) -> QualityReport: + """Measures sharpness, exposure, contrast and glare of ``image``.""" + gray = _to_gray(image) + h, w = gray.shape[:2] + report = QualityReport( + sharpness=sharpness(gray), + brightness=float(gray.mean()), + contrast=float(gray.std()), + glare_fraction=float((gray >= 250).mean()), + width=int(w), + height=int(h), + ) + if report.sharpness < SHARPNESS_WARN: + report.warnings.append( + f"Image looks blurry (sharpness {report.sharpness:.0f}); results may be " + "incomplete. Retake the photo in focus if fields are missing." + ) + if report.brightness < DARK_WARN: + report.warnings.append("Image is very dark; use more light.") + elif report.brightness > BRIGHT_WARN: + report.warnings.append("Image is overexposed; reduce light or flash.") + # Saturated pixels on a page that is mostly white are just paper, not + # glare; only photos with a darker overall tone can show specular glare. + if report.glare_fraction > GLARE_WARN and float(np.median(gray)) < 200: + report.warnings.append( + f"Glare covers {report.glare_fraction:.0%} of the image; tilt the document or remove plastic sleeves." + ) + if max(h, w) < MIN_LONG_SIDE: + report.warnings.append(f"Low resolution ({w}x{h}); small text may be misread.") + return report diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/benchmark_report.json b/tests/benchmark_report.json new file mode 100644 index 0000000..75952c5 --- /dev/null +++ b/tests/benchmark_report.json @@ -0,0 +1,3999 @@ +{ + "labels": "labels.json", + "backend": "auto", + "overall": { + "n": 213, + "accuracy": 0.9624413145539906, + "coverage": 0.9624413145539906, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 203 + }, + "by_doc_type": { + "cnic": { + "n": 97, + "accuracy": 0.9175257731958762, + "coverage": 0.9175257731958762, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 89 + }, + "degree": { + "n": 18, + "accuracy": 1.0, + "coverage": 1.0, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 16 + }, + "intermediate": { + "n": 42, + "accuracy": 1.0, + "coverage": 1.0, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 42 + }, + "matric": { + "n": 56, + "accuracy": 1.0, + "coverage": 1.0, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 56 + } + }, + "by_field": { + "cnic.cnic_number": { + "n": 14, + "accuracy": 0.9285714285714286, + "coverage": 0.9285714285714286, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 13 + }, + "cnic.country_of_stay": { + "n": 12, + "accuracy": 0.9166666666666666, + "coverage": 0.9166666666666666, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 11 + }, + "cnic.date_of_birth": { + "n": 12, + "accuracy": 0.9166666666666666, + "coverage": 0.9166666666666666, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 11 + }, + "cnic.date_of_expiry": { + "n": 12, + "accuracy": 0.9166666666666666, + "coverage": 0.9166666666666666, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 11 + }, + "cnic.date_of_issue": { + "n": 12, + "accuracy": 0.9166666666666666, + "coverage": 0.9166666666666666, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 11 + }, + "cnic.father_name": { + "n": 12, + "accuracy": 0.9166666666666666, + "coverage": 0.9166666666666666, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 11 + }, + "cnic.gender": { + "n": 12, + "accuracy": 0.9166666666666666, + "coverage": 0.9166666666666666, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 11 + }, + "cnic.name": { + "n": 11, + "accuracy": 0.9090909090909091, + "coverage": 0.9090909090909091, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 10 + }, + "degree.cgpa": { + "n": 2, + "accuracy": 1.0, + "coverage": 1.0, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 2 + }, + "degree.date_of_issue": { + "n": 2, + "accuracy": 1.0, + "coverage": 1.0, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 2 + }, + "degree.degree": { + "n": 2, + "accuracy": 1.0, + "coverage": 1.0, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 2 + }, + "degree.father_name": { + "n": 2, + "accuracy": 1.0, + "coverage": 1.0, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 2 + }, + "degree.institution": { + "n": 2, + "accuracy": 1.0, + "coverage": 1.0, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 2 + }, + "degree.registration_number": { + "n": 2, + "accuracy": 1.0, + "coverage": 1.0, + "precision": 1.0, + "ok_precision": null, + "ok_count": 0 + }, + "degree.serial_number": { + "n": 2, + "accuracy": 1.0, + "coverage": 1.0, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 2 + }, + "degree.student_name": { + "n": 2, + "accuracy": 1.0, + "coverage": 1.0, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 2 + }, + "degree.year": { + "n": 2, + "accuracy": 1.0, + "coverage": 1.0, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 2 + }, + "intermediate.board": { + "n": 3, + "accuracy": 1.0, + "coverage": 1.0, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 3 + }, + "intermediate.certificate_number": { + "n": 3, + "accuracy": 1.0, + "coverage": 1.0, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 3 + }, + "intermediate.date_of_birth": { + "n": 3, + "accuracy": 1.0, + "coverage": 1.0, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 3 + }, + "intermediate.father_name": { + "n": 3, + "accuracy": 1.0, + "coverage": 1.0, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 3 + }, + "intermediate.grade": { + "n": 3, + "accuracy": 1.0, + "coverage": 1.0, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 3 + }, + "intermediate.group": { + "n": 3, + "accuracy": 1.0, + "coverage": 1.0, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 3 + }, + "intermediate.institute": { + "n": 3, + "accuracy": 1.0, + "coverage": 1.0, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 3 + }, + "intermediate.max_marks": { + "n": 3, + "accuracy": 1.0, + "coverage": 1.0, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 3 + }, + "intermediate.registration_number": { + "n": 3, + "accuracy": 1.0, + "coverage": 1.0, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 3 + }, + "intermediate.roll_number": { + "n": 3, + "accuracy": 1.0, + "coverage": 1.0, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 3 + }, + "intermediate.serial_number": { + "n": 3, + "accuracy": 1.0, + "coverage": 1.0, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 3 + }, + "intermediate.student_name": { + "n": 3, + "accuracy": 1.0, + "coverage": 1.0, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 3 + }, + "intermediate.total_marks": { + "n": 3, + "accuracy": 1.0, + "coverage": 1.0, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 3 + }, + "intermediate.year": { + "n": 3, + "accuracy": 1.0, + "coverage": 1.0, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 3 + }, + "matric.board": { + "n": 4, + "accuracy": 1.0, + "coverage": 1.0, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 4 + }, + "matric.certificate_number": { + "n": 4, + "accuracy": 1.0, + "coverage": 1.0, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 4 + }, + "matric.date_of_birth": { + "n": 4, + "accuracy": 1.0, + "coverage": 1.0, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 4 + }, + "matric.father_name": { + "n": 4, + "accuracy": 1.0, + "coverage": 1.0, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 4 + }, + "matric.grade": { + "n": 4, + "accuracy": 1.0, + "coverage": 1.0, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 4 + }, + "matric.group": { + "n": 4, + "accuracy": 1.0, + "coverage": 1.0, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 4 + }, + "matric.institute": { + "n": 4, + "accuracy": 1.0, + "coverage": 1.0, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 4 + }, + "matric.max_marks": { + "n": 4, + "accuracy": 1.0, + "coverage": 1.0, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 4 + }, + "matric.registration_number": { + "n": 4, + "accuracy": 1.0, + "coverage": 1.0, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 4 + }, + "matric.roll_number": { + "n": 4, + "accuracy": 1.0, + "coverage": 1.0, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 4 + }, + "matric.serial_number": { + "n": 4, + "accuracy": 1.0, + "coverage": 1.0, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 4 + }, + "matric.student_name": { + "n": 4, + "accuracy": 1.0, + "coverage": 1.0, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 4 + }, + "matric.total_marks": { + "n": 4, + "accuracy": 1.0, + "coverage": 1.0, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 4 + }, + "matric.year": { + "n": 4, + "accuracy": 1.0, + "coverage": 1.0, + "precision": 1.0, + "ok_precision": 1.0, + "ok_count": 4 + } + }, + "subjects": { + "rows_correct": 53, + "rows_expected": 53, + "rows_returned": 53 + }, + "seconds_per_document": { + "mean": 2.662468469565362, + "max": 6.296093699998892 + }, + "cases": [ + { + "id": "00_cnic_front_clean", + "correct": 8, + "total": 8, + "seconds": 6.3, + "error": null + }, + { + "id": "01_cnic_front_clean", + "correct": 8, + "total": 8, + "seconds": 1.33, + "error": null + }, + { + "id": "02_cnic_front_rotated90", + "correct": 8, + "total": 8, + "seconds": 2.65, + "error": null + }, + { + "id": "03_cnic_front_rotated270", + "correct": 8, + "total": 8, + "seconds": 2.72, + "error": null + }, + { + "id": "04_cnic_front_upside_down", + "correct": 8, + "total": 8, + "seconds": 2.64, + "error": null + }, + { + "id": "05_cnic_front_skewed", + "correct": 8, + "total": 8, + "seconds": 2.97, + "error": null + }, + { + "id": "06_cnic_front_photo", + "correct": 8, + "total": 8, + "seconds": 1.57, + "error": null + }, + { + "id": "07_cnic_front_photo", + "correct": 8, + "total": 8, + "seconds": 1.22, + "error": null + }, + { + "id": "08_cnic_front_low_res", + "correct": 8, + "total": 8, + "seconds": 1.55, + "error": null + }, + { + "id": "09_cnic_front_blurry", + "correct": 8, + "total": 8, + "seconds": 1.32, + "error": null + }, + { + "id": "10_cnic_front_very_blurry", + "correct": 0, + "total": 8, + "seconds": 1.7, + "error": null + }, + { + "id": "11_cnic_front_occluded_name", + "correct": 7, + "total": 7, + "seconds": 1.43, + "error": null + }, + { + "id": "12_cnic_back_clean", + "correct": 1, + "total": 1, + "seconds": 4.41, + "error": null + }, + { + "id": "13_cnic_back_photo", + "correct": 1, + "total": 1, + "seconds": 3.32, + "error": null + }, + { + "id": "14_matric_clean", + "correct": 14, + "total": 14, + "seconds": 2.85, + "error": null + }, + { + "id": "15_matric_rotated90", + "correct": 14, + "total": 14, + "seconds": 5.1, + "error": null + }, + { + "id": "16_matric_photo", + "correct": 14, + "total": 14, + "seconds": 2.58, + "error": null + }, + { + "id": "17_matric_low_res", + "correct": 14, + "total": 14, + "seconds": 2.38, + "error": null + }, + { + "id": "18_intermediate_clean", + "correct": 14, + "total": 14, + "seconds": 2.61, + "error": null + }, + { + "id": "19_intermediate_skewed", + "correct": 14, + "total": 14, + "seconds": 4.72, + "error": null + }, + { + "id": "20_intermediate_photo", + "correct": 14, + "total": 14, + "seconds": 2.42, + "error": null + }, + { + "id": "21_degree_clean", + "correct": 9, + "total": 9, + "seconds": 1.75, + "error": null + }, + { + "id": "22_degree_photo", + "correct": 9, + "total": 9, + "seconds": 1.69, + "error": null + } + ], + "mean_cer_text_fields": 0.038461538461538464, + "rows": [ + { + "case": "00_cnic_front_clean", + "doc_type": "cnic", + "field": "name", + "expected": "Mahnoor Nawaz", + "actual": "Mahnoor Nawaz", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": 0.0, + "tags": [ + "cnic_front", + "clean" + ], + "error": null + }, + { + "case": "00_cnic_front_clean", + "doc_type": "cnic", + "field": "father_name", + "expected": "Ahmed Butt", + "actual": "Ahmed Butt", + "status": "ok", + "correct": true, + "confidence": 0.9983, + "cer": 0.0, + "tags": [ + "cnic_front", + "clean" + ], + "error": null + }, + { + "case": "00_cnic_front_clean", + "doc_type": "cnic", + "field": "gender", + "expected": "F", + "actual": "F", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "clean" + ], + "error": null + }, + { + "case": "00_cnic_front_clean", + "doc_type": "cnic", + "field": "country_of_stay", + "expected": "Pakistan", + "actual": "Pakistan", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "clean" + ], + "error": null + }, + { + "case": "00_cnic_front_clean", + "doc_type": "cnic", + "field": "cnic_number", + "expected": "59869-8797309-2", + "actual": "59869-8797309-2", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "clean" + ], + "error": null + }, + { + "case": "00_cnic_front_clean", + "doc_type": "cnic", + "field": "date_of_birth", + "expected": "08-12-1959", + "actual": "08-12-1959", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "clean" + ], + "error": null + }, + { + "case": "00_cnic_front_clean", + "doc_type": "cnic", + "field": "date_of_issue", + "expected": "11-06-2018", + "actual": "11-06-2018", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "clean" + ], + "error": null + }, + { + "case": "00_cnic_front_clean", + "doc_type": "cnic", + "field": "date_of_expiry", + "expected": "11-06-2023", + "actual": "11-06-2023", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "clean" + ], + "error": null + }, + { + "case": "01_cnic_front_clean", + "doc_type": "cnic", + "field": "name", + "expected": "Iqra Siddiqui", + "actual": "Iqra Siddiqui", + "status": "ok", + "correct": true, + "confidence": 0.9783, + "cer": 0.0, + "tags": [ + "cnic_front", + "clean" + ], + "error": null + }, + { + "case": "01_cnic_front_clean", + "doc_type": "cnic", + "field": "father_name", + "expected": "Usman Javed", + "actual": "Usman Javed", + "status": "ok", + "correct": true, + "confidence": 0.9997, + "cer": 0.0, + "tags": [ + "cnic_front", + "clean" + ], + "error": null + }, + { + "case": "01_cnic_front_clean", + "doc_type": "cnic", + "field": "gender", + "expected": "F", + "actual": "F", + "status": "ok", + "correct": true, + "confidence": 0.9999, + "cer": null, + "tags": [ + "cnic_front", + "clean" + ], + "error": null + }, + { + "case": "01_cnic_front_clean", + "doc_type": "cnic", + "field": "country_of_stay", + "expected": "Pakistan", + "actual": "Pakistan", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "clean" + ], + "error": null + }, + { + "case": "01_cnic_front_clean", + "doc_type": "cnic", + "field": "cnic_number", + "expected": "35568-1518844-8", + "actual": "35568-1518844-8", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "clean" + ], + "error": null + }, + { + "case": "01_cnic_front_clean", + "doc_type": "cnic", + "field": "date_of_birth", + "expected": "22-08-1962", + "actual": "22-08-1962", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "clean" + ], + "error": null + }, + { + "case": "01_cnic_front_clean", + "doc_type": "cnic", + "field": "date_of_issue", + "expected": "13-12-2024", + "actual": "13-12-2024", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "clean" + ], + "error": null + }, + { + "case": "01_cnic_front_clean", + "doc_type": "cnic", + "field": "date_of_expiry", + "expected": "13-12-2031", + "actual": "13-12-2031", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "clean" + ], + "error": null + }, + { + "case": "02_cnic_front_rotated90", + "doc_type": "cnic", + "field": "name", + "expected": "Hamza Abbasi", + "actual": "Hamza Abbasi", + "status": "ok", + "correct": true, + "confidence": 0.9999, + "cer": 0.0, + "tags": [ + "cnic_front", + "rotated90" + ], + "error": null + }, + { + "case": "02_cnic_front_rotated90", + "doc_type": "cnic", + "field": "father_name", + "expected": "Zain Rehman", + "actual": "Zain Rehman", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": 0.0, + "tags": [ + "cnic_front", + "rotated90" + ], + "error": null + }, + { + "case": "02_cnic_front_rotated90", + "doc_type": "cnic", + "field": "gender", + "expected": "M", + "actual": "M", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "rotated90" + ], + "error": null + }, + { + "case": "02_cnic_front_rotated90", + "doc_type": "cnic", + "field": "country_of_stay", + "expected": "Pakistan", + "actual": "Pakistan", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "rotated90" + ], + "error": null + }, + { + "case": "02_cnic_front_rotated90", + "doc_type": "cnic", + "field": "cnic_number", + "expected": "16987-1689768-5", + "actual": "16987-1689768-5", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "rotated90" + ], + "error": null + }, + { + "case": "02_cnic_front_rotated90", + "doc_type": "cnic", + "field": "date_of_birth", + "expected": "05-09-1987", + "actual": "05-09-1987", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "rotated90" + ], + "error": null + }, + { + "case": "02_cnic_front_rotated90", + "doc_type": "cnic", + "field": "date_of_issue", + "expected": "25-08-2024", + "actual": "25-08-2024", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "rotated90" + ], + "error": null + }, + { + "case": "02_cnic_front_rotated90", + "doc_type": "cnic", + "field": "date_of_expiry", + "expected": "25-08-2031", + "actual": "25-08-2031", + "status": "ok", + "correct": true, + "confidence": 0.9876, + "cer": null, + "tags": [ + "cnic_front", + "rotated90" + ], + "error": null + }, + { + "case": "03_cnic_front_rotated270", + "doc_type": "cnic", + "field": "name", + "expected": "Muhammad Tariq", + "actual": "Muhammad Tariq", + "status": "ok", + "correct": true, + "confidence": 0.9997, + "cer": 0.0, + "tags": [ + "cnic_front", + "rotated270" + ], + "error": null + }, + { + "case": "03_cnic_front_rotated270", + "doc_type": "cnic", + "field": "father_name", + "expected": "Usman Raza", + "actual": "Usman Raza", + "status": "ok", + "correct": true, + "confidence": 0.9981, + "cer": 0.0, + "tags": [ + "cnic_front", + "rotated270" + ], + "error": null + }, + { + "case": "03_cnic_front_rotated270", + "doc_type": "cnic", + "field": "gender", + "expected": "M", + "actual": "M", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "rotated270" + ], + "error": null + }, + { + "case": "03_cnic_front_rotated270", + "doc_type": "cnic", + "field": "country_of_stay", + "expected": "Pakistan", + "actual": "Pakistan", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "rotated270" + ], + "error": null + }, + { + "case": "03_cnic_front_rotated270", + "doc_type": "cnic", + "field": "cnic_number", + "expected": "70819-6727317-7", + "actual": "70819-6727317-7", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "rotated270" + ], + "error": null + }, + { + "case": "03_cnic_front_rotated270", + "doc_type": "cnic", + "field": "date_of_birth", + "expected": "14-12-1998", + "actual": "14-12-1998", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "rotated270" + ], + "error": null + }, + { + "case": "03_cnic_front_rotated270", + "doc_type": "cnic", + "field": "date_of_issue", + "expected": "25-10-2018", + "actual": "25-10-2018", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "rotated270" + ], + "error": null + }, + { + "case": "03_cnic_front_rotated270", + "doc_type": "cnic", + "field": "date_of_expiry", + "expected": "25-10-2028", + "actual": "25-10-2028", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "rotated270" + ], + "error": null + }, + { + "case": "04_cnic_front_upside_down", + "doc_type": "cnic", + "field": "name", + "expected": "Bilal Saleem", + "actual": "Bilal Saleem", + "status": "ok", + "correct": true, + "confidence": 0.9999, + "cer": 0.0, + "tags": [ + "cnic_front", + "upside_down" + ], + "error": null + }, + { + "case": "04_cnic_front_upside_down", + "doc_type": "cnic", + "field": "father_name", + "expected": "Fahad Aslam", + "actual": "Fahad Aslam", + "status": "ok", + "correct": true, + "confidence": 0.9998, + "cer": 0.0, + "tags": [ + "cnic_front", + "upside_down" + ], + "error": null + }, + { + "case": "04_cnic_front_upside_down", + "doc_type": "cnic", + "field": "gender", + "expected": "M", + "actual": "M", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "upside_down" + ], + "error": null + }, + { + "case": "04_cnic_front_upside_down", + "doc_type": "cnic", + "field": "country_of_stay", + "expected": "Pakistan", + "actual": "Pakistan", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "upside_down" + ], + "error": null + }, + { + "case": "04_cnic_front_upside_down", + "doc_type": "cnic", + "field": "cnic_number", + "expected": "76634-4002978-7", + "actual": "76634-4002978-7", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "upside_down" + ], + "error": null + }, + { + "case": "04_cnic_front_upside_down", + "doc_type": "cnic", + "field": "date_of_birth", + "expected": "17-04-1982", + "actual": "17-04-1982", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "upside_down" + ], + "error": null + }, + { + "case": "04_cnic_front_upside_down", + "doc_type": "cnic", + "field": "date_of_issue", + "expected": "25-06-2016", + "actual": "25-06-2016", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "upside_down" + ], + "error": null + }, + { + "case": "04_cnic_front_upside_down", + "doc_type": "cnic", + "field": "date_of_expiry", + "expected": "25-06-2023", + "actual": "25-06-2023", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "upside_down" + ], + "error": null + }, + { + "case": "05_cnic_front_skewed", + "doc_type": "cnic", + "field": "name", + "expected": "Fahad Aslam", + "actual": "Fahad Aslam", + "status": "ok", + "correct": true, + "confidence": 0.9735, + "cer": 0.0, + "tags": [ + "cnic_front", + "skewed" + ], + "error": null + }, + { + "case": "05_cnic_front_skewed", + "doc_type": "cnic", + "field": "father_name", + "expected": "Hassan Saleem", + "actual": "Hassan Saleem", + "status": "ok", + "correct": true, + "confidence": 0.9999, + "cer": 0.0, + "tags": [ + "cnic_front", + "skewed" + ], + "error": null + }, + { + "case": "05_cnic_front_skewed", + "doc_type": "cnic", + "field": "gender", + "expected": "M", + "actual": "M", + "status": "ok", + "correct": true, + "confidence": 0.9999, + "cer": null, + "tags": [ + "cnic_front", + "skewed" + ], + "error": null + }, + { + "case": "05_cnic_front_skewed", + "doc_type": "cnic", + "field": "country_of_stay", + "expected": "Pakistan", + "actual": "Pakistan", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "skewed" + ], + "error": null + }, + { + "case": "05_cnic_front_skewed", + "doc_type": "cnic", + "field": "cnic_number", + "expected": "60457-1784726-7", + "actual": "60457-1784726-7", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "skewed" + ], + "error": null + }, + { + "case": "05_cnic_front_skewed", + "doc_type": "cnic", + "field": "date_of_birth", + "expected": "08-10-1955", + "actual": "08-10-1955", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "skewed" + ], + "error": null + }, + { + "case": "05_cnic_front_skewed", + "doc_type": "cnic", + "field": "date_of_issue", + "expected": "22-12-2017", + "actual": "22-12-2017", + "status": "ok", + "correct": true, + "confidence": 0.9999, + "cer": null, + "tags": [ + "cnic_front", + "skewed" + ], + "error": null + }, + { + "case": "05_cnic_front_skewed", + "doc_type": "cnic", + "field": "date_of_expiry", + "expected": "22-12-2024", + "actual": "22-12-2024", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "skewed" + ], + "error": null + }, + { + "case": "06_cnic_front_photo", + "doc_type": "cnic", + "field": "name", + "expected": "Hassan Raza", + "actual": "Hassan Raza", + "status": "ok", + "correct": true, + "confidence": 0.9999, + "cer": 0.0, + "tags": [ + "cnic_front", + "photo" + ], + "error": null + }, + { + "case": "06_cnic_front_photo", + "doc_type": "cnic", + "field": "father_name", + "expected": "Zain Khan", + "actual": "Zain Khan", + "status": "ok", + "correct": true, + "confidence": 0.9888, + "cer": 0.0, + "tags": [ + "cnic_front", + "photo" + ], + "error": null + }, + { + "case": "06_cnic_front_photo", + "doc_type": "cnic", + "field": "gender", + "expected": "M", + "actual": "M", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "photo" + ], + "error": null + }, + { + "case": "06_cnic_front_photo", + "doc_type": "cnic", + "field": "country_of_stay", + "expected": "Pakistan", + "actual": "Pakistan", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "photo" + ], + "error": null + }, + { + "case": "06_cnic_front_photo", + "doc_type": "cnic", + "field": "cnic_number", + "expected": "10798-1933190-9", + "actual": "10798-1933190-9", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "photo" + ], + "error": null + }, + { + "case": "06_cnic_front_photo", + "doc_type": "cnic", + "field": "date_of_birth", + "expected": "03-02-1974", + "actual": "03-02-1974", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "photo" + ], + "error": null + }, + { + "case": "06_cnic_front_photo", + "doc_type": "cnic", + "field": "date_of_issue", + "expected": "05-01-2020", + "actual": "05-01-2020", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "photo" + ], + "error": null + }, + { + "case": "06_cnic_front_photo", + "doc_type": "cnic", + "field": "date_of_expiry", + "expected": "05-01-2025", + "actual": "05-01-2025", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "photo" + ], + "error": null + }, + { + "case": "07_cnic_front_photo", + "doc_type": "cnic", + "field": "name", + "expected": "Zain Raza", + "actual": "Zain Raza", + "status": "ok", + "correct": true, + "confidence": 0.9999, + "cer": 0.0, + "tags": [ + "cnic_front", + "photo" + ], + "error": null + }, + { + "case": "07_cnic_front_photo", + "doc_type": "cnic", + "field": "father_name", + "expected": "Fahad Butt", + "actual": "Fahad Butt", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": 0.0, + "tags": [ + "cnic_front", + "photo" + ], + "error": null + }, + { + "case": "07_cnic_front_photo", + "doc_type": "cnic", + "field": "gender", + "expected": "M", + "actual": "M", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "photo" + ], + "error": null + }, + { + "case": "07_cnic_front_photo", + "doc_type": "cnic", + "field": "country_of_stay", + "expected": "Pakistan", + "actual": "Pakistan", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "photo" + ], + "error": null + }, + { + "case": "07_cnic_front_photo", + "doc_type": "cnic", + "field": "cnic_number", + "expected": "49298-4162294-9", + "actual": "49298-4162294-9", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "photo" + ], + "error": null + }, + { + "case": "07_cnic_front_photo", + "doc_type": "cnic", + "field": "date_of_birth", + "expected": "19-12-1986", + "actual": "19-12-1986", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "photo" + ], + "error": null + }, + { + "case": "07_cnic_front_photo", + "doc_type": "cnic", + "field": "date_of_issue", + "expected": "03-10-2020", + "actual": "03-10-2020", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "photo" + ], + "error": null + }, + { + "case": "07_cnic_front_photo", + "doc_type": "cnic", + "field": "date_of_expiry", + "expected": "03-10-2025", + "actual": "03-10-2025", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "photo" + ], + "error": null + }, + { + "case": "08_cnic_front_low_res", + "doc_type": "cnic", + "field": "name", + "expected": "Hassan Hussain", + "actual": "Hassan Hussain", + "status": "ok", + "correct": true, + "confidence": 0.9998, + "cer": 0.0, + "tags": [ + "cnic_front", + "low_res" + ], + "error": null + }, + { + "case": "08_cnic_front_low_res", + "doc_type": "cnic", + "field": "father_name", + "expected": "Muhammad Abbasi", + "actual": "Muhammad Abbasi", + "status": "ok", + "correct": true, + "confidence": 0.9764, + "cer": 0.0, + "tags": [ + "cnic_front", + "low_res" + ], + "error": null + }, + { + "case": "08_cnic_front_low_res", + "doc_type": "cnic", + "field": "gender", + "expected": "M", + "actual": "M", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "low_res" + ], + "error": null + }, + { + "case": "08_cnic_front_low_res", + "doc_type": "cnic", + "field": "country_of_stay", + "expected": "Pakistan", + "actual": "Pakistan", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "low_res" + ], + "error": null + }, + { + "case": "08_cnic_front_low_res", + "doc_type": "cnic", + "field": "cnic_number", + "expected": "65008-7547517-7", + "actual": "65008-7547517-7", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "low_res" + ], + "error": null + }, + { + "case": "08_cnic_front_low_res", + "doc_type": "cnic", + "field": "date_of_birth", + "expected": "13-04-2001", + "actual": "13-04-2001", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "low_res" + ], + "error": null + }, + { + "case": "08_cnic_front_low_res", + "doc_type": "cnic", + "field": "date_of_issue", + "expected": "08-12-2025", + "actual": "08-12-2025", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "low_res" + ], + "error": null + }, + { + "case": "08_cnic_front_low_res", + "doc_type": "cnic", + "field": "date_of_expiry", + "expected": "08-12-2030", + "actual": "08-12-2030", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "low_res" + ], + "error": null + }, + { + "case": "09_cnic_front_blurry", + "doc_type": "cnic", + "field": "name", + "expected": "Usman Rehman", + "actual": "Usman Rehman", + "status": "ok", + "correct": true, + "confidence": 0.9999, + "cer": 0.0, + "tags": [ + "cnic_front", + "blurry" + ], + "error": null + }, + { + "case": "09_cnic_front_blurry", + "doc_type": "cnic", + "field": "father_name", + "expected": "Imran Siddiqui", + "actual": "Imran Siddiqui", + "status": "ok", + "correct": true, + "confidence": 0.9996, + "cer": 0.0, + "tags": [ + "cnic_front", + "blurry" + ], + "error": null + }, + { + "case": "09_cnic_front_blurry", + "doc_type": "cnic", + "field": "gender", + "expected": "M", + "actual": "M", + "status": "ok", + "correct": true, + "confidence": 0.9999, + "cer": null, + "tags": [ + "cnic_front", + "blurry" + ], + "error": null + }, + { + "case": "09_cnic_front_blurry", + "doc_type": "cnic", + "field": "country_of_stay", + "expected": "Pakistan", + "actual": "Pakistan", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "blurry" + ], + "error": null + }, + { + "case": "09_cnic_front_blurry", + "doc_type": "cnic", + "field": "cnic_number", + "expected": "49318-6032080-7", + "actual": "49318-6032080-7", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "blurry" + ], + "error": null + }, + { + "case": "09_cnic_front_blurry", + "doc_type": "cnic", + "field": "date_of_birth", + "expected": "30-11-1966", + "actual": "30-11-1966", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "blurry" + ], + "error": null + }, + { + "case": "09_cnic_front_blurry", + "doc_type": "cnic", + "field": "date_of_issue", + "expected": "07-11-2022", + "actual": "07-11-2022", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "blurry" + ], + "error": null + }, + { + "case": "09_cnic_front_blurry", + "doc_type": "cnic", + "field": "date_of_expiry", + "expected": "07-11-2032", + "actual": "07-11-2032", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "blurry" + ], + "error": null + }, + { + "case": "10_cnic_front_very_blurry", + "doc_type": "cnic", + "field": "name", + "expected": "Ali Nawaz", + "actual": null, + "status": "not_found", + "correct": false, + "confidence": 0.0, + "cer": 1.0, + "tags": [ + "cnic_front", + "very_blurry", + "unreadable" + ], + "error": null + }, + { + "case": "10_cnic_front_very_blurry", + "doc_type": "cnic", + "field": "father_name", + "expected": "Usman Nawaz", + "actual": null, + "status": "not_found", + "correct": false, + "confidence": 0.0, + "cer": 1.0, + "tags": [ + "cnic_front", + "very_blurry", + "unreadable" + ], + "error": null + }, + { + "case": "10_cnic_front_very_blurry", + "doc_type": "cnic", + "field": "gender", + "expected": "M", + "actual": null, + "status": "not_found", + "correct": false, + "confidence": 0.0, + "cer": null, + "tags": [ + "cnic_front", + "very_blurry", + "unreadable" + ], + "error": null + }, + { + "case": "10_cnic_front_very_blurry", + "doc_type": "cnic", + "field": "country_of_stay", + "expected": "Pakistan", + "actual": null, + "status": "not_found", + "correct": false, + "confidence": 0.0, + "cer": null, + "tags": [ + "cnic_front", + "very_blurry", + "unreadable" + ], + "error": null + }, + { + "case": "10_cnic_front_very_blurry", + "doc_type": "cnic", + "field": "cnic_number", + "expected": "22264-6861642-5", + "actual": null, + "status": "not_found", + "correct": false, + "confidence": 0.0, + "cer": null, + "tags": [ + "cnic_front", + "very_blurry", + "unreadable" + ], + "error": null + }, + { + "case": "10_cnic_front_very_blurry", + "doc_type": "cnic", + "field": "date_of_birth", + "expected": "03-09-1965", + "actual": null, + "status": "not_found", + "correct": false, + "confidence": 0.0, + "cer": null, + "tags": [ + "cnic_front", + "very_blurry", + "unreadable" + ], + "error": null + }, + { + "case": "10_cnic_front_very_blurry", + "doc_type": "cnic", + "field": "date_of_issue", + "expected": "28-11-2017", + "actual": null, + "status": "not_found", + "correct": false, + "confidence": 0.0, + "cer": null, + "tags": [ + "cnic_front", + "very_blurry", + "unreadable" + ], + "error": null + }, + { + "case": "10_cnic_front_very_blurry", + "doc_type": "cnic", + "field": "date_of_expiry", + "expected": "28-11-2024", + "actual": null, + "status": "not_found", + "correct": false, + "confidence": 0.0, + "cer": null, + "tags": [ + "cnic_front", + "very_blurry", + "unreadable" + ], + "error": null + }, + { + "case": "11_cnic_front_occluded_name", + "doc_type": "cnic", + "field": "father_name", + "expected": "Zain Shah", + "actual": "Zain Shah", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": 0.0, + "tags": [ + "cnic_front", + "occluded_name" + ], + "error": null + }, + { + "case": "11_cnic_front_occluded_name", + "doc_type": "cnic", + "field": "gender", + "expected": "M", + "actual": "M", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "occluded_name" + ], + "error": null + }, + { + "case": "11_cnic_front_occluded_name", + "doc_type": "cnic", + "field": "country_of_stay", + "expected": "Pakistan", + "actual": "Pakistan", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "occluded_name" + ], + "error": null + }, + { + "case": "11_cnic_front_occluded_name", + "doc_type": "cnic", + "field": "cnic_number", + "expected": "38771-8841330-1", + "actual": "38771-8841330-1", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "occluded_name" + ], + "error": null + }, + { + "case": "11_cnic_front_occluded_name", + "doc_type": "cnic", + "field": "date_of_birth", + "expected": "28-09-1990", + "actual": "28-09-1990", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "occluded_name" + ], + "error": null + }, + { + "case": "11_cnic_front_occluded_name", + "doc_type": "cnic", + "field": "date_of_issue", + "expected": "30-05-2019", + "actual": "30-05-2019", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "occluded_name" + ], + "error": null + }, + { + "case": "11_cnic_front_occluded_name", + "doc_type": "cnic", + "field": "date_of_expiry", + "expected": "30-05-2026", + "actual": "30-05-2026", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_front", + "occluded_name" + ], + "error": null + }, + { + "case": "12_cnic_back_clean", + "doc_type": "cnic", + "field": "cnic_number", + "expected": "72052-5898319-5", + "actual": "72052-5898319-5", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_back", + "clean" + ], + "error": null + }, + { + "case": "13_cnic_back_photo", + "doc_type": "cnic", + "field": "cnic_number", + "expected": "54948-2141621-1", + "actual": "54948-2141621-1", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "cnic_back", + "photo" + ], + "error": null + }, + { + "case": "14_matric_clean", + "doc_type": "matric", + "field": "board", + "expected": "FBISE", + "actual": "FBISE", + "status": "ok", + "correct": true, + "confidence": 0.9, + "cer": null, + "tags": [ + "matric", + "clean" + ], + "error": null + }, + { + "case": "14_matric_clean", + "doc_type": "matric", + "field": "serial_number", + "expected": "2618161", + "actual": "2618161", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "matric", + "clean" + ], + "error": null + }, + { + "case": "14_matric_clean", + "doc_type": "matric", + "field": "roll_number", + "expected": "4046427", + "actual": "4046427", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "matric", + "clean" + ], + "error": null + }, + { + "case": "14_matric_clean", + "doc_type": "matric", + "field": "registration_number", + "expected": "5393655803", + "actual": "5393655803", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "matric", + "clean" + ], + "error": null + }, + { + "case": "14_matric_clean", + "doc_type": "matric", + "field": "certificate_number", + "expected": "232092060/198429", + "actual": "232092060/198429", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "matric", + "clean" + ], + "error": null + }, + { + "case": "14_matric_clean", + "doc_type": "matric", + "field": "group", + "expected": "GENERAL", + "actual": "GENERAL", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "matric", + "clean" + ], + "error": null + }, + { + "case": "14_matric_clean", + "doc_type": "matric", + "field": "year", + "expected": "2015", + "actual": "2015", + "status": "ok", + "correct": true, + "confidence": 0.9, + "cer": null, + "tags": [ + "matric", + "clean" + ], + "error": null + }, + { + "case": "14_matric_clean", + "doc_type": "matric", + "field": "grade", + "expected": "B", + "actual": "B", + "status": "ok", + "correct": true, + "confidence": 0.9895, + "cer": null, + "tags": [ + "matric", + "clean" + ], + "error": null + }, + { + "case": "14_matric_clean", + "doc_type": "matric", + "field": "student_name", + "expected": "RABIA HUSSAIN", + "actual": "RABIA HUSSAIN", + "status": "ok", + "correct": true, + "confidence": 0.9627, + "cer": 0.0, + "tags": [ + "matric", + "clean" + ], + "error": null + }, + { + "case": "14_matric_clean", + "doc_type": "matric", + "field": "father_name", + "expected": "BILAL SIDDIQUI", + "actual": "BILAL SIDDIQUI", + "status": "ok", + "correct": true, + "confidence": 0.9949, + "cer": 0.0, + "tags": [ + "matric", + "clean" + ], + "error": null + }, + { + "case": "14_matric_clean", + "doc_type": "matric", + "field": "date_of_birth", + "expected": "28-04-1997", + "actual": "28-04-1997", + "status": "ok", + "correct": true, + "confidence": 0.986, + "cer": null, + "tags": [ + "matric", + "clean" + ], + "error": null + }, + { + "case": "14_matric_clean", + "doc_type": "matric", + "field": "institute", + "expected": "FG GIRLS COLLEGE, CANTT, PESHAWAR", + "actual": "FG GIRLS COLLEGE, CANTT, PESHAWAR", + "status": "ok", + "correct": true, + "confidence": 0.9965, + "cer": 0.0, + "tags": [ + "matric", + "clean" + ], + "error": null + }, + { + "case": "14_matric_clean", + "doc_type": "matric", + "field": "max_marks", + "expected": "1100", + "actual": "1100", + "status": "ok", + "correct": true, + "confidence": 0.95, + "cer": null, + "tags": [ + "matric", + "clean" + ], + "error": null + }, + { + "case": "14_matric_clean", + "doc_type": "matric", + "field": "total_marks", + "expected": "651", + "actual": "651", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "matric", + "clean" + ], + "error": null + }, + { + "case": "15_matric_rotated90", + "doc_type": "matric", + "field": "board", + "expected": "FBISE", + "actual": "FBISE", + "status": "ok", + "correct": true, + "confidence": 0.9, + "cer": null, + "tags": [ + "matric", + "rotated90" + ], + "error": null + }, + { + "case": "15_matric_rotated90", + "doc_type": "matric", + "field": "serial_number", + "expected": "5220771", + "actual": "5220771", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "matric", + "rotated90" + ], + "error": null + }, + { + "case": "15_matric_rotated90", + "doc_type": "matric", + "field": "roll_number", + "expected": "6627130", + "actual": "6627130", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "matric", + "rotated90" + ], + "error": null + }, + { + "case": "15_matric_rotated90", + "doc_type": "matric", + "field": "registration_number", + "expected": "8803163805", + "actual": "8803163805", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "matric", + "rotated90" + ], + "error": null + }, + { + "case": "15_matric_rotated90", + "doc_type": "matric", + "field": "certificate_number", + "expected": "161880504/871109", + "actual": "161880504/871109", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "matric", + "rotated90" + ], + "error": null + }, + { + "case": "15_matric_rotated90", + "doc_type": "matric", + "field": "group", + "expected": "SCIENCE", + "actual": "SCIENCE", + "status": "ok", + "correct": true, + "confidence": 0.9999, + "cer": null, + "tags": [ + "matric", + "rotated90" + ], + "error": null + }, + { + "case": "15_matric_rotated90", + "doc_type": "matric", + "field": "year", + "expected": "2020", + "actual": "2020", + "status": "ok", + "correct": true, + "confidence": 0.9, + "cer": null, + "tags": [ + "matric", + "rotated90" + ], + "error": null + }, + { + "case": "15_matric_rotated90", + "doc_type": "matric", + "field": "grade", + "expected": "C", + "actual": "C", + "status": "ok", + "correct": true, + "confidence": 0.9911, + "cer": null, + "tags": [ + "matric", + "rotated90" + ], + "error": null + }, + { + "case": "15_matric_rotated90", + "doc_type": "matric", + "field": "student_name", + "expected": "IMRAN AHMED", + "actual": "IMRAN AHMED", + "status": "ok", + "correct": true, + "confidence": 0.9549, + "cer": 0.0, + "tags": [ + "matric", + "rotated90" + ], + "error": null + }, + { + "case": "15_matric_rotated90", + "doc_type": "matric", + "field": "father_name", + "expected": "HASSAN SIDDIQUI", + "actual": "HASSAN SIDDIQUI", + "status": "ok", + "correct": true, + "confidence": 0.9923, + "cer": 0.0, + "tags": [ + "matric", + "rotated90" + ], + "error": null + }, + { + "case": "15_matric_rotated90", + "doc_type": "matric", + "field": "date_of_birth", + "expected": "23-10-2004", + "actual": "23-10-2004", + "status": "ok", + "correct": true, + "confidence": 0.9811, + "cer": null, + "tags": [ + "matric", + "rotated90" + ], + "error": null + }, + { + "case": "15_matric_rotated90", + "doc_type": "matric", + "field": "institute", + "expected": "GOVT BOYS COLLEGE, MALL ROAD, LAHORE", + "actual": "GOVT BOYS COLLEGE, MALL ROAD, LAHORE", + "status": "ok", + "correct": true, + "confidence": 0.995, + "cer": 0.0, + "tags": [ + "matric", + "rotated90" + ], + "error": null + }, + { + "case": "15_matric_rotated90", + "doc_type": "matric", + "field": "max_marks", + "expected": "1100", + "actual": "1100", + "status": "ok", + "correct": true, + "confidence": 0.95, + "cer": null, + "tags": [ + "matric", + "rotated90" + ], + "error": null + }, + { + "case": "15_matric_rotated90", + "doc_type": "matric", + "field": "total_marks", + "expected": "853", + "actual": "853", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "matric", + "rotated90" + ], + "error": null + }, + { + "case": "16_matric_photo", + "doc_type": "matric", + "field": "board", + "expected": "FBISE", + "actual": "FBISE", + "status": "ok", + "correct": true, + "confidence": 0.9, + "cer": null, + "tags": [ + "matric", + "photo" + ], + "error": null + }, + { + "case": "16_matric_photo", + "doc_type": "matric", + "field": "serial_number", + "expected": "3058316", + "actual": "3058316", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "matric", + "photo" + ], + "error": null + }, + { + "case": "16_matric_photo", + "doc_type": "matric", + "field": "roll_number", + "expected": "2084458", + "actual": "2084458", + "status": "ok", + "correct": true, + "confidence": 0.9978, + "cer": null, + "tags": [ + "matric", + "photo" + ], + "error": null + }, + { + "case": "16_matric_photo", + "doc_type": "matric", + "field": "registration_number", + "expected": "1160282720", + "actual": "1160282720", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "matric", + "photo" + ], + "error": null + }, + { + "case": "16_matric_photo", + "doc_type": "matric", + "field": "certificate_number", + "expected": "661120183/471238", + "actual": "661120183/471238", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "matric", + "photo" + ], + "error": null + }, + { + "case": "16_matric_photo", + "doc_type": "matric", + "field": "group", + "expected": "SCIENCE", + "actual": "SCIENCE", + "status": "ok", + "correct": true, + "confidence": 0.9999, + "cer": null, + "tags": [ + "matric", + "photo" + ], + "error": null + }, + { + "case": "16_matric_photo", + "doc_type": "matric", + "field": "year", + "expected": "2017", + "actual": "2017", + "status": "ok", + "correct": true, + "confidence": 0.9, + "cer": null, + "tags": [ + "matric", + "photo" + ], + "error": null + }, + { + "case": "16_matric_photo", + "doc_type": "matric", + "field": "grade", + "expected": "B", + "actual": "B", + "status": "ok", + "correct": true, + "confidence": 0.9927, + "cer": null, + "tags": [ + "matric", + "photo" + ], + "error": null + }, + { + "case": "16_matric_photo", + "doc_type": "matric", + "field": "student_name", + "expected": "FATIMA QURESHI", + "actual": "FATIMA QURESHI", + "status": "ok", + "correct": true, + "confidence": 0.9968, + "cer": 0.0, + "tags": [ + "matric", + "photo" + ], + "error": null + }, + { + "case": "16_matric_photo", + "doc_type": "matric", + "field": "father_name", + "expected": "HASSAN SIDDIQUI", + "actual": "HASSAN SIDDIQUI", + "status": "ok", + "correct": true, + "confidence": 0.9875, + "cer": 0.0, + "tags": [ + "matric", + "photo" + ], + "error": null + }, + { + "case": "16_matric_photo", + "doc_type": "matric", + "field": "date_of_birth", + "expected": "21-06-2002", + "actual": "21-06-2002", + "status": "ok", + "correct": true, + "confidence": 0.9794, + "cer": null, + "tags": [ + "matric", + "photo" + ], + "error": null + }, + { + "case": "16_matric_photo", + "doc_type": "matric", + "field": "institute", + "expected": "FG GIRLS COLLEGE, CANTT, PESHAWAR", + "actual": "FG GIRLS COLLEGE, CANTT, PESHAWAR", + "status": "ok", + "correct": true, + "confidence": 0.9883, + "cer": 0.0, + "tags": [ + "matric", + "photo" + ], + "error": null + }, + { + "case": "16_matric_photo", + "doc_type": "matric", + "field": "max_marks", + "expected": "1100", + "actual": "1100", + "status": "ok", + "correct": true, + "confidence": 0.95, + "cer": null, + "tags": [ + "matric", + "photo" + ], + "error": null + }, + { + "case": "16_matric_photo", + "doc_type": "matric", + "field": "total_marks", + "expected": "688", + "actual": "688", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "matric", + "photo" + ], + "error": null + }, + { + "case": "17_matric_low_res", + "doc_type": "matric", + "field": "board", + "expected": "FBISE", + "actual": "FBISE", + "status": "ok", + "correct": true, + "confidence": 0.9, + "cer": null, + "tags": [ + "matric", + "low_res" + ], + "error": null + }, + { + "case": "17_matric_low_res", + "doc_type": "matric", + "field": "serial_number", + "expected": "4829496", + "actual": "4829496", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "matric", + "low_res" + ], + "error": null + }, + { + "case": "17_matric_low_res", + "doc_type": "matric", + "field": "roll_number", + "expected": "491949", + "actual": "491949", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "matric", + "low_res" + ], + "error": null + }, + { + "case": "17_matric_low_res", + "doc_type": "matric", + "field": "registration_number", + "expected": "5366245723", + "actual": "5366245723", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "matric", + "low_res" + ], + "error": null + }, + { + "case": "17_matric_low_res", + "doc_type": "matric", + "field": "certificate_number", + "expected": "681055366/602103", + "actual": "681055366/602103", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "matric", + "low_res" + ], + "error": null + }, + { + "case": "17_matric_low_res", + "doc_type": "matric", + "field": "group", + "expected": "GENERAL", + "actual": "GENERAL", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "matric", + "low_res" + ], + "error": null + }, + { + "case": "17_matric_low_res", + "doc_type": "matric", + "field": "year", + "expected": "2019", + "actual": "2019", + "status": "ok", + "correct": true, + "confidence": 0.9, + "cer": null, + "tags": [ + "matric", + "low_res" + ], + "error": null + }, + { + "case": "17_matric_low_res", + "doc_type": "matric", + "field": "grade", + "expected": "C", + "actual": "C", + "status": "ok", + "correct": true, + "confidence": 0.9921, + "cer": null, + "tags": [ + "matric", + "low_res" + ], + "error": null + }, + { + "case": "17_matric_low_res", + "doc_type": "matric", + "field": "student_name", + "expected": "AMNA BUTT", + "actual": "AMNA BUTT", + "status": "ok", + "correct": true, + "confidence": 0.989, + "cer": 0.0, + "tags": [ + "matric", + "low_res" + ], + "error": null + }, + { + "case": "17_matric_low_res", + "doc_type": "matric", + "field": "father_name", + "expected": "ALI SHAH", + "actual": "ALI SHAH", + "status": "ok", + "correct": true, + "confidence": 0.9913, + "cer": 0.0, + "tags": [ + "matric", + "low_res" + ], + "error": null + }, + { + "case": "17_matric_low_res", + "doc_type": "matric", + "field": "date_of_birth", + "expected": "30-10-2001", + "actual": "30-10-2001", + "status": "ok", + "correct": true, + "confidence": 0.9724, + "cer": null, + "tags": [ + "matric", + "low_res" + ], + "error": null + }, + { + "case": "17_matric_low_res", + "doc_type": "matric", + "field": "institute", + "expected": "GOVT BOYS COLLEGE, MALL ROAD, LAHORE", + "actual": "GOVT BOYS COLLEGE, MALL ROAD, LAHORE", + "status": "ok", + "correct": true, + "confidence": 0.9861, + "cer": 0.0, + "tags": [ + "matric", + "low_res" + ], + "error": null + }, + { + "case": "17_matric_low_res", + "doc_type": "matric", + "field": "max_marks", + "expected": "1100", + "actual": "1100", + "status": "ok", + "correct": true, + "confidence": 0.95, + "cer": null, + "tags": [ + "matric", + "low_res" + ], + "error": null + }, + { + "case": "17_matric_low_res", + "doc_type": "matric", + "field": "total_marks", + "expected": "876", + "actual": "876", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "matric", + "low_res" + ], + "error": null + }, + { + "case": "18_intermediate_clean", + "doc_type": "intermediate", + "field": "board", + "expected": "FBISE", + "actual": "FBISE", + "status": "ok", + "correct": true, + "confidence": 0.9, + "cer": null, + "tags": [ + "intermediate", + "clean" + ], + "error": null + }, + { + "case": "18_intermediate_clean", + "doc_type": "intermediate", + "field": "serial_number", + "expected": "1191416", + "actual": "1191416", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "intermediate", + "clean" + ], + "error": null + }, + { + "case": "18_intermediate_clean", + "doc_type": "intermediate", + "field": "roll_number", + "expected": "4173617", + "actual": "4173617", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "intermediate", + "clean" + ], + "error": null + }, + { + "case": "18_intermediate_clean", + "doc_type": "intermediate", + "field": "registration_number", + "expected": "3020975659", + "actual": "3020975659", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "intermediate", + "clean" + ], + "error": null + }, + { + "case": "18_intermediate_clean", + "doc_type": "intermediate", + "field": "certificate_number", + "expected": "338197458/812133", + "actual": "338197458/812133", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "intermediate", + "clean" + ], + "error": null + }, + { + "case": "18_intermediate_clean", + "doc_type": "intermediate", + "field": "group", + "expected": "PRE-ENGINEERING", + "actual": "PRE-ENGINEERING", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "intermediate", + "clean" + ], + "error": null + }, + { + "case": "18_intermediate_clean", + "doc_type": "intermediate", + "field": "year", + "expected": "2020", + "actual": "2020", + "status": "ok", + "correct": true, + "confidence": 0.9, + "cer": null, + "tags": [ + "intermediate", + "clean" + ], + "error": null + }, + { + "case": "18_intermediate_clean", + "doc_type": "intermediate", + "field": "grade", + "expected": "A", + "actual": "A", + "status": "ok", + "correct": true, + "confidence": 0.9985, + "cer": null, + "tags": [ + "intermediate", + "clean" + ], + "error": null + }, + { + "case": "18_intermediate_clean", + "doc_type": "intermediate", + "field": "student_name", + "expected": "ALI SALEEM", + "actual": "ALI SALEEM", + "status": "ok", + "correct": true, + "confidence": 0.9878, + "cer": 0.0, + "tags": [ + "intermediate", + "clean" + ], + "error": null + }, + { + "case": "18_intermediate_clean", + "doc_type": "intermediate", + "field": "father_name", + "expected": "MUHAMMAD NAWAZ", + "actual": "MUHAMMAD NAWAZ", + "status": "ok", + "correct": true, + "confidence": 0.9836, + "cer": 0.0, + "tags": [ + "intermediate", + "clean" + ], + "error": null + }, + { + "case": "18_intermediate_clean", + "doc_type": "intermediate", + "field": "date_of_birth", + "expected": "26-04-2002", + "actual": "26-04-2002", + "status": "ok", + "correct": true, + "confidence": 0.9999, + "cer": null, + "tags": [ + "intermediate", + "clean" + ], + "error": null + }, + { + "case": "18_intermediate_clean", + "doc_type": "intermediate", + "field": "institute", + "expected": "GOVT BOYS COLLEGE, MALL ROAD, LAHORE", + "actual": "GOVT BOYS COLLEGE, MALL ROAD, LAHORE", + "status": "ok", + "correct": true, + "confidence": 0.9948, + "cer": 0.0, + "tags": [ + "intermediate", + "clean" + ], + "error": null + }, + { + "case": "18_intermediate_clean", + "doc_type": "intermediate", + "field": "max_marks", + "expected": "1100", + "actual": "1100", + "status": "ok", + "correct": true, + "confidence": 0.95, + "cer": null, + "tags": [ + "intermediate", + "clean" + ], + "error": null + }, + { + "case": "18_intermediate_clean", + "doc_type": "intermediate", + "field": "total_marks", + "expected": "864", + "actual": "864", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "intermediate", + "clean" + ], + "error": null + }, + { + "case": "19_intermediate_skewed", + "doc_type": "intermediate", + "field": "board", + "expected": "FBISE", + "actual": "FBISE", + "status": "ok", + "correct": true, + "confidence": 0.9, + "cer": null, + "tags": [ + "intermediate", + "skewed" + ], + "error": null + }, + { + "case": "19_intermediate_skewed", + "doc_type": "intermediate", + "field": "serial_number", + "expected": "6237740", + "actual": "6237740", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "intermediate", + "skewed" + ], + "error": null + }, + { + "case": "19_intermediate_skewed", + "doc_type": "intermediate", + "field": "roll_number", + "expected": "3252477", + "actual": "3252477", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "intermediate", + "skewed" + ], + "error": null + }, + { + "case": "19_intermediate_skewed", + "doc_type": "intermediate", + "field": "registration_number", + "expected": "2763248246", + "actual": "2763248246", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "intermediate", + "skewed" + ], + "error": null + }, + { + "case": "19_intermediate_skewed", + "doc_type": "intermediate", + "field": "certificate_number", + "expected": "620610912/105569", + "actual": "620610912/105569", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "intermediate", + "skewed" + ], + "error": null + }, + { + "case": "19_intermediate_skewed", + "doc_type": "intermediate", + "field": "group", + "expected": "PRE-ENGINEERING", + "actual": "PRE-ENGINEERING", + "status": "ok", + "correct": true, + "confidence": 0.9999, + "cer": null, + "tags": [ + "intermediate", + "skewed" + ], + "error": null + }, + { + "case": "19_intermediate_skewed", + "doc_type": "intermediate", + "field": "year", + "expected": "2025", + "actual": "2025", + "status": "ok", + "correct": true, + "confidence": 0.9, + "cer": null, + "tags": [ + "intermediate", + "skewed" + ], + "error": null + }, + { + "case": "19_intermediate_skewed", + "doc_type": "intermediate", + "field": "grade", + "expected": "B", + "actual": "B", + "status": "ok", + "correct": true, + "confidence": 0.9972, + "cer": null, + "tags": [ + "intermediate", + "skewed" + ], + "error": null + }, + { + "case": "19_intermediate_skewed", + "doc_type": "intermediate", + "field": "student_name", + "expected": "HAMZA MALIK", + "actual": "HAMZA MALIK", + "status": "ok", + "correct": true, + "confidence": 0.9963, + "cer": 0.0, + "tags": [ + "intermediate", + "skewed" + ], + "error": null + }, + { + "case": "19_intermediate_skewed", + "doc_type": "intermediate", + "field": "father_name", + "expected": "HAMZA ABBASI", + "actual": "HAMZA ABBASI", + "status": "ok", + "correct": true, + "confidence": 0.9855, + "cer": 0.0, + "tags": [ + "intermediate", + "skewed" + ], + "error": null + }, + { + "case": "19_intermediate_skewed", + "doc_type": "intermediate", + "field": "date_of_birth", + "expected": "04-04-2010", + "actual": "04-04-2010", + "status": "ok", + "correct": true, + "confidence": 0.9911, + "cer": null, + "tags": [ + "intermediate", + "skewed" + ], + "error": null + }, + { + "case": "19_intermediate_skewed", + "doc_type": "intermediate", + "field": "institute", + "expected": "MODEL HIGH SCHOOL, SECTOR G-7, ISLAMABAD", + "actual": "MODEL HIGH SCHOOL, SECTOR G-7, ISLAMABAD", + "status": "ok", + "correct": true, + "confidence": 0.9937, + "cer": 0.0, + "tags": [ + "intermediate", + "skewed" + ], + "error": null + }, + { + "case": "19_intermediate_skewed", + "doc_type": "intermediate", + "field": "max_marks", + "expected": "1100", + "actual": "1100", + "status": "ok", + "correct": true, + "confidence": 0.95, + "cer": null, + "tags": [ + "intermediate", + "skewed" + ], + "error": null + }, + { + "case": "19_intermediate_skewed", + "doc_type": "intermediate", + "field": "total_marks", + "expected": "887", + "actual": "887", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "intermediate", + "skewed" + ], + "error": null + }, + { + "case": "20_intermediate_photo", + "doc_type": "intermediate", + "field": "board", + "expected": "FBISE", + "actual": "FBISE", + "status": "ok", + "correct": true, + "confidence": 0.9, + "cer": null, + "tags": [ + "intermediate", + "photo" + ], + "error": null + }, + { + "case": "20_intermediate_photo", + "doc_type": "intermediate", + "field": "serial_number", + "expected": "7598076", + "actual": "7598076", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "intermediate", + "photo" + ], + "error": null + }, + { + "case": "20_intermediate_photo", + "doc_type": "intermediate", + "field": "roll_number", + "expected": "6485104", + "actual": "6485104", + "status": "ok", + "correct": true, + "confidence": 0.9999, + "cer": null, + "tags": [ + "intermediate", + "photo" + ], + "error": null + }, + { + "case": "20_intermediate_photo", + "doc_type": "intermediate", + "field": "registration_number", + "expected": "3842279313", + "actual": "3842279313", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "intermediate", + "photo" + ], + "error": null + }, + { + "case": "20_intermediate_photo", + "doc_type": "intermediate", + "field": "certificate_number", + "expected": "646278472/210018", + "actual": "646278472/210018", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "intermediate", + "photo" + ], + "error": null + }, + { + "case": "20_intermediate_photo", + "doc_type": "intermediate", + "field": "group", + "expected": "PRE-MEDICAL", + "actual": "PRE-MEDICAL", + "status": "ok", + "correct": true, + "confidence": 0.9999, + "cer": null, + "tags": [ + "intermediate", + "photo" + ], + "error": null + }, + { + "case": "20_intermediate_photo", + "doc_type": "intermediate", + "field": "year", + "expected": "2024", + "actual": "2024", + "status": "ok", + "correct": true, + "confidence": 0.9, + "cer": null, + "tags": [ + "intermediate", + "photo" + ], + "error": null + }, + { + "case": "20_intermediate_photo", + "doc_type": "intermediate", + "field": "grade", + "expected": "A1", + "actual": "A1", + "status": "ok", + "correct": true, + "confidence": 0.9909, + "cer": null, + "tags": [ + "intermediate", + "photo" + ], + "error": null + }, + { + "case": "20_intermediate_photo", + "doc_type": "intermediate", + "field": "student_name", + "expected": "HIRA RAZA", + "actual": "HIRA RAZA", + "status": "ok", + "correct": true, + "confidence": 0.9651, + "cer": 0.0, + "tags": [ + "intermediate", + "photo" + ], + "error": null + }, + { + "case": "20_intermediate_photo", + "doc_type": "intermediate", + "field": "father_name", + "expected": "AHMED KHAN", + "actual": "AHMED KHAN", + "status": "ok", + "correct": true, + "confidence": 0.9979, + "cer": 0.0, + "tags": [ + "intermediate", + "photo" + ], + "error": null + }, + { + "case": "20_intermediate_photo", + "doc_type": "intermediate", + "field": "date_of_birth", + "expected": "02-04-2006", + "actual": "02-04-2006", + "status": "ok", + "correct": true, + "confidence": 0.9949, + "cer": null, + "tags": [ + "intermediate", + "photo" + ], + "error": null + }, + { + "case": "20_intermediate_photo", + "doc_type": "intermediate", + "field": "institute", + "expected": "FG GIRLS COLLEGE, CANTT, PESHAWAR", + "actual": "FG GIRLS COLLEGE, CANTT, PESHAWAR", + "status": "ok", + "correct": true, + "confidence": 0.9993, + "cer": 0.0, + "tags": [ + "intermediate", + "photo" + ], + "error": null + }, + { + "case": "20_intermediate_photo", + "doc_type": "intermediate", + "field": "max_marks", + "expected": "1100", + "actual": "1100", + "status": "ok", + "correct": true, + "confidence": 0.95, + "cer": null, + "tags": [ + "intermediate", + "photo" + ], + "error": null + }, + { + "case": "20_intermediate_photo", + "doc_type": "intermediate", + "field": "total_marks", + "expected": "791", + "actual": "791", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": null, + "tags": [ + "intermediate", + "photo" + ], + "error": null + }, + { + "case": "21_degree_clean", + "doc_type": "degree", + "field": "institution", + "expected": "Sample Institute of Engineering and Technology", + "actual": "Sample Institute of Engineering and Technology", + "status": "ok", + "correct": true, + "confidence": 0.9942, + "cer": 0.0, + "tags": [ + "degree", + "clean" + ], + "error": null + }, + { + "case": "21_degree_clean", + "doc_type": "degree", + "field": "student_name", + "expected": "USMAN SHAH", + "actual": "USMAN SHAH", + "status": "ok", + "correct": true, + "confidence": 0.9996, + "cer": 0.0, + "tags": [ + "degree", + "clean" + ], + "error": null + }, + { + "case": "21_degree_clean", + "doc_type": "degree", + "field": "father_name", + "expected": "BILAL QURESHI", + "actual": "BILAL QURESHI", + "status": "ok", + "correct": true, + "confidence": 0.9996, + "cer": 0.0, + "tags": [ + "degree", + "clean" + ], + "error": null + }, + { + "case": "21_degree_clean", + "doc_type": "degree", + "field": "degree", + "expected": "Master of Business Administration", + "actual": "Master of Business Administration", + "status": "ok", + "correct": true, + "confidence": 0.9999, + "cer": 0.0, + "tags": [ + "degree", + "clean" + ], + "error": null + }, + { + "case": "21_degree_clean", + "doc_type": "degree", + "field": "serial_number", + "expected": "297863", + "actual": "297863", + "status": "ok", + "correct": true, + "confidence": 0.9989, + "cer": null, + "tags": [ + "degree", + "clean" + ], + "error": null + }, + { + "case": "21_degree_clean", + "doc_type": "degree", + "field": "registration_number", + "expected": "EX/SP13-BCS-192", + "actual": "EX/SP13-BCS-192", + "status": "low_confidence", + "correct": true, + "confidence": 0.5997, + "cer": null, + "tags": [ + "degree", + "clean" + ], + "error": null + }, + { + "case": "21_degree_clean", + "doc_type": "degree", + "field": "cgpa", + "expected": "3.26", + "actual": "3.26", + "status": "ok", + "correct": true, + "confidence": 0.9967, + "cer": null, + "tags": [ + "degree", + "clean" + ], + "error": null + }, + { + "case": "21_degree_clean", + "doc_type": "degree", + "field": "date_of_issue", + "expected": "18-07-2017", + "actual": "18-07-2017", + "status": "ok", + "correct": true, + "confidence": 0.998, + "cer": null, + "tags": [ + "degree", + "clean" + ], + "error": null + }, + { + "case": "21_degree_clean", + "doc_type": "degree", + "field": "year", + "expected": "2017", + "actual": "2017", + "status": "ok", + "correct": true, + "confidence": 0.998, + "cer": null, + "tags": [ + "degree", + "clean" + ], + "error": null + }, + { + "case": "22_degree_photo", + "doc_type": "degree", + "field": "institution", + "expected": "Example University of Technology", + "actual": "Example University of Technology", + "status": "ok", + "correct": true, + "confidence": 1.0, + "cer": 0.0, + "tags": [ + "degree", + "photo" + ], + "error": null + }, + { + "case": "22_degree_photo", + "doc_type": "degree", + "field": "student_name", + "expected": "HASSAN ABBASI", + "actual": "HASSAN ABBASI", + "status": "ok", + "correct": true, + "confidence": 0.9997, + "cer": 0.0, + "tags": [ + "degree", + "photo" + ], + "error": null + }, + { + "case": "22_degree_photo", + "doc_type": "degree", + "field": "father_name", + "expected": "FAHAD SHAH", + "actual": "FAHAD SHAH", + "status": "ok", + "correct": true, + "confidence": 0.9997, + "cer": 0.0, + "tags": [ + "degree", + "photo" + ], + "error": null + }, + { + "case": "22_degree_photo", + "doc_type": "degree", + "field": "degree", + "expected": "Bachelor of Science in Computer Science", + "actual": "Bachelor of Science in Computer Science", + "status": "ok", + "correct": true, + "confidence": 0.9974, + "cer": 0.0, + "tags": [ + "degree", + "photo" + ], + "error": null + }, + { + "case": "22_degree_photo", + "doc_type": "degree", + "field": "serial_number", + "expected": "251776", + "actual": "251776", + "status": "ok", + "correct": true, + "confidence": 0.9923, + "cer": null, + "tags": [ + "degree", + "photo" + ], + "error": null + }, + { + "case": "22_degree_photo", + "doc_type": "degree", + "field": "registration_number", + "expected": "EX/SP16-BCS-042", + "actual": "EX/SP16-BCS-042", + "status": "low_confidence", + "correct": true, + "confidence": 0.597, + "cer": null, + "tags": [ + "degree", + "photo" + ], + "error": null + }, + { + "case": "22_degree_photo", + "doc_type": "degree", + "field": "cgpa", + "expected": "2.06", + "actual": "2.06", + "status": "ok", + "correct": true, + "confidence": 0.9859, + "cer": null, + "tags": [ + "degree", + "photo" + ], + "error": null + }, + { + "case": "22_degree_photo", + "doc_type": "degree", + "field": "date_of_issue", + "expected": "03-10-2020", + "actual": "03-10-2020", + "status": "ok", + "correct": true, + "confidence": 0.9846, + "cer": null, + "tags": [ + "degree", + "photo" + ], + "error": null + }, + { + "case": "22_degree_photo", + "doc_type": "degree", + "field": "year", + "expected": "2020", + "actual": "2020", + "status": "ok", + "correct": true, + "confidence": 0.9846, + "cer": null, + "tags": [ + "degree", + "photo" + ], + "error": null + } + ] +} \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..1864b0b --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,97 @@ +"""Shared test helpers. + +Most tests run the real parsers on *constructed* OCR output (text boxes at +known positions) via :class:`FakeBackend`. That exercises every line of +parsing, validation and layout logic without downloading OCR models, so the +suite runs in seconds on CI. Tests marked ``ocr`` run the real EasyOCR +models on synthetic fixture images. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Dict, List, Optional, Sequence, Tuple + +import numpy as np +import pytest + +from docling_pk.ocr.base import OCRBackend, TextBox +from docling_pk.pipeline import Page + +FIXTURES = Path(__file__).parent / "fixtures" + + +def box(text: str, x: float, y: float, w: Optional[float] = None, h: float = 20, conf: float = 0.95) -> TextBox: + """A text box with its top-left corner at (x, y).""" + w = w if w is not None else max(len(text) * h * 0.55, h) + return TextBox.from_rect(text, conf, x, y, x + w, y + h) + + +class FakeBackend(OCRBackend): + """Returns preset boxes; ``recognize`` returns preset re-reads by text.""" + + name = "fake" + + def __init__(self, boxes: Sequence[TextBox] = (), rereads: Optional[Dict[str, Tuple[str, float]]] = None): + self.boxes = list(boxes) + self.rereads = rereads or {} + self.last_crop_shape = None + self.calls = 0 + + def read(self, image: np.ndarray) -> List[TextBox]: + self.calls += 1 + return list(self.boxes) + + def recognize(self, image: np.ndarray, allowlist: Optional[str] = None) -> Tuple[str, float]: + self.last_crop_shape = image.shape + return self.rereads.get("*", ("", 0.0)) + + +def make_page(boxes: Sequence[TextBox], rereads=None, size=(1000, 1600)) -> Page: + backend = FakeBackend(boxes, rereads) + image = np.full((size[0], size[1], 3), 255, dtype=np.uint8) + return Page(image=image, boxes=list(boxes), backend=backend) + + +def cnic_front_boxes( + name="Muhammad Ali", + father="Muhammad Akram", + gender="M", + number="35202-1234567-9", + dob="01.01.1995", + doi="15.03.2020", + doe="14.03.2030", + shuffle_dates=False, +) -> List[TextBox]: + """Boxes laid out like the front of a current CNIC.""" + b = [ + box("PAKISTAN", 300, 40, h=40), + box("National Identity Card", 620, 45, h=28), + box("ISLAMIC REPUBLIC OF PAKISTAN", 300, 100, h=16), + box("Name", 340, 160, h=18), + box(name, 350, 185, h=30), + box("Father Name", 340, 310, h=18), + box(father, 350, 335, h=30), + box("Gender", 335, 470, h=18), + box("Country of Stay", 455, 470, h=18), + box(gender, 350, 505, h=28) if gender else None, + box("Pakistan", 470, 505, h=28), + box("Identity Number", 335, 575, h=18), + box("Date of Birth", 635, 575, h=18), + box(number, 345, 605, h=30), + box(dob, 650, 605, h=30), + box("Date of Issue", 335, 665, h=18), + box("Date of Expiry", 635, 665, h=18), + box(doi, 345, 700, h=30), + box(doe, 650, 700, h=30), + box("Holder's Signature", 910, 745, h=18), + ] + b = [x for x in b if x is not None] + if shuffle_dates: + b.reverse() + return b + + +@pytest.fixture +def fixtures_dir() -> Path: + return FIXTURES diff --git a/tests/fixtures/synthetic/00_cnic_front_clean.jpg b/tests/fixtures/synthetic/00_cnic_front_clean.jpg new file mode 100644 index 0000000..8d66f6a Binary files /dev/null and b/tests/fixtures/synthetic/00_cnic_front_clean.jpg differ diff --git a/tests/fixtures/synthetic/01_cnic_front_clean.jpg b/tests/fixtures/synthetic/01_cnic_front_clean.jpg new file mode 100644 index 0000000..1121607 Binary files /dev/null and b/tests/fixtures/synthetic/01_cnic_front_clean.jpg differ diff --git a/tests/fixtures/synthetic/02_cnic_front_rotated90.jpg b/tests/fixtures/synthetic/02_cnic_front_rotated90.jpg new file mode 100644 index 0000000..3de5748 Binary files /dev/null and b/tests/fixtures/synthetic/02_cnic_front_rotated90.jpg differ diff --git a/tests/fixtures/synthetic/03_cnic_front_rotated270.jpg b/tests/fixtures/synthetic/03_cnic_front_rotated270.jpg new file mode 100644 index 0000000..01fda3a Binary files /dev/null and b/tests/fixtures/synthetic/03_cnic_front_rotated270.jpg differ diff --git a/tests/fixtures/synthetic/04_cnic_front_upside_down.jpg b/tests/fixtures/synthetic/04_cnic_front_upside_down.jpg new file mode 100644 index 0000000..7a4bb18 Binary files /dev/null and b/tests/fixtures/synthetic/04_cnic_front_upside_down.jpg differ diff --git a/tests/fixtures/synthetic/05_cnic_front_skewed.jpg b/tests/fixtures/synthetic/05_cnic_front_skewed.jpg new file mode 100644 index 0000000..0e95a72 Binary files /dev/null and b/tests/fixtures/synthetic/05_cnic_front_skewed.jpg differ diff --git a/tests/fixtures/synthetic/06_cnic_front_photo.jpg b/tests/fixtures/synthetic/06_cnic_front_photo.jpg new file mode 100644 index 0000000..3dcb118 Binary files /dev/null and b/tests/fixtures/synthetic/06_cnic_front_photo.jpg differ diff --git a/tests/fixtures/synthetic/07_cnic_front_photo.jpg b/tests/fixtures/synthetic/07_cnic_front_photo.jpg new file mode 100644 index 0000000..4e16390 Binary files /dev/null and b/tests/fixtures/synthetic/07_cnic_front_photo.jpg differ diff --git a/tests/fixtures/synthetic/08_cnic_front_low_res.jpg b/tests/fixtures/synthetic/08_cnic_front_low_res.jpg new file mode 100644 index 0000000..3040872 Binary files /dev/null and b/tests/fixtures/synthetic/08_cnic_front_low_res.jpg differ diff --git a/tests/fixtures/synthetic/09_cnic_front_blurry.jpg b/tests/fixtures/synthetic/09_cnic_front_blurry.jpg new file mode 100644 index 0000000..248a35b Binary files /dev/null and b/tests/fixtures/synthetic/09_cnic_front_blurry.jpg differ diff --git a/tests/fixtures/synthetic/10_cnic_front_very_blurry.jpg b/tests/fixtures/synthetic/10_cnic_front_very_blurry.jpg new file mode 100644 index 0000000..6afcb3a Binary files /dev/null and b/tests/fixtures/synthetic/10_cnic_front_very_blurry.jpg differ diff --git a/tests/fixtures/synthetic/11_cnic_front_occluded_name.jpg b/tests/fixtures/synthetic/11_cnic_front_occluded_name.jpg new file mode 100644 index 0000000..92b05e0 Binary files /dev/null and b/tests/fixtures/synthetic/11_cnic_front_occluded_name.jpg differ diff --git a/tests/fixtures/synthetic/12_cnic_back_clean.jpg b/tests/fixtures/synthetic/12_cnic_back_clean.jpg new file mode 100644 index 0000000..29d2447 Binary files /dev/null and b/tests/fixtures/synthetic/12_cnic_back_clean.jpg differ diff --git a/tests/fixtures/synthetic/13_cnic_back_photo.jpg b/tests/fixtures/synthetic/13_cnic_back_photo.jpg new file mode 100644 index 0000000..16c24a3 Binary files /dev/null and b/tests/fixtures/synthetic/13_cnic_back_photo.jpg differ diff --git a/tests/fixtures/synthetic/14_matric_clean.jpg b/tests/fixtures/synthetic/14_matric_clean.jpg new file mode 100644 index 0000000..9b19515 Binary files /dev/null and b/tests/fixtures/synthetic/14_matric_clean.jpg differ diff --git a/tests/fixtures/synthetic/15_matric_rotated90.jpg b/tests/fixtures/synthetic/15_matric_rotated90.jpg new file mode 100644 index 0000000..8d38076 Binary files /dev/null and b/tests/fixtures/synthetic/15_matric_rotated90.jpg differ diff --git a/tests/fixtures/synthetic/16_matric_photo.jpg b/tests/fixtures/synthetic/16_matric_photo.jpg new file mode 100644 index 0000000..5844feb Binary files /dev/null and b/tests/fixtures/synthetic/16_matric_photo.jpg differ diff --git a/tests/fixtures/synthetic/17_matric_low_res.jpg b/tests/fixtures/synthetic/17_matric_low_res.jpg new file mode 100644 index 0000000..7febcc3 Binary files /dev/null and b/tests/fixtures/synthetic/17_matric_low_res.jpg differ diff --git a/tests/fixtures/synthetic/18_intermediate_clean.jpg b/tests/fixtures/synthetic/18_intermediate_clean.jpg new file mode 100644 index 0000000..1516380 Binary files /dev/null and b/tests/fixtures/synthetic/18_intermediate_clean.jpg differ diff --git a/tests/fixtures/synthetic/19_intermediate_skewed.jpg b/tests/fixtures/synthetic/19_intermediate_skewed.jpg new file mode 100644 index 0000000..cca70ff Binary files /dev/null and b/tests/fixtures/synthetic/19_intermediate_skewed.jpg differ diff --git a/tests/fixtures/synthetic/20_intermediate_photo.jpg b/tests/fixtures/synthetic/20_intermediate_photo.jpg new file mode 100644 index 0000000..7ed8255 Binary files /dev/null and b/tests/fixtures/synthetic/20_intermediate_photo.jpg differ diff --git a/tests/fixtures/synthetic/21_degree_clean.jpg b/tests/fixtures/synthetic/21_degree_clean.jpg new file mode 100644 index 0000000..90e7fd2 Binary files /dev/null and b/tests/fixtures/synthetic/21_degree_clean.jpg differ diff --git a/tests/fixtures/synthetic/22_degree_photo.jpg b/tests/fixtures/synthetic/22_degree_photo.jpg new file mode 100644 index 0000000..952c620 Binary files /dev/null and b/tests/fixtures/synthetic/22_degree_photo.jpg differ diff --git a/tests/fixtures/synthetic/labels.json b/tests/fixtures/synthetic/labels.json new file mode 100644 index 0000000..695670f --- /dev/null +++ b/tests/fixtures/synthetic/labels.json @@ -0,0 +1,755 @@ +{ + "_note": "Synthetic data. Generated by benchmarks/synthetic/generate.py", + "seed": 2026, + "cases": [ + { + "id": "00_cnic_front_clean", + "image": "00_cnic_front_clean.jpg", + "doc_type": "cnic", + "tags": [ + "cnic_front", + "clean" + ], + "expected": { + "name": "Mahnoor Nawaz", + "father_name": "Ahmed Butt", + "gender": "F", + "country_of_stay": "Pakistan", + "cnic_number": "59869-8797309-2", + "date_of_birth": "08-12-1959", + "date_of_issue": "11-06-2018", + "date_of_expiry": "11-06-2023" + } + }, + { + "id": "01_cnic_front_clean", + "image": "01_cnic_front_clean.jpg", + "doc_type": "cnic", + "tags": [ + "cnic_front", + "clean" + ], + "expected": { + "name": "Iqra Siddiqui", + "father_name": "Usman Javed", + "gender": "F", + "country_of_stay": "Pakistan", + "cnic_number": "35568-1518844-8", + "date_of_birth": "22-08-1962", + "date_of_issue": "13-12-2024", + "date_of_expiry": "13-12-2031" + } + }, + { + "id": "02_cnic_front_rotated90", + "image": "02_cnic_front_rotated90.jpg", + "doc_type": "cnic", + "tags": [ + "cnic_front", + "rotated90" + ], + "expected": { + "name": "Hamza Abbasi", + "father_name": "Zain Rehman", + "gender": "M", + "country_of_stay": "Pakistan", + "cnic_number": "16987-1689768-5", + "date_of_birth": "05-09-1987", + "date_of_issue": "25-08-2024", + "date_of_expiry": "25-08-2031" + } + }, + { + "id": "03_cnic_front_rotated270", + "image": "03_cnic_front_rotated270.jpg", + "doc_type": "cnic", + "tags": [ + "cnic_front", + "rotated270" + ], + "expected": { + "name": "Muhammad Tariq", + "father_name": "Usman Raza", + "gender": "M", + "country_of_stay": "Pakistan", + "cnic_number": "70819-6727317-7", + "date_of_birth": "14-12-1998", + "date_of_issue": "25-10-2018", + "date_of_expiry": "25-10-2028" + } + }, + { + "id": "04_cnic_front_upside_down", + "image": "04_cnic_front_upside_down.jpg", + "doc_type": "cnic", + "tags": [ + "cnic_front", + "upside_down" + ], + "expected": { + "name": "Bilal Saleem", + "father_name": "Fahad Aslam", + "gender": "M", + "country_of_stay": "Pakistan", + "cnic_number": "76634-4002978-7", + "date_of_birth": "17-04-1982", + "date_of_issue": "25-06-2016", + "date_of_expiry": "25-06-2023" + } + }, + { + "id": "05_cnic_front_skewed", + "image": "05_cnic_front_skewed.jpg", + "doc_type": "cnic", + "tags": [ + "cnic_front", + "skewed" + ], + "expected": { + "name": "Fahad Aslam", + "father_name": "Hassan Saleem", + "gender": "M", + "country_of_stay": "Pakistan", + "cnic_number": "60457-1784726-7", + "date_of_birth": "08-10-1955", + "date_of_issue": "22-12-2017", + "date_of_expiry": "22-12-2024" + } + }, + { + "id": "06_cnic_front_photo", + "image": "06_cnic_front_photo.jpg", + "doc_type": "cnic", + "tags": [ + "cnic_front", + "photo" + ], + "expected": { + "name": "Hassan Raza", + "father_name": "Zain Khan", + "gender": "M", + "country_of_stay": "Pakistan", + "cnic_number": "10798-1933190-9", + "date_of_birth": "03-02-1974", + "date_of_issue": "05-01-2020", + "date_of_expiry": "05-01-2025" + } + }, + { + "id": "07_cnic_front_photo", + "image": "07_cnic_front_photo.jpg", + "doc_type": "cnic", + "tags": [ + "cnic_front", + "photo" + ], + "expected": { + "name": "Zain Raza", + "father_name": "Fahad Butt", + "gender": "M", + "country_of_stay": "Pakistan", + "cnic_number": "49298-4162294-9", + "date_of_birth": "19-12-1986", + "date_of_issue": "03-10-2020", + "date_of_expiry": "03-10-2025" + } + }, + { + "id": "08_cnic_front_low_res", + "image": "08_cnic_front_low_res.jpg", + "doc_type": "cnic", + "tags": [ + "cnic_front", + "low_res" + ], + "expected": { + "name": "Hassan Hussain", + "father_name": "Muhammad Abbasi", + "gender": "M", + "country_of_stay": "Pakistan", + "cnic_number": "65008-7547517-7", + "date_of_birth": "13-04-2001", + "date_of_issue": "08-12-2025", + "date_of_expiry": "08-12-2030" + } + }, + { + "id": "09_cnic_front_blurry", + "image": "09_cnic_front_blurry.jpg", + "doc_type": "cnic", + "tags": [ + "cnic_front", + "blurry" + ], + "expected": { + "name": "Usman Rehman", + "father_name": "Imran Siddiqui", + "gender": "M", + "country_of_stay": "Pakistan", + "cnic_number": "49318-6032080-7", + "date_of_birth": "30-11-1966", + "date_of_issue": "07-11-2022", + "date_of_expiry": "07-11-2032" + } + }, + { + "id": "10_cnic_front_very_blurry", + "image": "10_cnic_front_very_blurry.jpg", + "doc_type": "cnic", + "tags": [ + "cnic_front", + "very_blurry", + "unreadable" + ], + "expected": { + "name": "Ali Nawaz", + "father_name": "Usman Nawaz", + "gender": "M", + "country_of_stay": "Pakistan", + "cnic_number": "22264-6861642-5", + "date_of_birth": "03-09-1965", + "date_of_issue": "28-11-2017", + "date_of_expiry": "28-11-2024" + } + }, + { + "id": "11_cnic_front_occluded_name", + "image": "11_cnic_front_occluded_name.jpg", + "doc_type": "cnic", + "tags": [ + "cnic_front", + "occluded_name" + ], + "expected": { + "father_name": "Zain Shah", + "gender": "M", + "country_of_stay": "Pakistan", + "cnic_number": "38771-8841330-1", + "date_of_birth": "28-09-1990", + "date_of_issue": "30-05-2019", + "date_of_expiry": "30-05-2026" + }, + "expect_missing": [ + "name" + ] + }, + { + "id": "12_cnic_back_clean", + "image": "12_cnic_back_clean.jpg", + "doc_type": "cnic", + "tags": [ + "cnic_back", + "clean" + ], + "expected": { + "cnic_number": "72052-5898319-5" + } + }, + { + "id": "13_cnic_back_photo", + "image": "13_cnic_back_photo.jpg", + "doc_type": "cnic", + "tags": [ + "cnic_back", + "photo" + ], + "expected": { + "cnic_number": "54948-2141621-1" + } + }, + { + "id": "14_matric_clean", + "image": "14_matric_clean.jpg", + "doc_type": "matric", + "tags": [ + "matric", + "clean" + ], + "expected": { + "board": "FBISE", + "serial_number": "2618161", + "roll_number": "4046427", + "registration_number": "5393655803", + "certificate_number": "232092060/198429", + "group": "GENERAL", + "year": "2015", + "grade": "B", + "student_name": "RABIA HUSSAIN", + "father_name": "BILAL SIDDIQUI", + "date_of_birth": "28-04-1997", + "institute": "FG GIRLS COLLEGE, CANTT, PESHAWAR", + "max_marks": "1100", + "total_marks": "651", + "subjects": [ + [ + "ENGLISH (COMPULSORY)", + 150, + 80 + ], + [ + "URDU (COMPULSORY)", + 150, + 93 + ], + [ + "ISLAMIYAT (COMPULSORY)", + 100, + 56 + ], + [ + "PAKISTAN STUDIES", + 100, + 44 + ], + [ + "MATHEMATICS", + 150, + 61 + ], + [ + "PHYSICS", + 150, + 112 + ], + [ + "CHEMISTRY", + 150, + 92 + ], + [ + "BIOLOGY", + 150, + 113 + ] + ] + } + }, + { + "id": "15_matric_rotated90", + "image": "15_matric_rotated90.jpg", + "doc_type": "matric", + "tags": [ + "matric", + "rotated90" + ], + "expected": { + "board": "FBISE", + "serial_number": "5220771", + "roll_number": "6627130", + "registration_number": "8803163805", + "certificate_number": "161880504/871109", + "group": "SCIENCE", + "year": "2020", + "grade": "C", + "student_name": "IMRAN AHMED", + "father_name": "HASSAN SIDDIQUI", + "date_of_birth": "23-10-2004", + "institute": "GOVT BOYS COLLEGE, MALL ROAD, LAHORE", + "max_marks": "1100", + "total_marks": "853", + "subjects": [ + [ + "ENGLISH (COMPULSORY)", + 150, + 137 + ], + [ + "URDU (COMPULSORY)", + 150, + 87 + ], + [ + "ISLAMIYAT (COMPULSORY)", + 100, + 78 + ], + [ + "PAKISTAN STUDIES", + 100, + 69 + ], + [ + "MATHEMATICS", + 150, + 114 + ], + [ + "PHYSICS", + 150, + 146 + ], + [ + "CHEMISTRY", + 150, + 75 + ], + [ + "BIOLOGY", + 150, + 147 + ] + ] + } + }, + { + "id": "16_matric_photo", + "image": "16_matric_photo.jpg", + "doc_type": "matric", + "tags": [ + "matric", + "photo" + ], + "expected": { + "board": "FBISE", + "serial_number": "3058316", + "roll_number": "2084458", + "registration_number": "1160282720", + "certificate_number": "661120183/471238", + "group": "SCIENCE", + "year": "2017", + "grade": "B", + "student_name": "FATIMA QURESHI", + "father_name": "HASSAN SIDDIQUI", + "date_of_birth": "21-06-2002", + "institute": "FG GIRLS COLLEGE, CANTT, PESHAWAR", + "max_marks": "1100", + "total_marks": "688", + "subjects": [ + [ + "ENGLISH (COMPULSORY)", + 150, + 134 + ], + [ + "URDU (COMPULSORY)", + 150, + 62 + ], + [ + "ISLAMIYAT (COMPULSORY)", + 100, + 74 + ], + [ + "PAKISTAN STUDIES", + 100, + 61 + ], + [ + "MATHEMATICS", + 150, + 85 + ], + [ + "PHYSICS", + 150, + 131 + ], + [ + "CHEMISTRY", + 150, + 67 + ], + [ + "BIOLOGY", + 150, + 74 + ] + ] + } + }, + { + "id": "17_matric_low_res", + "image": "17_matric_low_res.jpg", + "doc_type": "matric", + "tags": [ + "matric", + "low_res" + ], + "expected": { + "board": "FBISE", + "serial_number": "4829496", + "roll_number": "491949", + "registration_number": "5366245723", + "certificate_number": "681055366/602103", + "group": "GENERAL", + "year": "2019", + "grade": "C", + "student_name": "AMNA BUTT", + "father_name": "ALI SHAH", + "date_of_birth": "30-10-2001", + "institute": "GOVT BOYS COLLEGE, MALL ROAD, LAHORE", + "max_marks": "1100", + "total_marks": "876", + "subjects": [ + [ + "ENGLISH (COMPULSORY)", + 150, + 129 + ], + [ + "URDU (COMPULSORY)", + 150, + 63 + ], + [ + "ISLAMIYAT (COMPULSORY)", + 100, + 82 + ], + [ + "PAKISTAN STUDIES", + 100, + 85 + ], + [ + "MATHEMATICS", + 150, + 129 + ], + [ + "PHYSICS", + 150, + 147 + ], + [ + "CHEMISTRY", + 150, + 131 + ], + [ + "BIOLOGY", + 150, + 110 + ] + ] + } + }, + { + "id": "18_intermediate_clean", + "image": "18_intermediate_clean.jpg", + "doc_type": "intermediate", + "tags": [ + "intermediate", + "clean" + ], + "expected": { + "board": "FBISE", + "serial_number": "1191416", + "roll_number": "4173617", + "registration_number": "3020975659", + "certificate_number": "338197458/812133", + "group": "PRE-ENGINEERING", + "year": "2020", + "grade": "A", + "student_name": "ALI SALEEM", + "father_name": "MUHAMMAD NAWAZ", + "date_of_birth": "26-04-2002", + "institute": "GOVT BOYS COLLEGE, MALL ROAD, LAHORE", + "max_marks": "1100", + "total_marks": "864", + "subjects": [ + [ + "ENGLISH (COMPULSORY)", + 200, + 184 + ], + [ + "URDU (COMPULSORY)", + 200, + 113 + ], + [ + "ISLAMIC EDUCATION", + 50, + 30 + ], + [ + "PAKISTAN STUDIES", + 50, + 40 + ], + [ + "MATHEMATICS", + 200, + 146 + ], + [ + "PHYSICS", + 200, + 176 + ], + [ + "CHEMISTRY", + 200, + 175 + ] + ] + } + }, + { + "id": "19_intermediate_skewed", + "image": "19_intermediate_skewed.jpg", + "doc_type": "intermediate", + "tags": [ + "intermediate", + "skewed" + ], + "expected": { + "board": "FBISE", + "serial_number": "6237740", + "roll_number": "3252477", + "registration_number": "2763248246", + "certificate_number": "620610912/105569", + "group": "PRE-ENGINEERING", + "year": "2025", + "grade": "B", + "student_name": "HAMZA MALIK", + "father_name": "HAMZA ABBASI", + "date_of_birth": "04-04-2010", + "institute": "MODEL HIGH SCHOOL, SECTOR G-7, ISLAMABAD", + "max_marks": "1100", + "total_marks": "887", + "subjects": [ + [ + "ENGLISH (COMPULSORY)", + 200, + 138 + ], + [ + "URDU (COMPULSORY)", + 200, + 161 + ], + [ + "ISLAMIC EDUCATION", + 50, + 34 + ], + [ + "PAKISTAN STUDIES", + 50, + 37 + ], + [ + "MATHEMATICS", + 200, + 183 + ], + [ + "PHYSICS", + 200, + 140 + ], + [ + "CHEMISTRY", + 200, + 194 + ] + ] + } + }, + { + "id": "20_intermediate_photo", + "image": "20_intermediate_photo.jpg", + "doc_type": "intermediate", + "tags": [ + "intermediate", + "photo" + ], + "expected": { + "board": "FBISE", + "serial_number": "7598076", + "roll_number": "6485104", + "registration_number": "3842279313", + "certificate_number": "646278472/210018", + "group": "PRE-MEDICAL", + "year": "2024", + "grade": "A1", + "student_name": "HIRA RAZA", + "father_name": "AHMED KHAN", + "date_of_birth": "02-04-2006", + "institute": "FG GIRLS COLLEGE, CANTT, PESHAWAR", + "max_marks": "1100", + "total_marks": "791", + "subjects": [ + [ + "ENGLISH (COMPULSORY)", + 200, + 148 + ], + [ + "URDU (COMPULSORY)", + 200, + 107 + ], + [ + "ISLAMIC EDUCATION", + 50, + 47 + ], + [ + "PAKISTAN STUDIES", + 50, + 47 + ], + [ + "MATHEMATICS", + 200, + 169 + ], + [ + "PHYSICS", + 200, + 154 + ], + [ + "CHEMISTRY", + 200, + 119 + ] + ] + } + }, + { + "id": "21_degree_clean", + "image": "21_degree_clean.jpg", + "doc_type": "degree", + "tags": [ + "degree", + "clean" + ], + "expected": { + "institution": "Sample Institute of Engineering and Technology", + "student_name": "USMAN SHAH", + "father_name": "BILAL QURESHI", + "degree": "Master of Business Administration", + "serial_number": "297863", + "registration_number": "EX/SP13-BCS-192", + "cgpa": "3.26", + "date_of_issue": "18-07-2017", + "year": "2017" + } + }, + { + "id": "22_degree_photo", + "image": "22_degree_photo.jpg", + "doc_type": "degree", + "tags": [ + "degree", + "photo" + ], + "expected": { + "institution": "Example University of Technology", + "student_name": "HASSAN ABBASI", + "father_name": "FAHAD SHAH", + "degree": "Bachelor of Science in Computer Science", + "serial_number": "251776", + "registration_number": "EX/SP16-BCS-042", + "cgpa": "2.06", + "date_of_issue": "03-10-2020", + "year": "2020" + } + } + ] +} \ No newline at end of file diff --git a/tests/test_certificate_parser.py b/tests/test_certificate_parser.py new file mode 100644 index 0000000..f9ed486 --- /dev/null +++ b/tests/test_certificate_parser.py @@ -0,0 +1,239 @@ +"""Matric / Intermediate certificate parsing on constructed OCR layouts.""" + +import pytest + +from conftest import box, make_page +from docling_pk.parsers import certificate as cert +from docling_pk.schema import FieldStatus + +SUBJECTS = [("ENGLISH (COMPULSORY)", "150", "118"), ("URDU (COMPULSORY)", "150", "086"), ("MATHEMATICS", "150", "102")] + + +def fbise_boxes( + level="matric", + total_ob="306", + words="Three Hundred Six", + subjects=SUBJECTS, + total_max="450", + title_board=True, + dob=True, + grade="A", +): + title = ( + "SECONDARY SCHOOL CERTIFICATE EXAMINATION" + if level == "matric" + else "HIGHER SECONDARY SCHOOL CERTIFICATE EXAMINATION" + ) + b = [] + if title_board: + b.append(box("FEDERAL BOARD OF INTERMEDIATE AND SECONDARY EDUCATION", 100, 40, w=1000, h=36)) + b += [ + box("ISLAMABAD", 520, 90, h=32), + box("1234567", 240, 150, h=34), # serial printed raised above its label line + box("Serial No.", 90, 185, h=26), + box("Certificate No.", 760, 185, h=26), + box("123456789/012345", 950, 185, h=26), + box("Roll No.", 90, 250, h=26), + box("654301", 228, 250, h=26), + box("Registration No.", 760, 250, h=26), + box("1234500001", 970, 250, h=26), + box("Group", 90, 315, h=26), + box("SCIENCE", 230, 315, h=26), + box("Attempt(s)", 760, 315, h=26), + box("FIRST", 970, 315, h=26), + box(title, 300, 400, w=640, h=26), + box("ANNUAL 2019", 520, 440, h=26), + box("Certified that", 90, 500, h=26), + box("ALI HASSAN KHAN", 260, 500, h=26), + box("Son / Daughter of", 90, 550, h=26), + box("TARIQ MAHMOOD", 345, 550, h=26), + ] + if dob: + b.append( + box("whose date of birth is 14-02-2004 (Fourteenth February, Two Thousand and Four)", 90, 600, w=950, h=26) + ) + b += [ + box( + "at the examination held in the month(s) of March / April as a Regular Candidate from", + 90, + 650, + w=1000, + h=26, + ), + box("MODEL SCHOOL, SECTOR F-8, ISLAMABAD", 90, 700, w=600, h=26), + box(f"as per statement of marks given below and has obtained grade {grade}", 90, 750, w=900, h=26), + box("SUBJECT-WISE STATEMENT OF MARKS", 400, 800, w=420, h=26), + box("S.No.", 90, 860, h=24), + box("Subject(s)", 400, 860, h=24), + box("Maximum", 780, 870, h=22), + box("Obtained", 980, 870, h=22), + ] + y = 920 + for i, (s, mx, ob) in enumerate(subjects, 1): + b += [box(str(i), 100, y, h=22), box(s, 180, y, h=22), box(mx, 800, y, h=22), box(ob, 1000, y, h=22)] + y += 46 + b += [box("TOTAL", 420, y, h=26), box(total_max, 795, y, h=26), box(total_ob, 1000, y, h=26)] + y += 80 + b += [box("(Marks in words)", 90, y, h=26), box(words, 320, y, w=420, h=26)] + return b + + +def test_full_fbise_matric(): + fields, warnings, meta, rows = cert.extract(make_page(fbise_boxes(), size=(1600, 1240)), level_hint="matric") + assert meta == {"board": "FBISE", "level": "matric"} + expect = { + "student_name": "ALI HASSAN KHAN", + "father_name": "TARIQ MAHMOOD", + "date_of_birth": "14-02-2004", + "roll_number": "654301", + "registration_number": "1234500001", + "serial_number": "1234567", + "certificate_number": "123456789/012345", + "board": "FBISE", + "exam": "SSC", + "year": "2019", + "session": "ANNUAL", + "group": "SCIENCE", + "grade": "A", + "total_marks": "306", + "max_marks": "450", + } + for k, v in expect.items(): + assert fields[k].value == v, k + assert fields["institute"].value.startswith("MODEL SCHOOL") + assert rows == [ + {"subject": "ENGLISH (COMPULSORY)", "max_marks": 150, "obtained_marks": 118}, + {"subject": "URDU (COMPULSORY)", "max_marks": 150, "obtained_marks": 86}, + {"subject": "MATHEMATICS", "max_marks": 150, "obtained_marks": 102}, + ] + assert fields["total_marks"].confidence >= 0.99 # three sources agree + assert not warnings + assert list(fields) == cert.FIELD_NAMES + + +def test_title_overrides_hint(): + fields, warnings, meta, _ = cert.extract(make_page(fbise_boxes(level="intermediate")), level_hint="matric") + assert meta["level"] == "intermediate" + assert fields["exam"].value == "HSSC" + assert any("parsed as intermediate" in w for w in warnings) + + +def test_disagreeing_totals_flagged(): + fields, warnings, _, _ = cert.extract(make_page(fbise_boxes(total_ob="307", words="Three Hundred Eight"))) + assert fields["total_marks"].status == FieldStatus.LOW_CONFIDENCE + assert any("could not be confirmed" in w for w in warnings) + assert any("failed validation" in w for w in warnings) + + +def test_two_sources_agree_without_words(): + boxes = [b for b in fbise_boxes() if b.text not in ("(Marks in words)", "Three Hundred Six")] + fields, _, _, _ = cert.extract(make_page(boxes)) + assert fields["total_marks"].value == "306" and fields["total_marks"].confidence >= 0.9 + + +def test_max_marks_disagreement_is_low_confidence(): + fields, _, _, _ = cert.extract(make_page(fbise_boxes(total_max="9100"))) + assert fields["max_marks"].value == "9100" + assert fields["max_marks"].status == FieldStatus.LOW_CONFIDENCE + + +def test_reread_repairs_bad_cell(): + subjects = [ + ("ENGLISH (COMPULSORY)", "150", "118"), + ("URDU (COMPULSORY)", "150", "O86"), + ("MATHEMATICS", "150", "102"), + ] + page = make_page(fbise_boxes(subjects=subjects), rereads={"*": ("", 0.0)}) + fields, _, _, rows = cert.extract(page) + assert rows[1]["obtained_marks"] == 86 # O read as 0 + assert fields["total_marks"].value == "306" + + +def test_board_fallback_from_certificate_number_format(): + fields, _, meta, _ = cert.extract(make_page(fbise_boxes(title_board=False))) + assert meta["board"] == "FBISE" and fields["board"].confidence == pytest.approx(0.7) + + +def test_missing_dob_and_grade(): + fields, _, _, _ = cert.extract(make_page(fbise_boxes(dob=False, grade=""))) + assert fields["date_of_birth"].value is None + assert fields["grade"].value is None and "grade" in fields["grade"].reason + + +def test_empty_page(): + fields, warnings, meta, rows = cert.extract(make_page([box("nothing useful here", 10, 10)])) + assert rows == [] and "marks table not found" in warnings + assert fields["board"].value is None + assert fields["total_marks"].value is None and fields["max_marks"].value is None + assert fields["year"].value is None + + +def test_garbled_name_label_uses_line_above_father(): + boxes = [b for b in fbise_boxes() if b.text != "Certified that"] + boxes.append(box("eedha", 90, 500, h=26)) + fields, _, _, _ = cert.extract(make_page(boxes)) + assert fields["student_name"].value == "ALI HASSAN KHAN" + assert fields["student_name"].status == FieldStatus.LOW_CONFIDENCE + + +def test_group_with_glued_noise(): + boxes = [box("pSCIENCE", b.x0, b.y0, b.width, b.height) if b.text == "SCIENCE" else b for b in fbise_boxes()] + fields, _, _, _ = cert.extract(make_page(boxes)) + assert fields["group"].value == "SCIENCE" + + +def test_numeric_reread_disagreement(): + page = make_page(fbise_boxes(), rereads={"*": ("999999", 0.99)}) + page.boxes = [box(b.text, b.x0, b.y0, b.width, b.height, conf=0.5) for b in page.boxes] + fields, _, _, _ = cert.extract(page) + assert fields["roll_number"].status == FieldStatus.LOW_CONFIDENCE + + +@pytest.mark.parametrize( + "text,board", + [ + ("FEDERAL BOARD OF INTERMEDIATE AND SECONDARY EDUCATION", "FBISE"), + ("BOARD OF INTERMEDIATE AND SECONDARY EDUCATION, LAHORE", "BISE Lahore"), + ("Board of Intermediate & Secondary Education Dera Ghazi Khan", "BISE Dera Ghazi Khan"), + ("BOARD OF INTERMEDIATE AND SECONDARY EDUCATION", "BISE"), + ("BOARD OF INTERMEDIATE EDUCATION KARACHI", "BIEK Karachi"), + ("Aga Khan University Examination Board", "AKU-EB"), + ("some other text", None), + ], +) +def test_detect_board(text, board): + assert cert.detect_board(text) == board + + +@pytest.mark.parametrize( + "text,level", + [ + ("SECONDARY SCHOOL CERTIFICATE EXAMINATION", "matric"), + ("HIGHER SECONDARY SCHOOL CERTIFICATE EXAMINATION", "intermediate"), + ("Intermediate Part-II Examination", "intermediate"), + ("hello", None), + ], +) +def test_detect_level(text, level): + assert cert.detect_level(text) == level + + +def test_non_fbise_board_warns(): + boxes = [ + box("BOARD OF INTERMEDIATE AND SECONDARY EDUCATION, LAHORE", b.x0, b.y0, b.width, b.height) + if b.text.startswith("FEDERAL") + else b + for b in fbise_boxes() + ] + _, warnings, meta, _ = cert.extract(make_page(boxes)) + assert meta["board"] == "BISE Lahore" + assert any("not been verified" in w for w in warnings) + + +def test_assign_columns_without_headers(): + nums = [box("150", 800, 10), box("118", 1000, 10)] + assert cert._assign_columns(nums, None, None) == (150, 118) + assert cert._assign_columns([box("150", 800, 10)], None, None) == (None, None) + assert cert._assign_columns([], 800, 1000) == (None, None) + assert cert._assign_columns([box("150", 800, 10)], 800, 1000) == (150, None) + assert cert._assign_columns([box("118", 1000, 10)], 800, 1000) == (None, 118) diff --git a/tests/test_cli.py b/tests/test_cli.py deleted file mode 100644 index 2f2b045..0000000 --- a/tests/test_cli.py +++ /dev/null @@ -1,66 +0,0 @@ -""" -Tests for the CLI skeleton: command registration, placeholder JSON -shape, and exit code behavior. Real extraction logic is not tested -here since it is not implemented yet, only the CLI's own contract. -""" - -import json -import pytest -from typer.testing import CliRunner - -from cli import app - -runner = CliRunner() - -COMMANDS = ["cnic", "matric", "intermediate", "degree"] - - -def _combined_output(result) -> str: - """ - Returns stdout plus stderr if available. click's CliRunner does not - consistently capture stderr separately across versions: older - versions merge stdout+stderr into one stream (result.output holds - both, result.stderr raises ValueError), newer versions capture them - separately. Checking both this way works either way, instead of - assuming one specific click version's behavior. - """ - combined = result.output - if result.stderr_bytes is not None: - combined += result.stderr - return combined - - -@pytest.mark.parametrize("command", COMMANDS) -def test_command_registered(command): - """Each of the four document commands exists and shows help.""" - result = runner.invoke(app, [command, "--help"]) - assert result.exit_code == 0 - - -@pytest.mark.parametrize("command", COMMANDS) -def test_valid_path_returns_placeholder_json_and_exits_nonzero(command, tmp_path): - """ - A valid but unimplemented command returns placeholder JSON - matching the schema shape, and exits non-zero, since the result - is not real output and callers should not treat it as success. - """ - fake_image = tmp_path / "fake.jpg" - fake_image.write_bytes(b"not a real image, just needs to exist") - - result = runner.invoke(app, [command, str(fake_image)]) - - assert result.exit_code == 1 - output = json.loads(result.stdout) - assert output["document_type"] == command - assert output["fields"] == {} - assert output["confidence"] == 0.0 - assert "not implemented" in output["warnings"][0] - assert output["raw_text"] is None - - -@pytest.mark.parametrize("command", COMMANDS) -def test_invalid_path_exits_with_error_code(command): - """A path that does not exist exits with a distinct error code, not the placeholder code.""" - result = runner.invoke(app, [command, "this/path/does/not/exist.jpg"]) - assert result.exit_code == 2 - assert "does not exist" in _combined_output(result) \ No newline at end of file diff --git a/tests/test_cnic.py b/tests/test_cnic.py deleted file mode 100644 index b10aebb..0000000 --- a/tests/test_cnic.py +++ /dev/null @@ -1,79 +0,0 @@ -""" -Test scaffold for CNIC extraction. - -Skeleton only, per this week's scope: structure and fixtures are in -place, but most tests are marked skip until fixture images are added -to tests/fixtures/. This lets the Research/Implementation Engineer's -code land and run against these immediately, without scrambling to -write tests at that point. - -Known discrepancy, flagged here rather than silently worked around: -the clean-scan test below asserts confidence > 0.85 per the Week 2 -guide. Real testing against actual CNIC photos measured name and -father_name at 0.65 and dates at 0.8, reflecting genuine extraction- -method uncertainty (label-adjacency matching, not a made-up number). -Document-level confidence also structurally cannot reach 0.85 while -gender and address remain unreliable by design. This test is written -to the spec as given; whether the 0.85 threshold itself needs -revisiting is a separate, already-raised question for Khadija. -""" - -import pytest -from pathlib import Path - -from docling_pk.preprocessor import preprocess, TooBlurryError -from docling_pk.ocr import run_ocr -from docling_pk.parsers import cnic - -FIXTURES = Path(__file__).parent / "fixtures" - - -def _extract(image_path: Path): - image = preprocess(str(image_path)) - raw_text, _ = run_ocr(image) - return cnic.extract(raw_text) - - -@pytest.mark.skipif(not (FIXTURES / "cnic_clean.jpg").exists(), reason="fixture not added yet") -def test_clean_scan_all_fields_high_confidence(): - """Clean scan: all fields should extract with confidence > 0.85.""" - fields = _extract(FIXTURES / "cnic_clean.jpg") - for name, result in fields.items(): - assert result.confidence > 0.85, f"{name} confidence {result.confidence} below 0.85" - - -@pytest.mark.skipif(not (FIXTURES / "cnic_rotated.jpg").exists(), reason="fixture not added yet") -def test_rotated_image_cnic_number_still_extracts(): - """A sideways or upside-down photo should still recover the CNIC number.""" - fields = _extract(FIXTURES / "cnic_rotated.jpg") - assert fields["cnic_number"].value is not None - - -@pytest.mark.skipif(not (FIXTURES / "cnic_obscured_field.jpg").exists(), reason="fixture not added yet") -def test_obscured_field_returns_none_not_crash(): - """A partially obscured field should return None with a warning, not raise.""" - fields = _extract(FIXTURES / "cnic_obscured_field.jpg") - obscured_result = fields["gender"] - assert obscured_result.value is None - assert obscured_result.confidence == 0.0 - - -def test_invalid_path_raises_file_not_found(): - with pytest.raises(FileNotFoundError): - preprocess("this/path/does/not/exist.jpg") - - -def test_invalid_image_raises_value_error(tmp_path): - bad_file = tmp_path / "not_an_image.jpg" - bad_file.write_text("this is not image data") - with pytest.raises(ValueError): - preprocess(str(bad_file)) - - -def test_too_blurry_image_raises_too_blurry_error(): - """ - Not in the original Week 2 spec, added since blur detection is - already implemented and tested against real samples. A blurry - image should raise TooBlurryError, not return garbage output. - """ - pytest.skip("needs a real blurry fixture image, see samples used in manual testing") \ No newline at end of file diff --git a/tests/test_cnic_parser.py b/tests/test_cnic_parser.py new file mode 100644 index 0000000..6d9bef0 --- /dev/null +++ b/tests/test_cnic_parser.py @@ -0,0 +1,230 @@ +"""CNIC parsing on constructed OCR layouts (no OCR models needed).""" + +from conftest import box, cnic_front_boxes, make_page +from docling_pk.parsers import cnic +from docling_pk.schema import FieldResult, FieldStatus + + +def _extract(boxes, rereads=None): + fields, warnings, meta = cnic.extract(make_page(boxes, rereads)) + return fields, warnings, meta + + +def test_clean_front_extracts_every_front_field(): + fields, warnings, meta = _extract(cnic_front_boxes()) + assert meta["side"] == "front" + assert fields["name"].value == "Muhammad Ali" + assert fields["father_name"].value == "Muhammad Akram" + assert fields["gender"].value == "M" + assert fields["country_of_stay"].value == "Pakistan" + assert fields["cnic_number"].value == "35202-1234567-9" + assert fields["date_of_birth"].value == "01-01-1995" + assert fields["date_of_issue"].value == "15-03-2020" + assert fields["date_of_expiry"].value == "14-03-2030" + assert not warnings + for name in cnic.FRONT_FIELDS: + assert fields[name].status == FieldStatus.OK, name + + +def test_back_fields_are_not_applicable_on_front(): + fields, _, _ = _extract(cnic_front_boxes()) + assert fields["address"].status == FieldStatus.NOT_APPLICABLE + assert "back" in fields["address"].reason + + +def test_every_field_key_always_present(): + fields, _, _ = _extract([box("random text", 10, 10)]) + assert list(fields) == cnic.FIELD_NAMES + + +def test_ocr_box_order_does_not_matter(): + """The original parser assumed labels precede values in OCR order.""" + fields, _, _ = _extract(cnic_front_boxes(shuffle_dates=True)) + assert fields["date_of_birth"].value == "01-01-1995" + assert fields["date_of_issue"].value == "15-03-2020" + assert fields["date_of_expiry"].value == "14-03-2030" + assert fields["name"].value == "Muhammad Ali" + + +def test_dates_assigned_chronologically_even_if_layout_scrambled(): + boxes = cnic_front_boxes(dob="14.03.2030", doe="01.01.1995") # values in wrong slots + fields, _, _ = _extract(boxes) + assert fields["date_of_birth"].value == "01-01-1995" + assert fields["date_of_expiry"].value == "14-03-2030" + + +def test_father_label_does_not_steal_name(): + """Old bug: 'Father Name' minus 'Father' left 'Name' as the father's name.""" + fields, _, _ = _extract(cnic_front_boxes()) + assert fields["father_name"].value != "Name" + + +def test_misread_labels_still_match(): + boxes = cnic_front_boxes() + renamed = {"Father Name": "Fathgr Namg", "Gender": "GenUer", "Date of Birth": "Date 0/ 8irth"} + boxes = [box(renamed.get(b.text, b.text), b.x0, b.y0, b.width, b.height, b.confidence) for b in boxes] + fields, _, _ = _extract(boxes) + assert fields["father_name"].value == "Muhammad Akram" + assert fields["gender"].value == "M" + + +def test_cnic_number_separator_misread(): + fields, _, _ = _extract(cnic_front_boxes(number="35202-1234567.9")) + assert fields["cnic_number"].value == "35202-1234567-9" + + +def test_gender_inferred_from_cnic_when_letter_unreadable(): + fields, _, _ = _extract(cnic_front_boxes(gender=None, number="35202-1234567-4")) + g = fields["gender"] + assert g.value == "F" + assert g.status == FieldStatus.LOW_CONFIDENCE + assert "inferred" in g.reason + + +def test_gender_conflict_with_cnic_is_flagged(): + fields, _, _ = _extract(cnic_front_boxes(gender="F", number="35202-1234567-9")) + assert fields["gender"].value == "F" + assert fields["gender"].status == FieldStatus.LOW_CONFIDENCE + assert "disagrees" in fields["gender"].reason + + +def test_matching_gender_raises_confidence(): + fields, _, _ = _extract(cnic_front_boxes()) + assert fields["gender"].confidence > 0.95 + + +def test_reread_agreement_raises_confidence(): + fields, _, _ = _extract(cnic_front_boxes(), rereads={"*": ("35202-1234567-9", 0.9)}) + assert fields["cnic_number"].confidence > 0.99 + + +def test_reread_disagreement_is_flagged(): + fields, _, _ = _extract(cnic_front_boxes(), rereads={"*": ("35202-1234568-9", 0.99)}) + f = fields["cnic_number"] + assert f.status == FieldStatus.LOW_CONFIDENCE + assert "disagree" in f.reason + + +def test_invalid_region_digit_reported_as_invalid(): + fields, _, _ = _extract(cnic_front_boxes(number="05202-1234567-9")) + f = fields["cnic_number"] + assert f.value is None and f.status == FieldStatus.INVALID + assert f.raw == "05202-1234567-9" + + +def test_missing_cnic_number_reason(): + boxes = [b for b in cnic_front_boxes() if b.text != "35202-1234567-9"] + fields, _, _ = _extract(boxes) + assert fields["cnic_number"].value is None + assert "13-digit" in fields["cnic_number"].reason + + +def test_cnic_split_across_boxes(): + boxes = [b for b in cnic_front_boxes() if b.text != "35202-1234567-9"] + boxes += [box("35202-", 345, 605, h=30), box("1234567-9", 460, 605, h=30)] + fields, _, _ = _extract(boxes) + assert fields["cnic_number"].value == "35202-1234567-9" + + +def test_impossible_date_order_warns(): + boxes = cnic_front_boxes(dob="01.01.1995", doi="15.03.2020", doe="15.03.2020") + fields, warnings, _ = _extract(boxes) + # only two distinct dates -> label geometry assignment, then logic check + assert any("not before" in w for w in warnings) + + +def test_two_dates_assigned_by_label_geometry(): + boxes = [b for b in cnic_front_boxes() if b.text != "14.03.2030"] + fields, _, _ = _extract(boxes) + assert fields["date_of_birth"].value == "01-01-1995" + assert fields["date_of_issue"].value == "15-03-2020" + assert fields["date_of_expiry"].value is None + + +def test_husband_name_label(): + boxes = [ + box("Husband Name", b.x0, b.y0, None, b.height) if b.text == "Father Name" else b for b in cnic_front_boxes() + ] + fields, _, _ = _extract(boxes) + assert fields["father_name"].value == "Muhammad Akram" + assert "Husband" in fields["father_name"].reason + + +def test_value_in_same_box_as_label(): + boxes = [b for b in cnic_front_boxes() if b.text not in ("Gender", "M")] + boxes.append(box("Gender M", 335, 470, h=18)) + fields, _, _ = _extract(boxes) + assert fields["gender"].value == "M" + + +def test_detect_side_back(): + boxes = [ + box("35202-1234567-1", 900, 30), + box("موجودہ پتہ : مکان نمبر B-12", 150, 60), + box("تحصیل کامونکی، ضلع گوجرانوالہ", 300, 120), + box("100000000001", 970, 400), + ] + assert cnic.detect_side(boxes) == "back" + + +def test_back_without_urdu_backend_explains(): + boxes = [box("35202-1234567-1", 900, 30), box("موجودہ پتہ مکان نمبر", 150, 60), box("ضلع گوجرانوالہ", 300, 120)] + fields, _, meta = _extract(boxes) + assert meta["side"] == "back" + assert fields["cnic_number"].value == "35202-1234567-1" + assert fields["name"].status == FieldStatus.NOT_APPLICABLE + assert "Urdu" in fields["address"].reason + + +def test_back_with_urdu_backend_reads_address(): + from conftest import FakeBackend + + urdu = FakeBackend( + [ + box("موجودہ پتہ : مکان نمبر B-12، محلہ نوری", 150, 60, w=700), + box("کینٹ، تحصیل کامونکی، ضلع گوجرانوالہ", 350, 120, w=500), + box("مستقل پتہ : مکان نمبر B-12، محلہ نوری", 150, 280, w=700), + box("کینٹ، تحصیل کامونکی", 350, 340, w=400), + box("گمشدہ کارڈ ملنے پر قریبی لیٹر بکس میں ڈال دیں", 200, 600, w=800), + ] + ) + page = make_page([box("35202-1234567-1", 900, 30), box("مکان نمبر", 150, 60), box("ضلع گوجرانوالہ", 300, 120)]) + fields, _, _ = cnic.extract(page, urdu_backend=urdu) + addr = fields["address"] + assert addr.value and "مکان نمبر B-12" in addr.value and "گوجرانوالہ" in addr.value + assert "موجودہ" not in addr.value + assert addr.status == FieldStatus.LOW_CONFIDENCE + assert fields["permanent_address"].value and "کامونکی" in fields["permanent_address"].value + assert "لیٹر" not in (addr.value + fields["permanent_address"].value) + + +def test_unknown_document_warns(): + fields, warnings, meta = _extract([box("hello world", 10, 10)]) + assert meta["side"] == "unknown" + assert warnings + + +def test_merge_front_and_back(): + front, _, _ = _extract(cnic_front_boxes()) + back = {n: FieldResult.missing("x", status=FieldStatus.NOT_APPLICABLE) for n in cnic.FIELD_NAMES} + back["cnic_number"] = FieldResult("35202-1234567-9", 0.9) + back["address"] = FieldResult("مکان نمبر 1", 0.4, FieldStatus.LOW_CONFIDENCE) + merged = cnic.merge_sides([front, back]) + assert merged["name"].value == "Muhammad Ali" + assert merged["address"].value == "مکان نمبر 1" + assert merged["cnic_number"].confidence > front["cnic_number"].confidence + + +def test_merge_conflicting_numbers_flagged(): + a = {"cnic_number": FieldResult("35202-1234567-9", 0.9)} + b = {"cnic_number": FieldResult("35202-1234567-8", 0.8)} + m = cnic.merge_sides([a, b]) + assert m["cnic_number"].status == FieldStatus.LOW_CONFIDENCE + assert "differ" in m["cnic_number"].reason + + +def test_merge_prefers_higher_confidence_and_found_values(): + a = {"name": FieldResult.missing("x"), "father_name": FieldResult("A", 0.5)} + b = {"name": FieldResult("Ali", 0.7), "father_name": FieldResult("B", 0.9)} + m = cnic.merge_sides([a, b]) + assert m["name"].value == "Ali" and m["father_name"].value == "B" diff --git a/tests/test_degree_urdu_layout.py b/tests/test_degree_urdu_layout.py new file mode 100644 index 0000000..d6ccba9 --- /dev/null +++ b/tests/test_degree_urdu_layout.py @@ -0,0 +1,150 @@ +"""Degree/transcript parser, Urdu post-processing and layout helpers.""" + +import pytest + +from conftest import FakeBackend, box, make_page +from docling_pk import layout +from docling_pk.parsers import degree +from docling_pk.schema import FieldStatus +from docling_pk.urdu import correct_address_tokens, normalize_urdu + + +def lines_to_boxes(lines, conf=0.97): + return [box(t, 60, 50 + i * 50, w=len(t) * 14, h=28, conf=conf) for i, t in enumerate(lines)] + + +DEGREE_LINES = [ + "Serial 3o: 100200 Registration 2o: UNI/SP22-BCS-001/ISB", + "COMSATS Unibersity Islamabad", + "ALI HASSAN KHAN s/o TARIQ MAHMOOD", + "Of", + "Lahore Campus", + "has been conferred upon the degree of", + "Bachelor of Science in Artificial Intelligence", + "Date of Issuance: 20th July 2026", +] + +TRANSCRIPT_LINES = [ + "Example University of Technology", + "TRANSCRIPT", + "Date: 20-Jul-2026", + "Certified that SANA KHAN d/o IMRAN KHAN Registration No. EX/FA20-", + "BCS-007 Department of Computer Science has completed", + "examinations for the degree of Bachelor of Science in Computer Science , on 6th July 2026", + "Date of Birth : 14-Feb-2004", + "Course Code Semester Credit Hours", + "CGPA: 3.41 First Division", +] + + +class TestDegree: + def test_degree_certificate(self): + fields, warnings, meta = degree.extract(make_page(lines_to_boxes(DEGREE_LINES))) + assert meta["variant"] == "degree" + assert fields["student_name"].value == "ALI HASSAN KHAN" + assert fields["father_name"].value == "TARIQ MAHMOOD" + assert fields["institution"].value == "COMSATS University Islamabad" + assert fields["degree"].value == "Bachelor of Science in Artificial Intelligence" + assert fields["campus"].value == "Lahore" + assert fields["registration_number"].value == "UNI/SP22-BCS-001/ISB" + assert fields["serial_number"].value == "100200" + assert fields["date_of_issue"].value == "20-07-2026" + assert fields["year"].value == "2026" + assert fields["cgpa"].value is None + assert list(fields) == degree.FIELD_NAMES + + def test_transcript_with_wrapped_registration(self): + fields, _, meta = degree.extract(make_page(lines_to_boxes(TRANSCRIPT_LINES))) + assert meta["variant"] == "transcript" + assert fields["student_name"].value == "SANA KHAN" + assert fields["father_name"].value == "IMRAN KHAN" + assert fields["registration_number"].value == "EX/FA20-BCS-007" + assert fields["institution"].value == "Example University of Technology" + assert fields["cgpa"].value == "3.41" + assert fields["division"].value == "First Division" + assert fields["date_of_birth"].value == "14-02-2004" + assert fields["date_of_issue"].value == "20-07-2026" + + def test_nothing_found(self): + fields, _, _ = degree.extract(make_page([box("random", 10, 10)])) + assert all(f.value is None for f in fields.values()) + assert "s/o" in fields["student_name"].reason + + def test_year_fallback_without_issue_date(self): + fields, _, _ = degree.extract(make_page(lines_to_boxes(["Example University of Technology", "class of 2019"]))) + assert fields["year"].value == "2019" and fields["year"].status == FieldStatus.LOW_CONFIDENCE + + def test_registration_verifier_disagreement(self): + page = make_page(lines_to_boxes(DEGREE_LINES), rereads={"*": ("XXXX/SP22", 0.9)}) + page.verifier = FakeBackend(rereads={"*": ("XXXX/SP22", 0.9)}) + fields, _, _ = degree.extract(page) + assert fields["registration_number"].status == FieldStatus.LOW_CONFIDENCE + + def test_registration_verifier_agreement(self): + page = make_page(lines_to_boxes(DEGREE_LINES)) + page.verifier = FakeBackend(rereads={"*": ("Registration No: UNI/SP22-BCS-001/ISB", 0.9)}) + fields, _, _ = degree.extract(page) + assert fields["registration_number"].status == FieldStatus.OK + + @pytest.mark.parametrize( + "line,degree_text", + [ + ("BS (Hons) in Computer Science", "BS (Hons) in Computer Science"), + ("MBA Finance", "MBA Finance"), + ("AHMED s/o MAHMOOD BASHIR", None), # names starting with MA/BA/BS are not degrees + ], + ) + def test_degree_abbreviations(self, line, degree_text): + m = degree.DEGREE.search(line) + assert (m.group(1).strip() if m else None) == degree_text + + @pytest.mark.parametrize("text", ["Uniuersity", "Unibersity", "Universlty"]) + def test_fix_common_words(self, text): + assert degree.fix_common_words(f"COMSATS {text} Islamabad") == "COMSATS University Islamabad" + + +class TestUrdu: + def test_normalize(self): + assert normalize_urdu("محلّه نوري") == "محلہ نوری" + assert normalize_urdu("كراچی، ") == "کراچی،" + + def test_corrections(self): + assert correct_address_tokens("خصیل کامونکی ضع گوجرانوالا") == "تحصیل کامونکی ضلع گوجرانوالہ" + + def test_leaves_numbers_latin_and_unknown_words(self): + assert correct_address_tokens("مکان نمبر B-12 گلی 4") == "مکان نمبر B-12 گلی 4" + + def test_does_not_invent_place_names_from_noise(self): + assert correct_address_tokens("ززز") == "ززز" + + +class TestLayout: + def test_find_label_fuzzy_and_exclude(self): + boxes = [box("Name", 10, 10), box("Fathgr Namg", 10, 100)] + assert layout.find_label(boxes, "Father Name").text == "Fathgr Namg" + assert layout.find_label(boxes, "Name", exclude=["Father Name"]).text == "Name" + assert layout.find_label(boxes, "Date of Birth") is None + + def test_find_label_prefix(self): + assert layout.find_label([box("Gender M", 0, 0)], "Gender").text == "Gender M" + + def test_label_score_empty(self): + assert layout.label_score("123", "Name") == 0.0 + assert layout.find_label([box("1234", 0, 0)], "Name") is None + + def test_group_lines_and_reading_order(self): + boxes = [box("world", 200, 12), box("hello", 10, 10), box("next", 10, 60)] + assert layout.reading_order_text(boxes) == "hello world\nnext" + + def test_below_and_right(self): + label = box("Name", 100, 100, h=20) + below = box("Ali Khan", 105, 130, h=30) + far = box("Other", 105, 400, h=30) + right = box("value", 200, 102, h=20) + assert layout.boxes_below([label, below, far, right], label) == [below] + assert layout.boxes_right_of([label, below, right], label) == [right] + + def test_ascii_ratio(self): + assert layout.ascii_ratio("abc") == 1.0 + assert layout.ascii_ratio("") == 0.0 + assert layout.ascii_ratio("مکان") == 0.0 diff --git a/tests/test_integration_ocr.py b/tests/test_integration_ocr.py new file mode 100644 index 0000000..742e5af --- /dev/null +++ b/tests/test_integration_ocr.py @@ -0,0 +1,164 @@ +""" +End-to-end tests: real OCR (RapidOCR) on the synthetic fixtures. + +Covers the fixture categories the project brief requires: clean scans, +rotated images, low-resolution photos and partially obscured fields, plus an +unreadable image and PDF input. Marked ``ocr``: the first run downloads the +small ONNX models (~15 MB). Deselect with ``pytest -m "not ocr"``. + +Two kinds of assertions: + +* **Correctness** on fields the brief singles out (CNIC number, dates, marks). +* **Safety**: a field reported with status ``ok`` must never be wrong. A + missing value is acceptable on a degraded image; a confident wrong one is + not, because downstream KYC/admissions systems auto-accept ``ok`` fields. +""" + +import json +import re +from pathlib import Path + +import pytest + +from docling_pk import extract + +pytestmark = pytest.mark.ocr +pytest.importorskip("rapidocr") + +SYNTH = Path(__file__).parent / "fixtures" / "synthetic" +CASES = json.loads((SYNTH / "labels.json").read_text(encoding="utf-8"))["cases"] +BY_ID = {c["id"]: c for c in CASES} + + +def norm(v): + return None if v is None else re.sub(r"[\s,.]+", " ", str(v)).strip().upper() + + +def run(case_id, **kw): + case = BY_ID[case_id] + kw.setdefault("verify", False) + kw.setdefault("urdu", False) + return case, extract(str(SYNTH / case["image"]), case["doc_type"], backend="rapidocr", **kw) + + +def assert_no_confident_errors(case, result): + for name, expected in case["expected"].items(): + if name == "subjects": + continue + f = result.fields[name] + if f.status.value == "ok": + assert norm(f.value) == norm(expected), f"{case['id']}.{name}: ok-status value {f.value!r} != {expected!r}" + + +@pytest.mark.parametrize("case_id", [c["id"] for c in CASES]) +def test_no_confident_errors_on_any_fixture(case_id): + case, result = run(case_id) + assert_no_confident_errors(case, result) + + +@pytest.mark.parametrize( + "case_id", + [ + "00_cnic_front_clean", + "02_cnic_front_rotated90", + "03_cnic_front_rotated270", + "04_cnic_front_upside_down", + "06_cnic_front_photo", + "08_cnic_front_low_res", + "09_cnic_front_blurry", + ], +) +def test_cnic_front_key_fields(case_id): + case, r = run(case_id) + exp = case["expected"] + assert r.document_type == "cnic" + for name in ("cnic_number", "date_of_birth", "date_of_issue", "date_of_expiry"): + assert r.fields[name].value == exp[name], name + assert r.confidence > 0.75 + + +def test_rotation_is_reported(): + _, r = run("02_cnic_front_rotated90") + assert r.metadata["pages"][0]["rotation_degrees"] in (90, 270) + + +def test_obscured_name_returns_none_with_warning(): + case, r = run("11_cnic_front_occluded_name") + assert r.fields["name"].value is None + assert any(w.startswith("name") for w in r.warnings) + assert r.fields["cnic_number"].value == case["expected"]["cnic_number"] + + +def test_unreadable_image_degrades_gracefully(): + case, r = run("10_cnic_front_very_blurry") + assert any("blurry" in w for w in r.warnings) + assert r.confidence < 0.5 + assert_no_confident_errors(case, r) + + +def test_cnic_back(): + case, r = run("12_cnic_back_clean") + assert r.metadata["sides"] == ["back"] + assert r.fields["cnic_number"].value == case["expected"]["cnic_number"] + assert r.fields["name"].status.value == "not_applicable" + + +@pytest.mark.parametrize( + "case_id", ["14_matric_clean", "15_matric_rotated90", "18_intermediate_clean", "20_intermediate_photo"] +) +def test_certificate_marks(case_id): + case, r = run(case_id) + exp = case["expected"] + assert r.document_type == case["doc_type"] + assert r.fields["total_marks"].value == exp["total_marks"] + assert r.fields["roll_number"].value == exp["roll_number"] + rows = [(s["subject"], s["max_marks"], s["obtained_marks"]) for s in r.tables["subjects"]] + assert rows == [tuple(x) for x in exp["subjects"]] + + +def test_degree(): + case, r = run("21_degree_clean") + for name in ("student_name", "father_name", "degree", "institution", "cgpa"): + assert norm(r.fields[name].value) == norm(case["expected"][name]), name + + +def test_auto_document_type(): + for case_id, expected in [ + ("00_cnic_front_clean", "cnic"), + ("14_matric_clean", "matric"), + ("18_intermediate_clean", "intermediate"), + ("21_degree_clean", "degree"), + ]: + r = extract(str(SYNTH / BY_ID[case_id]["image"]), backend="rapidocr", verify=False, urdu=False) + assert r.document_type == expected, case_id + + +def test_pdf_input(tmp_path): + fitz = pytest.importorskip("fitz") + doc = fitz.open() + page = doc.new_page(width=600, height=380) + page.insert_image(page.rect, filename=str(SYNTH / BY_ID["00_cnic_front_clean"]["image"])) + pdf = tmp_path / "cnic.pdf" + doc.save(str(pdf)) + r = extract(str(pdf), "cnic", backend="rapidocr", verify=False, urdu=False) + assert r.fields["cnic_number"].value == BY_ID["00_cnic_front_clean"]["expected"]["cnic_number"] + + +def test_brief_contract_shape(): + """The exact JSON shape promised in the project brief.""" + _, r = run("00_cnic_front_clean") + d = json.loads(r.to_json()) + assert set(d) >= {"document_type", "fields", "confidence", "warnings"} + for key in ( + "name", + "father_name", + "cnic_number", + "date_of_birth", + "date_of_issue", + "date_of_expiry", + "gender", + "address", + ): + assert {"value", "confidence"} <= set(d["fields"][key]) + assert 0.0 <= d["confidence"] <= 1.0 + assert re.fullmatch(r"\d{5}-\d{7}-\d", d["fields"]["cnic_number"]["value"]) diff --git a/tests/test_ocr.py b/tests/test_ocr.py deleted file mode 100644 index ace345a..0000000 --- a/tests/test_ocr.py +++ /dev/null @@ -1,33 +0,0 @@ -""" -Tests for ocr.py per the Week 3 Integration Engineer spec: returns a -non-empty string on a valid image, returns something recognizable on a -known fixture. These require the actual EasyOCR model and a GPU/CPU -capable of running it, so they are skipped in CI until a lightweight -testing strategy (small fixtures, mocked reader) is added, consistent -with the approach already documented in test_smoke.py. -""" - -import pytest -from pathlib import Path - -from docling_pk.preprocessor import preprocess -from docling_pk.ocr import run_ocr - -FIXTURES = Path(__file__).parent / "fixtures" - - -@pytest.mark.skipif(not (FIXTURES / "cnic_clean.jpg").exists(), reason="fixture not added yet, and requires a downloaded EasyOCR model") -def test_returns_non_empty_string_on_valid_image(): - image = preprocess(str(FIXTURES / "cnic_clean.jpg")) - text, confidence = run_ocr(image) - assert isinstance(text, str) - assert len(text) > 0 - assert confidence > 0.0 - - -@pytest.mark.skipif(not (FIXTURES / "cnic_clean.jpg").exists(), reason="fixture not added yet, and requires a downloaded EasyOCR model") -def test_recognizes_known_content_on_fixture(): - """Known fixture should contain the word 'PAKISTAN' somewhere in the raw text.""" - image = preprocess(str(FIXTURES / "cnic_clean.jpg")) - text, _ = run_ocr(image) - assert "PAKISTAN" in text.upper() \ No newline at end of file diff --git a/tests/test_pipeline_extractor_cli.py b/tests/test_pipeline_extractor_cli.py new file mode 100644 index 0000000..81a3b1d --- /dev/null +++ b/tests/test_pipeline_extractor_cli.py @@ -0,0 +1,435 @@ +"""Pipeline (orientation, skew, variants), extract() wiring, CLI and backend wrappers. + +OCR engines are replaced by fakes so these run in milliseconds. +""" + +import json +import math +from types import SimpleNamespace + +import numpy as np +import pytest +from typer.testing import CliRunner + +import docling_pk +from conftest import FakeBackend, box, cnic_front_boxes +from docling_pk import extractor, pipeline +from docling_pk.cli import app +from docling_pk.errors import ImageLoadError, UnsupportedDocumentTypeError +from docling_pk.ocr.base import OCRBackend, TextBox +from docling_pk.schema import DocumentResult, FieldResult, FieldStatus +from docling_pk.vision.geometry import rotate_right_angle + +LATIN = [box("PAKISTAN National Identity Card", 10, 10, conf=0.99), box("Name Father Name Gender", 10, 60, conf=0.95)] +JUNK = [box("؟ّ", 10, 10, conf=0.4), box("٧؟", 40, 10, conf=0.4), box("xq", 10, 50, conf=0.2)] + + +class MarkerBackend(OCRBackend): + """Reads 'good' text only when the white marker block is top-left.""" + + name = "marker" + + def __init__(self): + self.calls = 0 + + def read(self, image): + self.calls += 1 + h, w = image.shape[:2] + corner = image[: max(h // 10, 1), : max(w // 10, 1)] + return list(LATIN) if corner.mean() > 200 else list(JUNK) + + +def marked_image(): + img = np.zeros((600, 900, 3), np.uint8) + img[:120, :180] = 255 + return img + + +# ------------------------------------------------------------------ pipeline + + +@pytest.mark.parametrize("k", [0, 1, 2, 3]) +def test_detect_orientation_recovers_rotation(k): + rotated = rotate_right_angle(marked_image(), k) + turns, scores = pipeline.detect_orientation(rotated, MarkerBackend()) + assert (k + turns) % 4 == 0 + assert scores[turns] == max(scores.values()) + + +def test_upright_short_circuits(): + be = MarkerBackend() + pipeline.detect_orientation(marked_image(), be) + assert be.calls == 1 + + +def test_latin_score_penalizes_tall_boxes(): + wide = [TextBox.from_rect("PAKISTAN", 0.99, 0, 0, 200, 30)] + tall = [TextBox.from_rect("PAKISTAN", 0.99, 0, 0, 30, 200)] + assert pipeline.latin_score(wide) > 0 and pipeline.latin_score(tall) == 0 + + +def test_drop_noise_boxes(): + real = [box(f"Real text {i}", 10, i * 40, h=30, conf=0.95) for i in range(4)] + noise = [box("MABRD", 300, 5, h=10, conf=0.5)] + small_confident = [box("ISLAMIC REPUBLIC", 300, 100, h=10, conf=0.97)] + kept = pipeline.drop_noise_boxes(real + noise + small_confident) + assert noise[0] not in kept and small_confident[0] in kept + assert pipeline.drop_noise_boxes(noise) == noise # too little evidence: keep all + + +def test_suppress_light_ink(): + img = np.full((50, 50), 255, np.uint8) + img[10:20, 10:40] = 180 # light micro-text + img[30:40, 10:40] = 20 # dark ink + out = pipeline.suppress_light_ink(img) + assert out.shape == (50, 50, 3) + assert out[15, 15, 0] == 255 and out[35, 15, 0] == 20 + + +class SkewBackend(OCRBackend): + name = "skew" + + def __init__(self): + self.calls = 0 + + def read(self, image): + self.calls += 1 + if self.calls == 1 or self.calls == 2: # probe (upright accepted) then first main read + a = math.radians(6) + return [ + TextBox.from_polygon( + "PAKISTAN National Identity Card Name Gender", + 0.99, + [ + (10, 100 + i * 50), + (410, 100 + i * 50 - 400 * math.tan(a)), + (410, 130 + i * 50 - 400 * math.tan(a)), + (10, 130 + i * 50), + ], + ) + for i in range(5) + ] + return list(LATIN) + + +def test_prepare_page_deskews(): + be = SkewBackend() + page = pipeline.prepare_page(np.full((600, 900, 3), 255, np.uint8), be) + assert abs(page.skew - 6) < 0.5 + assert be.calls == 3 + assert page.text.startswith("PAKISTAN") + + +class WatermarkBackend(OCRBackend): + name = "wm" + + def read(self, image): + if (image == 255).mean() > 0.8: # ink-only variant: light background whitened + return [box(f"Clean line {i}", 10, i * 40, h=30, conf=0.99) for i in range(6)] + real = [box(f"Real text {i}", 10, i * 40, h=30, conf=0.95) for i in range(3)] + return real + [box("HDARY", 400, i * 12, h=10, conf=0.5) for i in range(30)] + + +def test_ink_only_variant_used_for_micro_text(): + img = np.full((800, 600, 3), 200, np.uint8) # light micro-text background + img[700:760, 50:550] = 20 # dark ink + page = pipeline.prepare_page(img, WatermarkBackend(), auto_rotate=False) + assert "ink_only_variant" in page.timings + assert page.boxes[0].text == "Clean line 0" + + +def test_page_reread_uses_verifier_and_upscales(): + main, verifier = FakeBackend(rereads={"*": ("main", 0.5)}), FakeBackend(rereads={"*": ("verifier", 0.9)}) + page = pipeline.Page(np.full((100, 200, 3), 255, np.uint8), [], main, verifier=verifier) + assert page.reread(box("x", 10, 10, h=10)) == ("verifier", 0.9) + assert verifier.last_crop_shape[0] >= 48 + page.verifier = None + assert page.reread(box("x", 10, 10, h=10)) == ("main", 0.5) + assert page.reread(TextBox.from_rect("x", 1, 500, 500, 600, 600)) == ("", 0.0) + + +def test_base_recognize_joins_left_to_right(): + be = FakeBackend([box("World", 100, 0), box("Hello", 0, 0)]) + assert OCRBackend.recognize(be, np.zeros((5, 5, 3), np.uint8)) == ("Hello World", 0.95) + assert OCRBackend.recognize(FakeBackend([]), np.zeros((5, 5), np.uint8)) == ("", 0.0) + with pytest.raises(NotImplementedError): + OCRBackend().read(np.zeros((5, 5), np.uint8)) + + +# ----------------------------------------------------------------- extractor + + +def _img(): + return np.full((630, 1000, 3), 230, np.uint8) + + +def test_extract_cnic_with_fake_backend(): + r = docling_pk.extract(_img(), "cnic", backend=FakeBackend(cnic_front_boxes()), urdu=False, verify=False) + assert isinstance(r, DocumentResult) + assert r.document_type == "cnic" + assert r["fields"]["cnic_number"]["value"] == "35202-1234567-9" + assert r.metadata["sides"] == ["front"] and r.metadata["ocr_backend"] == "fake" + assert 0.9 < r.confidence <= 1.0 + assert r.raw_text and "PAKISTAN" in r.raw_text + + +def test_extract_auto_classifies_cnic(): + r = docling_pk.extract(_img(), backend=FakeBackend(cnic_front_boxes()), urdu=False, verify=False) + assert r.document_type == "cnic" + + +def test_extract_unknown_document(): + r = docling_pk.extract(_img(), backend=FakeBackend([box("lorem ipsum", 10, 10)]), verify=False) + assert r.document_type == "unknown" and r.fields == {} and r.confidence == 0.0 + assert any("could not recognize" in w for w in r.warnings) + + +def test_extract_front_and_back_merge(monkeypatch): + monkeypatch.setattr(extractor, "_easyocr_available", lambda: False) + back = [box("35202-1234567-9", 650, 30, h=34), box("مکان نمبر 12 گلی 4", 100, 100), box("ضلع ملتان", 300, 160)] + + class TwoSided(FakeBackend): + def read(self, image): + return list(cnic_front_boxes() if image.mean() > 200 else back) + + r = docling_pk.extract([_img(), np.full((630, 1000, 3), 150, np.uint8)], "cnic", backend=TwoSided()) + assert r.metadata["sides"] == ["front", "back"] + assert r.fields["name"].value == "Muhammad Ali" + assert r.fields["address"].status in (FieldStatus.NOT_FOUND,) + assert any("urdu" in w.lower() for w in r.warnings) + + +def test_extract_certificate_and_degree_routes(): + from test_certificate_parser import fbise_boxes + from test_degree_urdu_layout import DEGREE_LINES, lines_to_boxes + + r = docling_pk.extract(_img(), "ssc", backend=FakeBackend(fbise_boxes()), verify=False) + assert r.document_type == "matric" and r.metadata["board"] == "FBISE" + assert len(r.tables["subjects"]) == 3 + r = docling_pk.extract(_img(), "auto", backend=FakeBackend(fbise_boxes(level="intermediate")), verify=False) + assert r.document_type == "intermediate" + r = docling_pk.extract(_img(), "transcript", backend=FakeBackend(lines_to_boxes(DEGREE_LINES)), verify=False) + assert r.document_type == "degree" and r.fields["degree"].value.startswith("Bachelor") + + +def test_include_raw_text_false(): + r = docling_pk.extract( + _img(), "cnic", backend=FakeBackend(cnic_front_boxes()), urdu=False, verify=False, include_raw_text=False + ) + assert r.raw_text is None + + +@pytest.mark.parametrize( + "alias,canonical", + [ + ("CNIC", "cnic"), + ("fsc", "intermediate"), + ("Inter", "intermediate"), + ("SSC", "matric"), + ("transcript", "degree"), + ("auto", "auto"), + ], +) +def test_document_type_aliases(alias, canonical): + assert extractor.normalize_document_type(alias) == canonical + + +def test_errors(): + with pytest.raises(UnsupportedDocumentTypeError): + docling_pk.extract(_img(), "passport") + with pytest.raises(ValueError): + docling_pk.extract(_img(), "passport") + with pytest.raises(FileNotFoundError): + docling_pk.extract("does_not_exist.jpg", "cnic") + with pytest.raises(ValueError): + extractor._make_backend("tesseract", None) + + +def test_bad_image_file(tmp_path): + p = tmp_path / "not_an_image.jpg" + p.write_text("text") + with pytest.raises(ImageLoadError): + docling_pk.extract(str(p), "cnic") + + +def test_verifier_selection(monkeypatch): + monkeypatch.setattr(extractor, "_easyocr_available", lambda: False) + assert extractor._verifier(FakeBackend(), True, None) is None + assert extractor._urdu_backend(None) is None + monkeypatch.setattr(extractor, "_easyocr_available", lambda: True) + assert extractor._verifier(FakeBackend(), False, None) is None + v = extractor._verifier(FakeBackend(), True, False) + assert v.name == "easyocr" and v.languages == ("en",) + assert extractor._urdu_backend(False).languages == ("ur", "en") + + +def test_make_backend_names(): + assert extractor._make_backend("rapidocr", None).name == "rapidocr" + assert extractor._make_backend("easyocr", False).name == "easyocr" + assert extractor._make_backend("auto", None).name in ("rapidocr", "easyocr") + fake = FakeBackend() + assert extractor._make_backend(fake, None) is fake + + +# --------------------------------------------------------- backend wrappers + + +def test_easyocr_wrapper(monkeypatch): + from docling_pk.ocr import easyocr_backend as eb + + class FakeReader: + def readtext(self, image, detail, paragraph): + return [([[0, 0], [50, 0], [50, 10], [0, 10]], "Hello", 0.9), ([[0, 0], [5, 0], [5, 5], [0, 5]], " ", 0.1)] + + def recognize(self, gray, horizontal_list, free_list, allowlist, detail): + assert gray.ndim == 2 and allowlist == "0123" + return [(None, "0123", 0.8)] + + monkeypatch.setattr(eb, "get_reader", lambda languages, gpu: FakeReader()) + be = eb.EasyOCRBackend(("en",), gpu=False) + boxes = be.read(np.zeros((10, 50), np.uint8)) + assert [b.text for b in boxes] == ["Hello"] and boxes[0].x1 == 50 + assert be.recognize(np.zeros((10, 50, 3), np.uint8), allowlist="0123") == ("0123", 0.8) + + class EmptyReader(FakeReader): + def recognize(self, *a, **k): + return [] + + monkeypatch.setattr(eb, "get_reader", lambda languages, gpu: EmptyReader()) + assert eb.EasyOCRBackend().recognize(np.zeros((10, 50), np.uint8)) == ("", 0.0) + + +def test_easyocr_reader_cache(monkeypatch): + import sys + + from docling_pk.ocr import easyocr_backend as eb + + created = [] + fake_module = SimpleNamespace(Reader=lambda langs, gpu, verbose: created.append((tuple(langs), gpu)) or object()) + monkeypatch.setitem(sys.modules, "easyocr", fake_module) + monkeypatch.setattr(eb, "_READERS", {}) + r1 = eb.get_reader(("en",), gpu=False) + r2 = eb.get_reader(("en",), gpu=False) + assert r1 is r2 and created == [(("en",), False)] + eb.get_reader(("en",), gpu=True) # different device -> new reader + assert len(created) == 2 + + +def test_rapidocr_wrapper(monkeypatch): + from docling_pk.ocr import rapidocr_backend as rb + + def engine(image, use_det=True, use_cls=False, use_rec=True): + if use_det: + return SimpleNamespace( + boxes=[[[0, 0], [40, 0], [40, 10], [0, 10]], [[0, 0], [1, 0], [1, 1], [0, 1]]], + txts=("Roll No", ""), + scores=(0.97, 0.1), + ) + return SimpleNamespace(boxes=None, txts=("654301",), scores=(0.99,)) + + monkeypatch.setattr(rb, "_get_engine", lambda: engine) + be = rb.RapidOCRBackend() + assert [b.text for b in be.read(np.zeros((10, 40), np.uint8))] == ["Roll No"] + assert be.recognize(np.zeros((10, 40, 3), np.uint8)) == ("654301", 0.99) + monkeypatch.setattr(rb, "_get_engine", lambda: lambda *a, **k: SimpleNamespace(boxes=None, txts=None, scores=None)) + assert be.read(np.zeros((10, 40), np.uint8)) == [] + assert be.recognize(np.zeros((10, 40), np.uint8)) == ("", 0.0) + + +# ---------------------------------------------------------------------- CLI + +runner = CliRunner() + + +def _fake_result(empty=False): + fields = { + "cnic_number": FieldResult(None if empty else "35202-1234567-9", 0.97), + "name": FieldResult.missing("label not found"), + } + return DocumentResult( + "cnic", + fields, + 0.48, + ["name: label not found"], + raw_text="RAW", + tables={"subjects": [{"subject": "MATHEMATICS", "max_marks": 150, "obtained_marks": 99}]}, + ) + + +@pytest.fixture +def fake_extract(monkeypatch): + calls = [] + + def fake(source, **kwargs): + calls.append((source, kwargs)) + if "missing" in str(source): + raise FileNotFoundError(f"Image not found: {source}") + if "bad" in str(source): + raise ImageLoadError("Could not read image") + return _fake_result(empty="empty" in str(source)) + + monkeypatch.setattr(docling_pk, "extract", fake) + return calls + + +def test_cli_json(fake_extract, tmp_path): + img = tmp_path / "card.jpg" + img.write_bytes(b"x") + result = runner.invoke(app, ["extract", str(img), "--type", "cnic", "--no-urdu", "--cpu", "--no-verify"]) + assert result.exit_code == 0, result.output + data = json.loads(result.stdout) + assert data["fields"]["cnic_number"]["value"] == "35202-1234567-9" + assert "raw_text" not in data + _, kwargs = fake_extract[0] + assert kwargs["urdu"] is False and kwargs["gpu"] is False and kwargs["verify"] is False + + +def test_cli_raw_and_multiple_files(fake_extract, tmp_path): + a, b = tmp_path / "front.jpg", tmp_path / "back.jpg" + a.write_bytes(b"x"), b.write_bytes(b"x") + result = runner.invoke(app, ["extract", str(a), str(b), "--raw"]) + assert json.loads(result.stdout)["raw_text"] == "RAW" + assert isinstance(fake_extract[0][0], list) + + +def test_cli_table_and_out_file(fake_extract, tmp_path): + img = tmp_path / "card.jpg" + img.write_bytes(b"x") + result = runner.invoke(app, ["extract", str(img), "-o", "table"]) + assert result.exit_code == 0 and "35202-1234567-9" in result.stdout and "MATHEMATICS" in result.stdout + out = tmp_path / "out.json" + result = runner.invoke(app, ["extract", str(img), "--out", str(out)]) + assert json.loads(out.read_text(encoding="utf-8"))["document_type"] == "cnic" + + +def test_cli_exit_codes(fake_extract, tmp_path): + assert runner.invoke(app, ["extract", "missing.jpg"]).exit_code == 2 + assert runner.invoke(app, ["extract", "bad.jpg"]).exit_code == 2 + img = tmp_path / "card.jpg" + img.write_bytes(b"x") + assert runner.invoke(app, ["extract", str(img), "-o", "xml"]).exit_code == 2 + empty = tmp_path / "empty.jpg" + empty.write_bytes(b"x") + assert runner.invoke(app, ["extract", str(empty)]).exit_code == 1 + + +def test_cli_batch(fake_extract, tmp_path): + (tmp_path / "a.jpg").write_bytes(b"x") + (tmp_path / "bad.png").write_bytes(b"x") + (tmp_path / "notes.txt").write_text("skip me") + out = tmp_path / "res.jsonl" + result = runner.invoke(app, ["batch", str(tmp_path), "--out", str(out)]) + assert result.exit_code == 0, result.output + records = [json.loads(line) for line in out.read_text(encoding="utf-8").splitlines()] + assert [r["file"] for r in records] == ["a.jpg", "bad.png"] + assert "error" in records[1] and records[0]["document_type"] == "cnic" + empty_dir = tmp_path / "empty" + empty_dir.mkdir() + assert runner.invoke(app, ["batch", str(empty_dir)]).exit_code == 2 + + +def test_cli_info_and_version(): + result = runner.invoke(app, ["info"]) + assert result.exit_code == 0 and "docling-pk" in result.stdout and "rapidocr" in result.stdout + result = runner.invoke(app, ["--version"]) + assert result.exit_code == 0 and docling_pk.__version__ in result.stdout diff --git a/tests/test_preprocessor.py b/tests/test_preprocessor.py deleted file mode 100644 index d49e599..0000000 --- a/tests/test_preprocessor.py +++ /dev/null @@ -1,49 +0,0 @@ -""" -Tests for preprocessor.py per the Week 3 Integration Engineer spec: -returns a numpy array, handles rotated images, raises FileNotFoundError -and ValueError correctly. -""" - -import numpy as np -import pytest -from pathlib import Path - -from docling_pk.preprocessor import preprocess, TooBlurryError - -FIXTURES = Path(__file__).parent / "fixtures" - - -def test_file_not_found_raises(): - with pytest.raises(FileNotFoundError): - preprocess("this/path/does/not/exist.jpg") - - -def test_invalid_image_raises_value_error(tmp_path): - bad_file = tmp_path / "not_an_image.jpg" - bad_file.write_text("this is not image data") - with pytest.raises(ValueError): - preprocess(str(bad_file)) - - -@pytest.mark.skipif(not (FIXTURES / "cnic_clean.jpg").exists(), reason="fixture not added yet") -def test_returns_numpy_array(): - result = preprocess(str(FIXTURES / "cnic_clean.jpg")) - assert isinstance(result, np.ndarray) - - -@pytest.mark.skipif(not (FIXTURES / "cnic_rotated.jpg").exists(), reason="fixture not added yet") -def test_handles_rotated_image_without_crashing(): - """ - preprocess() itself only corrects small skew, not full 90-degree - rotation (that is handled in ocr.py's rotation trial), so this - test only confirms it does not crash or raise on a rotated image, - not that the output is already upright. - """ - result = preprocess(str(FIXTURES / "cnic_rotated.jpg")) - assert isinstance(result, np.ndarray) - - -@pytest.mark.skipif(not (FIXTURES / "cnic_blurry.jpg").exists(), reason="fixture not added yet") -def test_too_blurry_raises_too_blurry_error(): - with pytest.raises(TooBlurryError): - preprocess(str(FIXTURES / "cnic_blurry.jpg")) \ No newline at end of file diff --git a/tests/test_schema_and_io.py b/tests/test_schema_and_io.py new file mode 100644 index 0000000..b9ed7ae --- /dev/null +++ b/tests/test_schema_and_io.py @@ -0,0 +1,241 @@ +"""Output schema, image loading, quality and geometry helpers.""" + +import datetime as dt +import json + +import cv2 +import numpy as np +import pytest + +from docling_pk.errors import ImageLoadError +from docling_pk.ocr.base import TextBox +from docling_pk.schema import DocumentResult, FieldResult, FieldStatus +from docling_pk.vision import geometry, quality +from docling_pk.vision.image_io import load_image, load_pages + +# ------------------------------------------------------------------ schema + + +class TestFieldResult: + def test_missing_value_forces_zero_confidence(self): + f = FieldResult(None, 0.9) + assert f.confidence == 0.0 and f.status == FieldStatus.NOT_FOUND and not f.ok + + def test_confidence_clamped(self): + assert FieldResult("x", 1.7).confidence == 1.0 + assert FieldResult("x", -1).confidence == 0.0 + + def test_missing_constructor(self): + f = FieldResult.missing("gone", status=FieldStatus.NOT_APPLICABLE, raw="r") + assert f.value is None and f.reason == "gone" and f.raw == "r" + assert f.status == FieldStatus.NOT_APPLICABLE + + def test_as_date(self): + assert FieldResult("14-02-2004", 0.9).as_date() == dt.date(2004, 2, 14) + assert FieldResult("nope", 0.9).as_date() is None + assert FieldResult(None, 0).as_date() is None + + def test_to_dict(self): + f = FieldResult("A", 0.5, FieldStatus.LOW_CONFIDENCE, "why", raw="a") + assert f.to_dict() == {"value": "A", "confidence": 0.5, "status": "low_confidence", "reason": "why"} + assert f.to_dict(include_raw=True)["raw"] == "a" + + +class TestDocumentResult: + def _result(self): + return DocumentResult( + "cnic", + {"cnic_number": FieldResult("35202-1234567-9", 0.9), "name": FieldResult.missing("x")}, + 0.45, + ["w"], + raw_text="RAW", + tables={"subjects": [{"subject": "A", "max_marks": 1, "obtained_marks": 1}]}, + metadata={"k": 1}, + ) + + def test_dict_style_access_matches_brief(self): + r = self._result() + assert r["fields"]["cnic_number"]["value"] == "35202-1234567-9" + assert r["document_type"] == "cnic" + assert r["raw_text"] == "RAW" + assert "fields" in list(r.keys()) + + def test_helpers(self): + r = self._result() + assert r.get("cnic_number") == "35202-1234567-9" + assert r.get("unknown") is None + assert r.missing_fields == ["name"] + + def test_raw_text_excluded_by_default(self): + d = self._result().to_dict() + assert "raw_text" not in d + assert self._result().to_dict(include_raw=True)["raw_text"] == "RAW" + + def test_json_keeps_urdu_readable(self): + r = DocumentResult("cnic", {"address": FieldResult("گوجرانوالہ", 0.4)}, 0.4) + s = r.to_json() + assert "گوجرانوالہ" in s + assert json.loads(s)["fields"]["address"]["value"] == "گوجرانوالہ" + + def test_defaults(self): + r = DocumentResult("cnic", {}, 0.0) + assert r.warnings == [] and r.raw_text is None and r.tables == {} and r.metadata == {} + assert "tables" not in r.to_dict() + + +# -------------------------------------------------------------- image io + + +def _png_bytes(img): + ok, buf = cv2.imencode(".png", img) + assert ok + return buf.tobytes() + + +class TestLoadImage: + def test_missing_file(self, tmp_path): + with pytest.raises(FileNotFoundError): + load_image(tmp_path / "nope.jpg") + + def test_not_an_image(self, tmp_path): + p = tmp_path / "x.jpg" + p.write_text("not an image") + with pytest.raises(ImageLoadError): + load_image(p) + with pytest.raises(ValueError): # ImageLoadError is a ValueError + load_image(str(p)) + + def test_empty_file(self, tmp_path): + p = tmp_path / "empty.png" + p.write_bytes(b"") + with pytest.raises(ImageLoadError): + load_image(p) + + def test_directory(self, tmp_path): + with pytest.raises(ImageLoadError): + load_image(tmp_path) + + def test_unicode_path(self, tmp_path): + """cv2.imread fails on non-ASCII Windows paths; we must not.""" + folder = tmp_path / "دستاویزات" + folder.mkdir() + p = folder / "شناختی کارڈ.png" + p.write_bytes(_png_bytes(np.full((20, 30, 3), 200, np.uint8))) + assert load_image(p).shape == (20, 30, 3) + + def test_bytes(self): + img = load_image(_png_bytes(np.zeros((5, 7, 3), np.uint8))) + assert img.shape == (5, 7, 3) + + def test_bad_bytes(self): + with pytest.raises(ImageLoadError): + load_image(b"garbage") + + @pytest.mark.parametrize( + "arr,shape", + [ + (np.zeros((4, 6), np.uint8), (4, 6, 3)), + (np.zeros((4, 6, 1), np.uint8), (4, 6, 3)), + (np.zeros((4, 6, 4), np.uint8), (4, 6, 3)), + (np.zeros((4, 6, 3), np.float32), (4, 6, 3)), + (np.zeros((4, 6, 3), np.uint16), (4, 6, 3)), + ], + ) + def test_arrays(self, arr, shape): + out = load_image(arr) + assert out.shape == shape and out.dtype == np.uint8 + + def test_bad_arrays(self): + with pytest.raises(ImageLoadError): + load_image(np.zeros((0,), np.uint8)) + with pytest.raises(ImageLoadError): + load_image(np.zeros((2, 2, 2), np.uint8)) + + def test_pil(self): + from PIL import Image + + im = Image.new("RGB", (8, 4), (255, 0, 0)) + out = load_image(im) + assert out.shape == (4, 8, 3) and tuple(out[0, 0]) == (0, 0, 255) + + def test_unsupported_type(self): + with pytest.raises(TypeError): + load_image(12345) + + def test_pdf_requires_pymupdf_or_renders(self, tmp_path): + fitz = pytest.importorskip("fitz") + doc = fitz.open() + page = doc.new_page(width=200, height=100) + page.insert_text((20, 50), "Roll No 123456") + p = tmp_path / "doc.pdf" + doc.save(str(p)) + pages = load_pages(p) + assert len(pages) == 1 and pages[0].ndim == 3 + assert len(load_pages(p.read_bytes())) == 1 + + +# ------------------------------------------------------------- quality + + +class TestQuality: + def test_sharp_vs_blurry(self): + rng = np.random.default_rng(0) + sharp = (rng.random((600, 900)) > 0.5).astype(np.uint8) * 255 + blurry = cv2.GaussianBlur(sharp, (51, 51), 20) + assert quality.sharpness(sharp) > quality.sharpness(blurry) + assert any("blurry" in w for w in quality.assess(blurry).warnings) + assert not any("blurry" in w for w in quality.assess(sharp).warnings) + + def test_dark_bright_glare_lowres(self): + assert any("dark" in w for w in quality.assess(np.full((800, 800), 10, np.uint8)).warnings) + bright = quality.assess(np.full((800, 800, 3), 255, np.uint8)) + assert any("overexposed" in w for w in bright.warnings) + assert not any("Glare" in w for w in bright.warnings) # white paper is not glare + photo = np.full((800, 800), 120, np.uint8) + photo[100:300, 100:400] = 255 # specular highlight on a mid-tone photo + assert any("Glare" in w for w in quality.assess(photo).warnings) + assert any("Low resolution" in w for w in quality.assess(np.full((100, 100), 128, np.uint8)).warnings) + + def test_report_dict(self): + d = quality.assess(np.full((50, 60), 128, np.uint8)).to_dict() + assert d["width"] == 60 and d["height"] == 50 + + +# ------------------------------------------------------------ geometry + + +class TestGeometry: + def test_resize_long_side(self): + img, s = geometry.resize_long_side(np.zeros((100, 200, 3), np.uint8), 400) + assert img.shape == (200, 400, 3) and s == 2.0 + same, s1 = geometry.resize_long_side(img, 400) + assert same is img and s1 == 1.0 + + @pytest.mark.parametrize("k", [0, 1, 2, 3, 4, -1]) + def test_right_angle_rotation_matches_numpy(self, k): + img = np.arange(24, dtype=np.uint8).reshape(4, 6) + assert np.array_equal(geometry.rotate_right_angle(img, k), np.rot90(img, k)) + + def test_rotate_bound_keeps_corners(self): + img = np.zeros((100, 200, 3), np.uint8) + out = geometry.rotate_bound(img, 30) + assert out.shape[0] > 100 and out.shape[1] > 200 + assert geometry.rotate_bound(img, 0.0) is img + + def test_estimate_skew(self): + def tilted(x, y, w, h, deg): + import math + + a = math.radians(deg) + dx, dy = w * math.cos(a), -w * math.sin(a) + return TextBox.from_polygon("text", 0.9, [(x, y), (x + dx, y + dy), (x + dx, y + dy + h), (x, y + h)]) + + boxes = [tilted(10, 100 + i * 40, 300, 20, 7.0) for i in range(5)] + assert abs(geometry.estimate_skew(boxes) - 7.0) < 0.5 + assert geometry.estimate_skew(boxes[:2]) == 0.0 + assert geometry.estimate_skew([TextBox.from_rect("x", 0.9, 0, 0, 10, 10)] * 5) == 0.0 + + def test_crop_box_clamps(self): + img = np.zeros((50, 50, 3), np.uint8) + crop = geometry.crop_box(img, TextBox.from_rect("x", 1, 40, 40, 60, 60)) + assert crop.shape[0] <= 50 and crop.shape[1] <= 50 diff --git a/tests/test_smoke.py b/tests/test_smoke.py deleted file mode 100644 index 84393cc..0000000 --- a/tests/test_smoke.py +++ /dev/null @@ -1,34 +0,0 @@ - - -from docling_pk.schema import FieldResult, DocumentResult -from docling_pk.parsers import cnic - - -def test_field_result_holds_value_and_confidence(): - result = FieldResult(value="test", confidence=0.9) - assert result.value == "test" - assert result.confidence == 0.9 - - -def test_document_result_defaults(): - result = DocumentResult(document_type="cnic", fields={}, confidence=0.0) - assert result.warnings == [] - assert result.raw_text is None - - -def test_cnic_extract_finds_number_from_known_text(): - """ - Real parsing logic tested against known raw text, not a live - image, so this runs in CI without needing OCR or a GPU. - """ - raw_text = "Identity Number\n37406-5772542-1\nGender\nM" - fields = cnic.extract(raw_text) - assert fields["cnic_number"].value == "37406-5772542-1" - assert fields["gender"].value == "M" - - -def test_cnic_extract_returns_none_for_missing_field_not_a_crash(): - """A field genuinely absent from the text returns None, not an exception.""" - fields = cnic.extract("some unrelated text with no real fields in it") - assert fields["cnic_number"].value is None - assert fields["cnic_number"].confidence == 0.0 \ No newline at end of file diff --git a/tests/test_validation.py b/tests/test_validation.py new file mode 100644 index 0000000..dd305a0 --- /dev/null +++ b/tests/test_validation.py @@ -0,0 +1,161 @@ +"""Format validators and OCR-error normalization.""" + +import datetime as dt + +import pytest + +from docling_pk.validation.cnic import check_cnic, find_cnic, format_cnic, gender_from_cnic +from docling_pk.validation.dates import find_dates, parse_date, to_date +from docling_pk.validation.marks import agree, check_subjects, words_to_number +from docling_pk.validation.text import digits_only, eastern_to_ascii_digits, fix_digits + + +class TestFindCnic: + @pytest.mark.parametrize( + "text", + [ + "35202-1234567-1", + "Identity Number 35202-1234567-1", + "35202_1234567_1", # underscore misread (seen in real output) + "35202-1234567.1", # dot misread (seen in real output) + "35202:1234567:1", # colon misread + "35202 1234567 1", # dashes dropped + "3520212345671", # all 13 digits run together + "352O2-l234567-l", # letter-for-digit misreads + "35202 - 1234567 - 1", + ], + ) + def test_tolerates_real_ocr_misreads(self, text): + assert find_cnic(text) == "35202-1234567-1" + + @pytest.mark.parametrize("text", ["", "no digits here", "35202-123456-1", "13.07.2012", "352021234567123"]) + def test_rejects_non_cnic(self, text): + assert find_cnic(text) is None + + def test_does_not_accept_arabic_indic_digits_as_ascii_silently(self): + # Eastern digits are converted deliberately, then validated as usual. + assert find_cnic("٣٥٢٠٢-١٢٣٤٥٦٧-١") == "35202-1234567-1" + + def test_format(self): + assert format_cnic("3520212345679") == "35202-1234567-9" + + +class TestCheckCnic: + def test_valid(self): + c = check_cnic("35202-1234567-9") + assert c.valid and c.region == "Punjab" and c.gender_digit == "9" + + def test_bad_region_digit(self): + c = check_cnic("05202-1234567-9") + assert not c.valid + assert "region" in c.problems[0] + + def test_wrong_length(self): + assert not check_cnic("3520-1234567-9").valid + + def test_missing(self): + assert check_cnic(None).problems == ("missing",) + + @pytest.mark.parametrize( + "num,gender", [("35202-1234567-9", "M"), ("35202-1234567-4", "F"), ("bad", None), (None, None)] + ) + def test_gender_from_last_digit(self, num, gender): + assert gender_from_cnic(num) == gender + + +class TestDates: + @pytest.mark.parametrize( + "text,expected", + [ + ("14.02.2004", "14-02-2004"), + ("14,02.2004", "14-02-2004"), + ("Date of Birth 11.10.1988", "11-10-1988"), + ("22-07-1998(Twenty Second July", "22-07-1998"), + ("14-Feb-2004", "14-02-2004"), + ("20-Jul-2026", "20-07-2026"), + ("February 22, 2022", "22-02-2022"), + ("Feb 01, 2024", "01-02-2024"), + ("November 08, 2019", "08-11-2019"), + ("20th July 2026", "20-07-2026"), + ("6th July 2026", "06-07-2026"), + ("Novembr 08, 2019", "08-11-2019"), # OCR typo in month name + ("17.O5.2O24", "17-05-2024"), # letter O for zero + ], + ) + def test_formats(self, text, expected): + assert parse_date(text) == expected + + @pytest.mark.parametrize("text", ["31.02.2003", "00.01.2000", "12.13.2000", "01.01.1800", "hello", ""]) + def test_invalid(self, text): + assert parse_date(text) is None + + def test_find_all_in_order(self): + assert find_dates("18.06.2023 then 18.06.2033") == ["18-06-2023", "18-06-2033"] + + def test_duplicates_removed(self): + assert find_dates("01.01.2000 01.01.2000") == ["01-01-2000"] + + def test_to_date(self): + assert to_date("14-02-2004") == dt.date(2004, 2, 14) + assert to_date(None) is None + assert to_date("garbage") is None + + +class TestText: + def test_fix_digits_only_in_numeric_tokens(self): + assert fix_digits("6543O1") == "654301" + assert fix_digits("Name") == "Name" + assert fix_digits("") == "" + + def test_digits_only(self): + assert digits_only("O98") == "098" + assert digits_only("1,100") == "1100" + + def test_eastern_digits(self): + assert eastern_to_ascii_digits("۱۲۳٤") == "1234" + + +class TestMarks: + @pytest.mark.parametrize( + "text,n", + [ + ("Seven Hundred Eighty Seven", 787), + ("EIGHT HUNDRED EIGHTY-TWO", 882), + ("Five Hundred Fifty Four", 554), + ("Eight Hundred Sixty Nine", 869), + ("One Thousand and Twenty", 1020), + ("Five Hundred Fiffy Four", 554), # OCR typo + ("Nine Hundred", 900), + ("Ninety", 90), + ], + ) + def test_words_to_number(self, text, n): + assert words_to_number(text) == n + + def test_words_to_number_none(self): + assert words_to_number("no number words") is None + assert words_to_number("") is None + + def test_check_subjects_consistent(self): + rows = [("ENGLISH", 150, 118), ("URDU", 150, 86)] + assert check_subjects(rows, 300, 204) == (True, []) + + def test_check_subjects_sum_mismatch(self): + ok, problems = check_subjects([("ENGLISH", 150, 118), ("URDU", 150, 86)], 300, 205) + assert not ok and "obtained marks add up to 204" in problems[0] + + def test_check_subjects_obtained_exceeds_max(self): + ok, problems = check_subjects([("ENGLISH", 50, 98)], None, None) + assert not ok and "exceeds" in problems[0] + + def test_check_subjects_incomplete(self): + ok, problems = check_subjects([("ENGLISH", None, 98)], None, None) + assert not ok and "incomplete" in problems[0] + + def test_check_subjects_empty(self): + assert check_subjects([], 1100, 787)[0] is False + + def test_agree(self): + assert agree([787, 787, None]) == 787 + assert agree([787, 788]) is None + assert agree([None]) is None