From 0cede2e673e246146e617ae2635578613eca0690 Mon Sep 17 00:00:00 2001 From: Sina Date: Sun, 12 Apr 2026 16:41:03 +0000 Subject: [PATCH 01/58] refactor(churro-ocr)!: stop re-exporting template types Keep the top-level package focused on the main OCR API surface. BREAKING CHANGE: import HFChatTemplate and OCRPromptTemplate from churro_ocr.templates instead of churro_ocr. --- src/churro_ocr/__init__.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/churro_ocr/__init__.py b/src/churro_ocr/__init__.py index 3d9fc09..3234379 100644 --- a/src/churro_ocr/__init__.py +++ b/src/churro_ocr/__init__.py @@ -16,8 +16,6 @@ CHURRO_3B_MODEL_ID, CHURRO_3B_XML_TEMPLATE, DEFAULT_OCR_TEMPLATE, - HFChatTemplate, - OCRPromptTemplate, ) __all__ = [ @@ -31,8 +29,6 @@ "DocumentOCRResult", "DocumentPageDetector", "DEFAULT_OCR_TEMPLATE", - "HFChatTemplate", - "OCRPromptTemplate", "OCRBackend", "OCRClient", "OCRResult", From d1269a6550ceb2373777ec16bcec95570383d5c9 Mon Sep 17 00:00:00 2001 From: Sina Date: Sun, 12 Apr 2026 16:41:13 +0000 Subject: [PATCH 02/58] docs(churro-ocr): clarify setup and provider guidance Standardize the primary install flow on plus direct commands. Also document PDF/all runtime targets and refresh the provider guide for the newer template and prompt helper exports. --- docs/cli.md | 1 + docs/getting-started.md | 30 +++++++++++++++++++++++------- docs/guides/ocr-workflows.md | 8 +++++++- docs/guides/providers.md | 26 ++++++++++++++++++++------ docs/pypi.md | 6 +++--- 5 files changed, 54 insertions(+), 17 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 4c85737..3ce2bcb 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -3,6 +3,7 @@ Use the CLI when you want a quick sanity check before writing Python code. Use `churro-ocr --help` or `python -m churro_ocr --help` to inspect the top-level commands. +This page assumes the CLI is installed and available as `churro-ocr`. Install Churro in [Getting Started](getting-started.md), and use [Providers And Configuration](guides/providers.md) for backend-specific runtime setup. diff --git a/docs/getting-started.md b/docs/getting-started.md index 92cef8b..974af16 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -13,23 +13,39 @@ ## Install -Use UV as the supported install path. +For the CLI-first workflow used in this guide, install Churro with UV as a tool. +Python 3.12 or newer is required. ```bash uv tool install churro-ocr -# or, in a project: -uv add churro-ocr ``` +If you are adding `churro-ocr` to a project instead, use `uv add churro-ocr` and prefix the CLI commands below with `uv run`. + Then install the runtime for the backend you plan to use: ```bash -uv run churro-ocr install llm -uv run churro-ocr install hf -uv run churro-ocr install local +churro-ocr install llm +churro-ocr install hf +churro-ocr install local +churro-ocr install pdf +churro-ocr install all +``` + +Common runtime targets: + +- `llm`: hosted multimodal OCR through LiteLLM-backed providers +- `hf`: local Hugging Face OCR plus a PyTorch runtime +- `local`: clients for OpenAI-compatible local or self-hosted servers +- `pdf`: PDF rasterization support for `process_pdf_*` and `extract-pages --pdf` +- `all`: every optional runtime in one command + +Use `--torch-backend` with `hf` or `all` when you need a specific PyTorch build: + +```bash +churro-ocr install hf --torch-backend cu126 ``` -If you installed the CLI with `uv tool install churro-ocr`, drop the `uv run` prefix. For the full provider/runtime matrix, use [Providers And Configuration](guides/providers.md). ## First OCR Example diff --git a/docs/guides/ocr-workflows.md b/docs/guides/ocr-workflows.md index f352c79..69d98e9 100644 --- a/docs/guides/ocr-workflows.md +++ b/docs/guides/ocr-workflows.md @@ -26,7 +26,13 @@ print(page.model_name) ## OCR A PDF -If you install the `pdf` extra, `DocumentOCRPipeline` can rasterize a PDF and OCR each page. +Install the `pdf` runtime first. If you also want local Hugging Face OCR for PDFs, install `all` or install both `hf` and `pdf`. + +```bash +churro-ocr install pdf +``` + +Then `DocumentOCRPipeline` can rasterize a PDF and OCR each page. ```python from churro_ocr import DocumentOCRPipeline diff --git a/docs/guides/providers.md b/docs/guides/providers.md index 1b061bb..d0fa7d0 100644 --- a/docs/guides/providers.md +++ b/docs/guides/providers.md @@ -13,18 +13,23 @@ backend = build_ocr_backend( ) ``` -## Which OCR Backend Should You Use? +## Which Runtime Should You Install? Install the base package first as shown in [Getting Started](../getting-started.md). -This page is the source of truth for matching providers to runtime targets. +This page is the source of truth for optional runtime install targets. +Commands on this page assume the CLI is installed and available as `churro-ocr`. -| Provider | Install command | Good default when | +| Provider or feature | Install command | Good default when | | --- | --- | --- | | `litellm` | `churro-ocr install llm` | you want hosted multimodal models routed through LiteLLM | | `openai-compatible` | `churro-ocr install local` | you have a local or self-hosted OpenAI-style server | | `hf` | `churro-ocr install hf` | you want local Transformers inference in-process | | `azure` | `churro-ocr install azure` | you want Azure Document Intelligence OCR | | `mistral` | `churro-ocr install mistral` | you want Mistral OCR | +| `pdf` | `churro-ocr install pdf` | you want `process_pdf_*` or `extract-pages --pdf` | +| `all` | `churro-ocr install all` | you want every optional runtime in one environment | + +`hf` and `all` also install a PyTorch runtime. Pass `--torch-backend ` when you need a specific build, for example `churro-ocr install hf --torch-backend cu126`. ## Recommended Starting Points @@ -145,7 +150,7 @@ backend = build_ocr_backend( ) ``` -Built-in model-specific profiles are resolved automatically for known models such as `stanford-oval/churro-3B`, `datalab-to/chandra-ocr-2`, `deepseek-ai/DeepSeek-OCR-2`, `kristaller486/dots.ocr-1.5`, `rednote-hilab/dots.mocr`, and the supported `olmOCR` checkpoints. +Built-in model-specific profiles are resolved automatically for known models such as `stanford-oval/churro-3B`, `datalab-to/chandra-ocr-2`, `deepseek-ai/DeepSeek-OCR-2`, `kristaller486/dots.ocr-1.5`, `rednote-hilab/dots.mocr`, `PaddlePaddle/PaddleOCR-VL-1.5`, `LiquidAI/LFM2.5-VL-1.6B`, and the supported `olmOCR` checkpoints. ## `OCRBackendSpec` Reference @@ -174,13 +179,13 @@ Built-in model-specific profiles are resolved automatically for known models suc Most users should rely on the built-in model profiles. If you need to override prompt rendering for a custom Hugging Face model, pass a custom `OCRModelProfile`. ```python -from churro_ocr import HFChatTemplate from churro_ocr.providers import ( HuggingFaceOptions, OCRBackendSpec, OCRModelProfile, build_ocr_backend, ) +from churro_ocr.templates import HFChatTemplate backend = build_ocr_backend( OCRBackendSpec( @@ -200,18 +205,25 @@ backend = build_ocr_backend( ### Prompt And Template Exports -Useful public template exports: +Useful public template exports and helpers: | Export | Module | Use case | | --- | --- | --- | | `HFChatTemplate` | `churro_ocr.templates` | Build a Hugging Face chat-style multimodal prompt. | +| `build_ocr_conversation(...)` | `churro_ocr.templates` | Render a template or template callable into the conversation payload passed to OCR backends. | | `DEFAULT_OCR_TEMPLATE` | `churro_ocr.templates` | Generic OCR prompt template used by the default model profile. | | `CHURRO_3B_XML_TEMPLATE` | `churro_ocr.templates` | Built-in template for `stanford-oval/churro-3B`. | | `CHANDRA_OCR_2_OCR_TEMPLATE` | `churro_ocr.templates` | Built-in template for `datalab-to/chandra-ocr-2`. | | `DEEPSEEK_OCR_2_OCR_TEMPLATE` | `churro_ocr.templates` | Built-in template for `deepseek-ai/DeepSeek-OCR-2`. | | `DOTS_OCR_1_5_OCR_TEMPLATE` | `churro_ocr.templates` | Built-in template for `kristaller486/dots.ocr-1.5`. | | `DOTS_MOCR_OCR_TEMPLATE` | `churro_ocr.templates` | Built-in template for `rednote-hilab/dots.mocr`. | +| `PADDLEOCR_VL_1_5_OCR_TEMPLATE` | `churro_ocr.templates` | Built-in template for `PaddlePaddle/PaddleOCR-VL-1.5`. | +| `OLMOCR_2_7B_1025_OCR_TEMPLATE` | `churro_ocr.templates` | Built-in template for the supported `olmOCR-2-7B-1025` checkpoints. | +| `LFM2_5_VL_1_6B_OCR_TEMPLATE` | `churro_ocr.templates` | Built-in template for `LiquidAI/LFM2.5-VL-1.6B`. | +| `OCRConversation` | `churro_ocr.templates` | Type alias for the rendered multimodal conversation payload. | | `OCRPromptTemplate` | `churro_ocr.templates` | Base protocol for custom profile integration. | +| `OCRPromptTemplateCallable` | `churro_ocr.templates` | Callable form for dynamic prompt rendering from a `DocumentPage`. | +| `OCRPromptTemplateLike` | `churro_ocr.templates` | Union accepted by helper APIs that can take either a protocol instance or callable template. | Useful public prompt exports: @@ -223,5 +235,7 @@ Useful public prompt exports: | `CHANDRA_OCR_LAYOUT_PROMPT` | `churro_ocr.prompts` | Upstream Chandra OCR 2 layout-block HTML prompt. | | `DEFAULT_OCR_OUTPUT_TAG` | `churro_ocr.prompts` | Shared tag name used by the default OCR postprocessor. | | `DEFAULT_BOUNDARY_DETECTION_PROMPT` | `churro_ocr.prompts` | Default prompt used by LLM-based page and text-block boundary detection helpers. | +| `OLMOCR_V4_YAML_PROMPT` | `churro_ocr.prompts` | Upstream olmOCR YAML-front-matter prompt used by the built-in olmOCR templates. | | `parse_chandra_response(...)` | `churro_ocr.prompts` | Convert Chandra HTML-layout output to plain text and preserve raw HTML metadata. | +| `parse_olmocr_response(...)` | `churro_ocr.prompts` | Convert olmOCR YAML/markdown output into plain text plus parsed metadata. | | `strip_ocr_output_tag(...)` | `churro_ocr.prompts` | Remove the default OCR wrapper tag from model output. | diff --git a/docs/pypi.md b/docs/pypi.md index 28e1359..48a3ea9 100644 --- a/docs/pypi.md +++ b/docs/pypi.md @@ -6,14 +6,14 @@ Full documentation and project overview live at https://stanford-oval.github.io/ ## Install -Use UV as the supported install path. +For the CLI-first workflow used in the docs, install Churro with UV as a tool. ```bash uv tool install churro-ocr -# or, in a project: -uv add churro-ocr ``` +If you are adding `churro-ocr` to a project instead, use `uv add churro-ocr` and prefix CLI commands with `uv run`. + Runtime setup and provider-specific install commands are in [Getting Started](https://stanford-oval.github.io/Churro/getting-started.html) and From 4089edb63970d97fdbc9715dc5c3410804f8ea61 Mon Sep 17 00:00:00 2001 From: Sina Date: Sun, 12 Apr 2026 17:30:58 +0000 Subject: [PATCH 03/58] docs(churro-ocr): reorganize CLI-first onboarding Align the docs home, getting-started flow, CLI guide, and task pages around one local HF quick-start. Split advanced provider customization into a dedicated guide and keep providers focused on runtime and backend choice. --- README.md | 2 + docs/cli.md | 79 ++++++++--- docs/getting-started.md | 118 +++++++++------- docs/guides/advanced-customization.md | 80 +++++++++++ docs/guides/ocr-workflows.md | 37 +++-- docs/guides/page-detection.md | 23 +++- docs/guides/providers.md | 190 ++++++-------------------- docs/index.md | 90 +++++++++++- 8 files changed, 384 insertions(+), 235 deletions(-) create mode 100644 docs/guides/advanced-customization.md diff --git a/README.md b/README.md index 77f2fde..b5e6e99 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,8 @@ It works with all major OCR providers and vision-language models, and provides f ## Quick Try +Python 3.12+ and `uv` are required. + ```bash uv tool install churro-ocr churro-ocr install hf diff --git a/docs/cli.md b/docs/cli.md index 3ce2bcb..2434e29 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1,32 +1,66 @@ # CLI -Use the CLI when you want a quick sanity check before writing Python code. +Use the CLI when you want to validate a backend, transcribe one image, or extract page crops without writing Python. Use `churro-ocr --help` or `python -m churro_ocr --help` to inspect the top-level commands. -This page assumes the CLI is installed and available as `churro-ocr`. -Install Churro in [Getting Started](getting-started.md), and use -[Providers And Configuration](guides/providers.md) -for backend-specific runtime setup. -## Command Summary +## Install the CLI -| Command | Use it when | -| --- | --- | -| `install` | you want Churro to install an optional runtime into the active UV environment | -| `transcribe` | you want OCR text for one image | -| `extract-pages` | you want page crops from an image or PDF | +Python 3.12 or newer is required. -## `install` Examples +```bash +uv tool install churro-ocr +``` + +If you are adding `churro-ocr` to a project instead, use `uv add churro-ocr` and prefix the commands on this page with `uv run`. + +## Install a Runtime + +Choose the optional runtime that matches the backend or feature you want to use: + +| Target | Command | Use it when | +| --- | --- | --- | +| `hf` | `churro-ocr install hf` | you want local Transformers OCR in-process | +| `llm` | `churro-ocr install llm` | you want hosted multimodal OCR through LiteLLM-backed providers | +| `local` | `churro-ocr install local` | you have a local or self-hosted OpenAI-style server | +| `azure` | `churro-ocr install azure` | you want Azure Document Intelligence OCR or page detection | +| `mistral` | `churro-ocr install mistral` | you want Mistral OCR | +| `pdf` | `churro-ocr install pdf` | you want `extract-pages --pdf` or PDF workflows in Python | +| `all` | `churro-ocr install all` | you want every optional runtime in one environment | -### Install Local Transformers OCR +Use `--torch-backend` with `hf` or `all` when you need a specific PyTorch build: ```bash -churro-ocr install hf +churro-ocr install hf --torch-backend cu126 +``` + +The examples below use the local `hf` path first. +For backend choice and Python setup, continue with [Providers And Configuration](guides/providers.md). + +## First Successful Transcription + +```bash +churro-ocr transcribe \ + --image scan.png \ + --backend hf \ + --model stanford-oval/churro-3B ``` ## `transcribe` Examples -### OCR One Image +### Write OCR Text To A File + +```bash +churro-ocr transcribe \ + --image scan.png \ + --backend hf \ + --model stanford-oval/churro-3B \ + --output output.txt +``` + +This writes the OCR text to `output.txt` and prints that written path to stdout. + +### OCR With LiteLLM ```bash churro-ocr transcribe \ @@ -45,7 +79,8 @@ churro-ocr transcribe \ --base-url http://127.0.0.1:8000/v1 ``` -For vLLM, serve the model separately with its OpenAI-compatible server and then use this same `openai-compatible` route. See the [official vLLM serving docs](https://docs.vllm.ai/en/stable/serving/openai_compatible_server.html). +For vLLM, serve the model separately with its OpenAI-compatible server and then use this same `openai-compatible` route. +See the [official vLLM serving docs](https://docs.vllm.ai/en/stable/serving/openai_compatible_server.html). ## `extract-pages` Examples @@ -72,6 +107,14 @@ churro-ocr extract-pages \ ### Extract Pages From A PDF +Install `pdf` first if you have not already: + +```bash +churro-ocr install pdf +``` + +Then extract rasterized PDF pages as PNG files: + ```bash churro-ocr extract-pages \ --pdf document.pdf \ @@ -80,6 +123,9 @@ churro-ocr extract-pages \ --trim-margin 30 ``` +Use [Page Detection](guides/page-detection.md) when you want the Python API for detection only. +Use [OCR Workflows](guides/ocr-workflows.md) when you want page detection and OCR together in Python. + ## Command Contracts ### `transcribe` Backends @@ -103,6 +149,7 @@ churro-ocr extract-pages \ ## Additional Rules - `transcribe` requires exactly one `--image`. +- `--output` writes OCR text to a file and prints the written path. - `extract-pages` requires exactly one of `--image` or `--pdf`. - `--dpi` only affects the `--pdf` path because PDFs are rasterized before page detection. - `--trim-margin` expands each detected crop by the requested number of pixels, clipped to image bounds. diff --git a/docs/getting-started.md b/docs/getting-started.md index 974af16..f9f8829 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -1,20 +1,16 @@ # Getting Started -`churro-ocr` is the Python package and CLI for running CHURRO-style OCR workflows on one image, photographed spreads, and PDFs. The PyPI package name is `churro-ocr`, and the Python import package is `churro_ocr`. +`churro-ocr` is the Python package and CLI for OCR on one-page images, photographed spreads, and PDFs. +This page takes the shortest path to one successful local transcription before branching into task-specific guides. -## Which API Should You Use? +## Prerequisites -| Goal | API | -| --- | --- | -| OCR one page or one image | `OCRClient` | -| Detect page crops only | `DocumentPageDetector` | -| Run an end-to-end image or PDF OCR workflow | `DocumentOCRPipeline` | -| Tune provider options directly | `build_ocr_backend(...)` + `OCRBackendSpec` | +- Python 3.12 or newer +- `uv` available on `PATH` -## Install +## Install the CLI For the CLI-first workflow used in this guide, install Churro with UV as a tool. -Python 3.12 or newer is required. ```bash uv tool install churro-ocr @@ -22,70 +18,86 @@ uv tool install churro-ocr If you are adding `churro-ocr` to a project instead, use `uv add churro-ocr` and prefix the CLI commands below with `uv run`. -Then install the runtime for the backend you plan to use: +## Install the First Runtime + +The canonical getting-started path uses the local Hugging Face backend and the `stanford-oval/churro-3B` model. ```bash -churro-ocr install llm churro-ocr install hf -churro-ocr install local -churro-ocr install pdf -churro-ocr install all ``` -Common runtime targets: +Use `--torch-backend` with `hf` when you need a specific PyTorch build: -- `llm`: hosted multimodal OCR through LiteLLM-backed providers -- `hf`: local Hugging Face OCR plus a PyTorch runtime -- `local`: clients for OpenAI-compatible local or self-hosted servers -- `pdf`: PDF rasterization support for `process_pdf_*` and `extract-pages --pdf` -- `all`: every optional runtime in one command +```bash +churro-ocr install hf --torch-backend cu126 +``` -Use `--torch-backend` with `hf` or `all` when you need a specific PyTorch build: +For hosted providers, self-hosted OpenAI-compatible servers, Azure, Mistral, or PDF support, continue with [Providers And Configuration](guides/providers.md). + +## First Successful Run ```bash -churro-ocr install hf --torch-backend cu126 +churro-ocr transcribe \ + --image scan.png \ + --backend hf \ + --model stanford-oval/churro-3B ``` -For the full provider/runtime matrix, use [Providers And Configuration](guides/providers.md). +This prints the OCR text to stdout. +Add `--output output.txt` when you want the CLI to write the text to a file instead. + +## If You're Writing Python Next + +| Goal | Start with | +| --- | --- | +| OCR one page or one image | `OCRClient` | +| Detect page crops only | `DocumentPageDetector` | +| Run an end-to-end image or PDF OCR workflow | `DocumentOCRPipeline` | +| Tune provider options directly | `build_ocr_backend(...)` + `OCRBackendSpec` | -## First OCR Example +For the page-and-pipeline mental model behind those types, read [Core Concepts](core-concepts.md). -Use `OCRClient` when your input is already one page per image. -This example uses `provider="litellm"`, so install the `llm` runtime first. +## Where To Go Next -```python -from churro_ocr.ocr import OCRClient -from churro_ocr.providers import OCRBackendSpec, build_ocr_backend +::::{grid} 1 1 2 2 +:gutter: 2 -backend = build_ocr_backend( - OCRBackendSpec( - provider="litellm", - model="vertex_ai/gemini-2.5-flash", - ) -) +:::{grid-item-card} CLI +:link: cli +:link-type: doc -page = OCRClient(backend).ocr_image(image_path="scan.png") +Stay in the shell for OCR checks, page extraction, and runtime installs. +::: -print(page.text) -print(page.provider_name) -print(page.model_name) -``` +:::{grid-item-card} OCR Workflows +:link: guides/ocr-workflows +:link-type: doc -When an API accepts both `image` and `image_path`, pass exactly one of them. +Use the Python API for single-page OCR, PDFs, photographed spreads, and async flows. +::: -## Quick CLI Sanity Check +:::{grid-item-card} Page Detection +:link: guides/page-detection +:link-type: doc -Use the CLI when you want to confirm a model or backend before writing Python code. +Extract page crops without OCR, or choose a detector backend for boundary discovery. +::: -```bash -uv tool install churro-ocr -churro-ocr install hf -churro-ocr transcribe \ - --image scan.png \ - --backend hf \ - --model stanford-oval/churro-3B -``` +:::{grid-item-card} Providers And Configuration +:link: guides/providers +:link-type: doc + +Choose another backend, install its runtime, and see minimal provider setup examples. +::: + +:::{grid-item-card} Core Concepts +:link: core-concepts +:link-type: doc + +Learn the `DocumentPage` and pipeline model that ties the APIs together. +::: +:::: -## Working From A Repo Checkout +## Working From the Source Code If you are developing from a clone instead of installing from PyPI, use the contributor instructions in [Contributing](contributing.md). diff --git a/docs/guides/advanced-customization.md b/docs/guides/advanced-customization.md new file mode 100644 index 0000000..6880f92 --- /dev/null +++ b/docs/guides/advanced-customization.md @@ -0,0 +1,80 @@ +# Advanced Customization + +Most users should rely on the built-in model profiles described in [Providers And Configuration](providers.md). +Use this page when you need to override prompt rendering, work directly with template helpers, or parse model-specific OCR output. + +## Custom `OCRModelProfile` + +If you need to override prompt rendering for a custom Hugging Face model, pass a custom `OCRModelProfile`. + +```python +from churro_ocr.providers import ( + HuggingFaceOptions, + OCRBackendSpec, + OCRModelProfile, + build_ocr_backend, +) +from churro_ocr.templates import HFChatTemplate + +backend = build_ocr_backend( + OCRBackendSpec( + provider="hf", + model="your-org/your-vlm", + profile=OCRModelProfile( + profile_name="custom", + template=HFChatTemplate( + system_message="Transcribe the page exactly.", + user_prompt=None, + ), + ), + options=HuggingFaceOptions(model_kwargs={"device_map": "auto"}), + ) +) +``` + +## Template Exports And Helpers + +Useful public template exports live in `churro_ocr.templates`. +Use the [templates API](../api/templates.md) for exact signatures. + +| Export | Use case | +| --- | --- | +| `HFChatTemplate` | Build a Hugging Face chat-style multimodal prompt. | +| `build_ocr_conversation(...)` | Render a template or template callable into the conversation payload passed to OCR backends. | +| `DEFAULT_OCR_TEMPLATE` | Generic OCR prompt template used by the default model profile. | +| `CHURRO_3B_XML_TEMPLATE` | Built-in template for `stanford-oval/churro-3B`. | +| `CHANDRA_OCR_2_OCR_TEMPLATE` | Built-in template for `datalab-to/chandra-ocr-2`. | +| `DEEPSEEK_OCR_2_OCR_TEMPLATE` | Built-in template for `deepseek-ai/DeepSeek-OCR-2`. | +| `DOTS_OCR_1_5_OCR_TEMPLATE` | Built-in template for `kristaller486/dots.ocr-1.5`. | +| `DOTS_MOCR_OCR_TEMPLATE` | Built-in template for `rednote-hilab/dots.mocr`. | +| `PADDLEOCR_VL_1_5_OCR_TEMPLATE` | Built-in template for `PaddlePaddle/PaddleOCR-VL-1.5`. | +| `OLMOCR_2_7B_1025_OCR_TEMPLATE` | Built-in template for the supported `olmOCR-2-7B-1025` checkpoints. | +| `LFM2_5_VL_1_6B_OCR_TEMPLATE` | Built-in template for `LiquidAI/LFM2.5-VL-1.6B`. | +| `OCRConversation` | Type alias for the rendered multimodal conversation payload. | +| `OCRPromptTemplate` | Base protocol for custom profile integration. | +| `OCRPromptTemplateCallable` | Callable form for dynamic prompt rendering from a `DocumentPage`. | +| `OCRPromptTemplateLike` | Union accepted by helper APIs that can take either a protocol instance or callable template. | + +## Prompt Exports And Response Helpers + +Useful public prompt exports and response helpers live in `churro_ocr.prompts`. +Use the [prompts API](../api/prompts.md) for exact signatures. + +| Export | Use case | +| --- | --- | +| `DEFAULT_OCR_SYSTEM_PROMPT` | Default system instruction for generic OCR prompting. | +| `DEFAULT_OCR_USER_PROMPT` | Default user prompt for plain OCR output. | +| `DEFAULT_MARKDOWN_OCR_USER_PROMPT` | Default user prompt when markdown-style OCR output is preferred. | +| `CHANDRA_OCR_LAYOUT_PROMPT` | Upstream Chandra OCR 2 layout-block HTML prompt. | +| `DEFAULT_OCR_OUTPUT_TAG` | Shared tag name used by the default OCR postprocessor. | +| `DEFAULT_BOUNDARY_DETECTION_PROMPT` | Default prompt used by LLM-based page and text-block boundary detection helpers. | +| `OLMOCR_V4_YAML_PROMPT` | Upstream olmOCR YAML-front-matter prompt used by the built-in olmOCR templates. | +| `parse_chandra_response(...)` | Convert Chandra HTML-layout output to plain text and preserve raw HTML metadata. | +| `parse_olmocr_response(...)` | Convert olmOCR YAML or markdown output into plain text plus parsed metadata. | +| `strip_ocr_output_tag(...)` | Remove the default OCR wrapper tag from model output. | + +## Exact Reference + +- Use the [Provider APIs](../api/providers.md) for `OCRBackendSpec`, `OCRModelProfile`, and provider option dataclasses. +- Use the [templates API](../api/templates.md) for template protocols, conversations, and built-in templates. +- Use the [prompts API](../api/prompts.md) for prompt constants and response-parsing helpers. diff --git a/docs/guides/ocr-workflows.md b/docs/guides/ocr-workflows.md index 69d98e9..735931b 100644 --- a/docs/guides/ocr-workflows.md +++ b/docs/guides/ocr-workflows.md @@ -1,10 +1,21 @@ # OCR Workflows -This page covers the common user-facing flows: single-image OCR, PDFs, multi-page photographed spreads, and async entry points. +Use this page after [Getting Started](../getting-started.md) when you want Python recipes instead of shell commands. +For page crops without OCR, use [Page Detection](page-detection.md). + +## Choose A Workflow + +| Input shape | Start with | Why | +| --- | --- | --- | +| One image already equals one page | `OCRClient` | simplest OCR path with no page detection | +| One image may contain multiple pages | `DocumentOCRPipeline` with a detection backend | page detection and OCR stay in one workflow | +| A PDF | `DocumentOCRPipeline` | rasterization and page OCR stay in one pipeline | +| You only want page crops, not text yet | `DocumentPageDetector` | detection-only workflow | ## OCR One Image Use `OCRClient` when each input image already represents one page. +Install `hf` first if you have not already. ```python from churro_ocr.ocr import OCRClient @@ -12,8 +23,8 @@ from churro_ocr.providers import OCRBackendSpec, build_ocr_backend backend = build_ocr_backend( OCRBackendSpec( - provider="litellm", - model="vertex_ai/gemini-2.5-flash", + provider="hf", + model="stanford-oval/churro-3B", ) ) @@ -24,9 +35,12 @@ print(page.provider_name) print(page.model_name) ``` +When an API accepts both `image` and `image_path`, pass exactly one of them. + ## OCR A PDF -Install the `pdf` runtime first. If you also want local Hugging Face OCR for PDFs, install `all` or install both `hf` and `pdf`. +Install the `pdf` runtime first. +If you also want local Hugging Face OCR for PDFs, install `all` or install both `hf` and `pdf`. ```bash churro-ocr install pdf @@ -41,8 +55,8 @@ from churro_ocr.providers import OCRBackendSpec, build_ocr_backend pipeline = DocumentOCRPipeline( build_ocr_backend( OCRBackendSpec( - provider="litellm", - model="vertex_ai/gemini-2.5-flash", + provider="hf", + model="stanford-oval/churro-3B", ) ), max_concurrency=4, @@ -57,6 +71,7 @@ for page in result.pages: ## Detect Pages And OCR A Photographed Spread This flow is useful when one input image contains multiple pages. +Install `llm` first if you want an LLM-based detector. ```python from pathlib import Path @@ -127,8 +142,8 @@ from churro_ocr.providers import OCRBackendSpec, build_ocr_backend async def main() -> None: backend = build_ocr_backend( OCRBackendSpec( - provider="litellm", - model="vertex_ai/gemini-2.5-flash", + provider="hf", + model="stanford-oval/churro-3B", ) ) page = await OCRClient(backend).aocr_image( @@ -157,14 +172,14 @@ async def main() -> None: pipeline = DocumentOCRPipeline( build_ocr_backend( OCRBackendSpec( - provider="litellm", - model="vertex_ai/gemini-2.5-flash", + provider="hf", + model="stanford-oval/churro-3B", ) ), max_concurrency=4, ) image_result = await pipeline.process_image( - PageDetectionRequest(image_path="spread.jpg", trim_margin=20), + PageDetectionRequest(image_path="scan.png"), ocr_metadata={"job_id": "demo-image"}, ) print(image_result.texts()) diff --git a/docs/guides/page-detection.md b/docs/guides/page-detection.md index 25f2269..535123e 100644 --- a/docs/guides/page-detection.md +++ b/docs/guides/page-detection.md @@ -1,14 +1,25 @@ # Page Detection Use `DocumentPageDetector` when you want page crops without OCR. +For the shell-first path, use [`extract-pages`](../cli.md). +For a combined detection-and-OCR pipeline, use [OCR Workflows](ocr-workflows.md). + +## Requirements + +Start with [Getting Started](../getting-started.md), then install the runtime that matches the detector or input type you want: + +- no extra runtime for the default detector +- `churro-ocr install llm` for `LLMPageDetector` +- `churro-ocr install azure` for `AzurePageDetector` +- `churro-ocr install pdf` when you want to detect pages from PDFs ## Which Detector Should You Use? | Detector | Good default when | | --- | --- | -| none | you want the whole image or rasterized PDF page treated as a single page | -| Azure | you want Azure Document Intelligence to find pages for you | -| LLM | you want a multimodal model to infer page boundaries from an image | +| `none` | you want the whole image or rasterized PDF page treated as a single page | +| `azure` | you want Azure Document Intelligence to find pages for you | +| `llm` | you want a multimodal model to infer page boundaries from an image | ## Default Detector @@ -71,4 +82,8 @@ result = detector.detect_image_sync( - `trim_margin` expands the detected crop by that many pixels and clips the result to the image bounds. - `detect_pdf(...)` rasterizes each PDF page before detection, so `dpi` only affects PDF workflows. -Pair page detection with OCR through [DocumentOCRPipeline](ocr-workflows.md). Use the [API Reference](../api/page_detection.md) when you need exact type definitions. +## Next Steps + +- Use [`extract-pages`](../cli.md) when you want PNG page crops from the shell. +- Pair page detection with OCR through [DocumentOCRPipeline](ocr-workflows.md). +- Use the [API Reference](../api/page_detection.md) when you need exact type definitions. diff --git a/docs/guides/providers.md b/docs/guides/providers.md index d0fa7d0..8b6e250 100644 --- a/docs/guides/providers.md +++ b/docs/guides/providers.md @@ -1,5 +1,8 @@ # Providers And Configuration +Use this page to choose a backend and install the matching runtime. +For custom profiles, prompt templates, and response helpers, continue with [Advanced Customization](advanced-customization.md). + All Churro OCR backends use the same builder entry point: ```python @@ -7,111 +10,70 @@ from churro_ocr.providers import OCRBackendSpec, build_ocr_backend backend = build_ocr_backend( OCRBackendSpec( - provider="litellm", - model="vertex_ai/gemini-2.5-flash", + provider="hf", + model="stanford-oval/churro-3B", ) ) ``` -## Which Runtime Should You Install? +## Runtime Install Matrix Install the base package first as shown in [Getting Started](../getting-started.md). -This page is the source of truth for optional runtime install targets. Commands on this page assume the CLI is installed and available as `churro-ocr`. | Provider or feature | Install command | Good default when | | --- | --- | --- | -| `litellm` | `churro-ocr install llm` | you want hosted multimodal models routed through LiteLLM | +| `litellm` | `churro-ocr install llm` | you want hosted multimodal OCR routed through LiteLLM | | `openai-compatible` | `churro-ocr install local` | you have a local or self-hosted OpenAI-style server | | `hf` | `churro-ocr install hf` | you want local Transformers inference in-process | -| `azure` | `churro-ocr install azure` | you want Azure Document Intelligence OCR | +| `azure` | `churro-ocr install azure` | you want Azure Document Intelligence OCR or page detection | | `mistral` | `churro-ocr install mistral` | you want Mistral OCR | | `pdf` | `churro-ocr install pdf` | you want `process_pdf_*` or `extract-pages --pdf` | | `all` | `churro-ocr install all` | you want every optional runtime in one environment | -`hf` and `all` also install a PyTorch runtime. Pass `--torch-backend ` when you need a specific build, for example `churro-ocr install hf --torch-backend cu126`. +`hf` and `all` also install a PyTorch runtime. +Pass `--torch-backend ` when you need a specific build, for example `churro-ocr install hf --torch-backend cu126`. ## Recommended Starting Points | Situation | Good default | Why | | --- | --- | --- | +| local OCR with no API account | `hf` + `stanford-oval/churro-3B` | matches the quickest credential-free onboarding path | | hosted OCR | `litellm` + `vertex_ai/gemini-2.5-flash` | easiest hosted path with the standard builder interface | -| local OCR | `hf` + `stanford-oval/churro-3B` | first-party local model support in-process | -| layout-heavy local OCR | `hf` + `datalab-to/chandra-ocr-2` | built-in profile matches Chandra's layout-oriented prompt, scaling, and generation defaults | +| layout-heavy local OCR | `hf` + `datalab-to/chandra-ocr-2` | built-in profile matches Chandra's layout-oriented defaults | | higher-throughput local serving | `openai-compatible` + your own OpenAI-style server | good when you already run a served local backend such as vLLM | +| managed OCR APIs | `azure` or `mistral` | provider-managed OCR without local model weights | -## Hosted Providers +## Minimal Provider Examples -### LiteLLM +### Hugging Face ```python from churro_ocr.providers import OCRBackendSpec, build_ocr_backend backend = build_ocr_backend( OCRBackendSpec( - provider="litellm", - model="vertex_ai/gemini-2.5-flash", - ) -) -``` - -Override transport or completion settings when you need to: - -```python -from churro_ocr.providers import LiteLLMTransportConfig, OCRBackendSpec, build_ocr_backend - -backend = build_ocr_backend( - OCRBackendSpec( - provider="litellm", - model="gpt-4.1-mini", - transport=LiteLLMTransportConfig( - api_base="https://example.invalid/v1", - api_key="secret", - api_version="2025-01-01-preview", - completion_kwargs={"temperature": 0}, - ), + provider="hf", + model="stanford-oval/churro-3B", ) ) ``` -### Azure Document Intelligence - -```python -from churro_ocr.providers import ( - AzureDocumentIntelligenceOptions, - OCRBackendSpec, - build_ocr_backend, -) - -backend = build_ocr_backend( - OCRBackendSpec( - provider="azure", - options=AzureDocumentIntelligenceOptions( - endpoint="https://.cognitiveservices.azure.com/", - api_key="", - ), - ) -) -``` +Built-in model-specific profiles are resolved automatically for known models such as `stanford-oval/churro-3B`, `datalab-to/chandra-ocr-2`, `deepseek-ai/DeepSeek-OCR-2`, `kristaller486/dots.ocr-1.5`, `rednote-hilab/dots.mocr`, `PaddlePaddle/PaddleOCR-VL-1.5`, `LiquidAI/LFM2.5-VL-1.6B`, and the supported `olmOCR` checkpoints. -### Mistral OCR +### LiteLLM ```python -from churro_ocr.providers import MistralOptions, OCRBackendSpec, build_ocr_backend +from churro_ocr.providers import OCRBackendSpec, build_ocr_backend backend = build_ocr_backend( OCRBackendSpec( - provider="mistral", - model="mistral-ocr-2512", - options=MistralOptions(api_key=""), + provider="litellm", + model="vertex_ai/gemini-2.5-flash", ) ) ``` -## Local And Self-Hosted Providers - -Before using a local or self-hosted provider, install the matching runtime from the table above. - ### OpenAI-compatible ```python @@ -132,110 +94,46 @@ backend = build_ocr_backend( ) ``` -If you want to use vLLM, serve it separately and point this backend at that server's OpenAI-compatible endpoint. See the [official vLLM serving docs](https://docs.vllm.ai/en/stable/serving/openai_compatible_server.html). +If you want to use vLLM, serve it separately and point this backend at that server's OpenAI-compatible endpoint. +See the [official vLLM serving docs](https://docs.vllm.ai/en/stable/serving/openai_compatible_server.html). -### Hugging Face +### Azure Document Intelligence ```python -from churro_ocr.providers import HuggingFaceOptions, OCRBackendSpec, build_ocr_backend +from churro_ocr.providers import ( + AzureDocumentIntelligenceOptions, + OCRBackendSpec, + build_ocr_backend, +) backend = build_ocr_backend( OCRBackendSpec( - provider="hf", - model="stanford-oval/churro-3B", - options=HuggingFaceOptions( - model_kwargs={"device_map": "auto", "torch_dtype": "auto"}, + provider="azure", + options=AzureDocumentIntelligenceOptions( + endpoint="https://.cognitiveservices.azure.com/", + api_key="", ), ) ) ``` -Built-in model-specific profiles are resolved automatically for known models such as `stanford-oval/churro-3B`, `datalab-to/chandra-ocr-2`, `deepseek-ai/DeepSeek-OCR-2`, `kristaller486/dots.ocr-1.5`, `rednote-hilab/dots.mocr`, `PaddlePaddle/PaddleOCR-VL-1.5`, `LiquidAI/LFM2.5-VL-1.6B`, and the supported `olmOCR` checkpoints. - -## `OCRBackendSpec` Reference - -| Field | Meaning | -| --- | --- | -| `provider` | One of `litellm`, `openai-compatible`, `azure`, `mistral`, or `hf`. | -| `model` | Required for `litellm`, `openai-compatible`, `mistral`, and `hf`. Optional for `azure`. For `mistral`, use one of `mistral-ocr-2505` or `mistral-ocr-2512`. | -| `profile` | `None`, a built-in profile name, or a custom `OCRModelProfile`. | -| `transport` | Shared request transport config for LiteLLM-based providers. | -| `options` | Provider-specific dataclass matching `provider`. | - -### Provider Option Dataclasses - -| Type | Used by | Required fields | Notes | -| --- | --- | --- | --- | -| `LiteLLMTransportConfig` | `litellm`, `openai-compatible`, `LLMPageDetector` | None at the dataclass level | Use this for transport, credentials, and completion settings. `api_base` is required for `openai-compatible`; `api_key` is optional. | -| `OpenAICompatibleOptions` | `openai-compatible` | None | Use `model_prefix` when your local server expects a provider prefix. | -| `HuggingFaceOptions` | `hf` | None | Carries runtime, processor, generation, and template options. | -| `AzureDocumentIntelligenceOptions` | `azure` | `endpoint`, `api_key` | `model` is optional for Azure OCR in `OCRBackendSpec`. | -| `MistralOptions` | `mistral` | `api_key` | `model` is required and must be `mistral-ocr-2505` or `mistral-ocr-2512`. | - -## Advanced Customization - -### Custom Profiles And Templates - -Most users should rely on the built-in model profiles. If you need to override prompt rendering for a custom Hugging Face model, pass a custom `OCRModelProfile`. +### Mistral OCR ```python -from churro_ocr.providers import ( - HuggingFaceOptions, - OCRBackendSpec, - OCRModelProfile, - build_ocr_backend, -) -from churro_ocr.templates import HFChatTemplate +from churro_ocr.providers import MistralOptions, OCRBackendSpec, build_ocr_backend backend = build_ocr_backend( OCRBackendSpec( - provider="hf", - model="your-org/your-vlm", - profile=OCRModelProfile( - profile_name="custom", - template=HFChatTemplate( - system_message="Transcribe the page exactly.", - user_prompt=None, - ), - ), - options=HuggingFaceOptions(model_kwargs={"device_map": "auto"}), + provider="mistral", + model="mistral-ocr-2512", + options=MistralOptions(api_key=""), ) ) ``` -### Prompt And Template Exports - -Useful public template exports and helpers: +## Next Steps -| Export | Module | Use case | -| --- | --- | --- | -| `HFChatTemplate` | `churro_ocr.templates` | Build a Hugging Face chat-style multimodal prompt. | -| `build_ocr_conversation(...)` | `churro_ocr.templates` | Render a template or template callable into the conversation payload passed to OCR backends. | -| `DEFAULT_OCR_TEMPLATE` | `churro_ocr.templates` | Generic OCR prompt template used by the default model profile. | -| `CHURRO_3B_XML_TEMPLATE` | `churro_ocr.templates` | Built-in template for `stanford-oval/churro-3B`. | -| `CHANDRA_OCR_2_OCR_TEMPLATE` | `churro_ocr.templates` | Built-in template for `datalab-to/chandra-ocr-2`. | -| `DEEPSEEK_OCR_2_OCR_TEMPLATE` | `churro_ocr.templates` | Built-in template for `deepseek-ai/DeepSeek-OCR-2`. | -| `DOTS_OCR_1_5_OCR_TEMPLATE` | `churro_ocr.templates` | Built-in template for `kristaller486/dots.ocr-1.5`. | -| `DOTS_MOCR_OCR_TEMPLATE` | `churro_ocr.templates` | Built-in template for `rednote-hilab/dots.mocr`. | -| `PADDLEOCR_VL_1_5_OCR_TEMPLATE` | `churro_ocr.templates` | Built-in template for `PaddlePaddle/PaddleOCR-VL-1.5`. | -| `OLMOCR_2_7B_1025_OCR_TEMPLATE` | `churro_ocr.templates` | Built-in template for the supported `olmOCR-2-7B-1025` checkpoints. | -| `LFM2_5_VL_1_6B_OCR_TEMPLATE` | `churro_ocr.templates` | Built-in template for `LiquidAI/LFM2.5-VL-1.6B`. | -| `OCRConversation` | `churro_ocr.templates` | Type alias for the rendered multimodal conversation payload. | -| `OCRPromptTemplate` | `churro_ocr.templates` | Base protocol for custom profile integration. | -| `OCRPromptTemplateCallable` | `churro_ocr.templates` | Callable form for dynamic prompt rendering from a `DocumentPage`. | -| `OCRPromptTemplateLike` | `churro_ocr.templates` | Union accepted by helper APIs that can take either a protocol instance or callable template. | - -Useful public prompt exports: - -| Export | Module | Use case | -| --- | --- | --- | -| `DEFAULT_OCR_SYSTEM_PROMPT` | `churro_ocr.prompts` | Default system instruction for generic OCR prompting. | -| `DEFAULT_OCR_USER_PROMPT` | `churro_ocr.prompts` | Default user prompt for plain OCR output. | -| `DEFAULT_MARKDOWN_OCR_USER_PROMPT` | `churro_ocr.prompts` | Default user prompt when markdown-style OCR output is preferred. | -| `CHANDRA_OCR_LAYOUT_PROMPT` | `churro_ocr.prompts` | Upstream Chandra OCR 2 layout-block HTML prompt. | -| `DEFAULT_OCR_OUTPUT_TAG` | `churro_ocr.prompts` | Shared tag name used by the default OCR postprocessor. | -| `DEFAULT_BOUNDARY_DETECTION_PROMPT` | `churro_ocr.prompts` | Default prompt used by LLM-based page and text-block boundary detection helpers. | -| `OLMOCR_V4_YAML_PROMPT` | `churro_ocr.prompts` | Upstream olmOCR YAML-front-matter prompt used by the built-in olmOCR templates. | -| `parse_chandra_response(...)` | `churro_ocr.prompts` | Convert Chandra HTML-layout output to plain text and preserve raw HTML metadata. | -| `parse_olmocr_response(...)` | `churro_ocr.prompts` | Convert olmOCR YAML/markdown output into plain text plus parsed metadata. | -| `strip_ocr_output_tag(...)` | `churro_ocr.prompts` | Remove the default OCR wrapper tag from model output. | +- Use [OCR Workflows](ocr-workflows.md) for Python recipes built on these backends. +- Use [CLI](../cli.md) for shell commands, quick checks, and page extraction. +- Use [Advanced Customization](advanced-customization.md) for custom `OCRModelProfile` work, prompt/template exports, and response helpers. +- Use the [Provider APIs](../api/providers.md), [templates API](../api/templates.md), and [prompts API](../api/prompts.md) when you need exact type definitions and signatures. diff --git a/docs/index.md b/docs/index.md index 51daa0b..8e8cc25 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,10 +1,81 @@ -```{include} ../README.md -:end-before: "## Citation" +# Churro OCR Documentation + +Churro OCR is a Python 3.12+ OCR toolkit for historical document transcription. +The docs start with a CLI-first local workflow, then branch into task guides and deeper reference material. + +## Quick Start + +```bash +uv tool install churro-ocr +churro-ocr install hf +churro-ocr transcribe \ + --image scan.png \ + --backend hf \ + --model stanford-oval/churro-3B ``` +Use [Getting Started](getting-started.md) for the full setup flow and first-run guidance. + +## Start Here + +::::{grid} 1 1 2 2 +:gutter: 2 + +:::{grid-item-card} Getting Started +:link: getting-started +:link-type: doc + +Install the CLI, install the first runtime, and verify one successful OCR run. +::: +:::: + +## Common Tasks + +::::{grid} 1 1 2 2 +:gutter: 2 + +:::{grid-item-card} CLI +:link: cli +:link-type: doc + +Run `transcribe`, `extract-pages`, and runtime installs from the shell. +::: + +:::{grid-item-card} OCR Workflows +:link: guides/ocr-workflows +:link-type: doc + +Choose the right Python workflow for single-page images, PDFs, and photographed spreads. +::: + +:::{grid-item-card} Page Detection +:link: guides/page-detection +:link-type: doc + +Extract page crops without OCR, or choose a detector backend for layout discovery. +::: + +:::{grid-item-card} Providers And Configuration +:link: guides/providers +:link-type: doc + +Choose a backend, install its runtime, and see minimal provider setup examples. +::: +:::: + +## Learn More + +- [Benchmark Snapshot](leaderboard.md) +- [Benchmarking Guide](benchmarking.md) +- [Paper](https://arxiv.org/abs/2509.19768) +- [Dataset](https://huggingface.co/datasets/stanford-oval/churro-dataset) +- [Model](https://huggingface.co/stanford-oval/churro-3B) +- [GitHub Repository](https://github.com/stanford-oval/Churro) + ```{toctree} :hidden: :maxdepth: 1 +:caption: Start Here Overview Getting Started @@ -13,20 +84,21 @@ Getting Started ```{toctree} :hidden: :maxdepth: 1 -:caption: Use CHURRO +:caption: Common Tasks +cli guides/ocr-workflows guides/page-detection guides/providers -cli ``` ```{toctree} :hidden: :maxdepth: 1 -:caption: Concepts +:caption: Advanced core-concepts +guides/advanced-customization guides/historical-document-xml ``` @@ -42,7 +114,15 @@ benchmarking ```{toctree} :hidden: :maxdepth: 1 +:caption: Reference api/index +``` + +```{toctree} +:hidden: +:maxdepth: 1 +:caption: Project + contributing ``` From acd721e7422fa5dad97e76e5f7b89afbc56b4222 Mon Sep 17 00:00:00 2001 From: Sina Date: Sun, 12 Apr 2026 17:31:01 +0000 Subject: [PATCH 04/58] docs(repo): clarify AGENTS workflow guidance Document the Python 3.12+ version requirement and require docs review plus relevant Pixi checks after changes. --- AGENTS.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index ce4df58..b614b85 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # AGENTS -This repo is `churro-ocr`, a Python 3.12 OCR and page-detection toolkit for historical documents. +This repo is `churro-ocr`, a Python 3.12+ OCR and page-detection toolkit for historical documents. ## Repo Layout @@ -23,6 +23,8 @@ pixi run docs-build pixi run package-check ``` +After each change, review docs to see if it needs updating and run the relevant Pixi tasks to check your work. + ## Guardrails - Prefer the Pixi tasks above over ad hoc commands. From 0280bf0cce1d2cfc1f700528552543e629bf6c9a Mon Sep 17 00:00:00 2001 From: Sina Date: Mon, 13 Apr 2026 00:03:52 +0000 Subject: [PATCH 05/58] docs(benchmark): add icons for Nanonets and PaddleOCR models --- benchmark_results.json | 4 ++-- docs/_static/img/nanonets-logo.png | Bin 0 -> 22509 bytes docs/_static/img/paddleocr-logo.png | Bin 0 -> 24585 bytes 3 files changed, 2 insertions(+), 2 deletions(-) create mode 100644 docs/_static/img/nanonets-logo.png create mode 100644 docs/_static/img/paddleocr-logo.png diff --git a/benchmark_results.json b/benchmark_results.json index 0ccccbe..908eddd 100644 --- a/benchmark_results.json +++ b/benchmark_results.json @@ -168,7 +168,7 @@ "modelName": "Nanonets OCR", "modelId": "nanonets/Nanonets-OCR-s", "modelUrl": "https://huggingface.co/nanonets/Nanonets-OCR-s", - "iconPath": null, + "iconPath": "_static/img/nanonets-logo.png", "printed": 69.7033, "handwritten": 43.1781, "total": 55.4205 @@ -261,7 +261,7 @@ "modelName": "PaddleOCR-VL 1.5", "modelId": "PaddlePaddle/PaddleOCR-VL-1.5", "modelUrl": "https://huggingface.co/PaddlePaddle/PaddleOCR-VL-1.5", - "iconPath": null, + "iconPath": "_static/img/paddleocr-logo.png", "printed": 62.6, "handwritten": 33.6, "total": 47.0 diff --git a/docs/_static/img/nanonets-logo.png b/docs/_static/img/nanonets-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..51722e482edee31168c33cc38d04dd773ab61013 GIT binary patch literal 22509 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 literal 0 HcmV?d00001 diff --git a/docs/_static/img/paddleocr-logo.png b/docs/_static/img/paddleocr-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..021dabd6d15f6f1c2b16ddd90ec0f4b19d47a2a2 GIT binary patch literal 24585 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 Yw40J^i* Date: Mon, 13 Apr 2026 00:04:14 +0000 Subject: [PATCH 06/58] docs(benchmark): add Qwen 3.5 9B model results to benchmark leaderboard --- benchmark_results.json | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/benchmark_results.json b/benchmark_results.json index 908eddd..7db51ac 100644 --- a/benchmark_results.json +++ b/benchmark_results.json @@ -173,6 +173,15 @@ "handwritten": 43.1781, "total": 55.4205 }, + { + "modelName": "Qwen 3.5 (9B)", + "modelId": "Qwen/Qwen3.5-9B", + "modelUrl": "https://huggingface.co/Qwen/Qwen3.5-9B", + "iconPath": "_static/img/qwen-logo.svg", + "printed": 69.1, + "handwritten": 42.4, + "total": 54.7 + }, { "modelName": "olmOCR", "modelId": "allenai/olmOCR-7B-0825", From 6b60e347473d5b192a96f9d6af151b94a217acda Mon Sep 17 00:00:00 2001 From: Sina Date: Mon, 13 Apr 2026 00:16:16 +0000 Subject: [PATCH 07/58] docs(benchmark): add DeepSeek OCR 2 model icon --- benchmark_results.json | 2 +- docs/_static/img/deepseek-logo.png | Bin 0 -> 17195 bytes 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 docs/_static/img/deepseek-logo.png diff --git a/benchmark_results.json b/benchmark_results.json index 7db51ac..3ba3891 100644 --- a/benchmark_results.json +++ b/benchmark_results.json @@ -383,7 +383,7 @@ "modelName": "DeepSeek OCR 2", "modelId": "deepseek-ai/DeepSeek-OCR-2", "modelUrl": "https://huggingface.co/deepseek-ai/DeepSeek-OCR-2", - "iconPath": null, + "iconPath": "_static/img/deepseek-logo.png", "printed": 56.1, "handwritten": 20.0, "total": 36.7 diff --git a/docs/_static/img/deepseek-logo.png b/docs/_static/img/deepseek-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..2d3c3b8db4363ccf4b90504992a4c1f8ceff271b GIT binary patch literal 17195 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 J%Q~loCIIUiyPp66 literal 0 HcmV?d00001 From 1fe2f5d224f2d0245bc95e1ab6bf837db0583b16 Mon Sep 17 00:00:00 2001 From: Sina Date: Mon, 13 Apr 2026 00:16:27 +0000 Subject: [PATCH 08/58] docs: update documentation to include llama.cpp alongside vLLM for model serving --- docs/benchmarking.md | 2 +- docs/cli.md | 4 ++-- docs/guides/providers.md | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/benchmarking.md b/docs/benchmarking.md index f21609a..934bec4 100644 --- a/docs/benchmarking.md +++ b/docs/benchmarking.md @@ -49,7 +49,7 @@ That means for example `--language Arabic --offset 100 --input-size 50` selects ## Example Commands -If you want to benchmark a model using vLLM, run a vLLM server separately and point `--backend openai-compatible` at its OpenAI-compatible endpoint. See the [official vLLM serving docs](https://docs.vllm.ai/en/stable/serving/openai_compatible_server.html). +If you want to benchmark a model using vLLM or llama.cpp, run the server separately and point `--backend openai-compatible` at its OpenAI-compatible endpoint. See the [official vLLM serving docs](https://docs.vllm.ai/en/stable/serving/openai_compatible_server.html) or the [official llama.cpp serving docs](https://github.com/ggml-org/llama.cpp/blob/master/tools/server/README.md). | Model | Model ID | Backend | Full command | | --- | --- | --- | --- | diff --git a/docs/cli.md b/docs/cli.md index 2434e29..a61041e 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -79,8 +79,8 @@ churro-ocr transcribe \ --base-url http://127.0.0.1:8000/v1 ``` -For vLLM, serve the model separately with its OpenAI-compatible server and then use this same `openai-compatible` route. -See the [official vLLM serving docs](https://docs.vllm.ai/en/stable/serving/openai_compatible_server.html). +For vLLM or llama.cpp, serve the model separately with its OpenAI-compatible server and then use this same `openai-compatible` route. +See the [official vLLM serving docs](https://docs.vllm.ai/en/stable/serving/openai_compatible_server.html) or the [official llama.cpp serving docs](https://github.com/ggml-org/llama.cpp/blob/master/tools/server/README.md). ## `extract-pages` Examples diff --git a/docs/guides/providers.md b/docs/guides/providers.md index 8b6e250..50a9b9b 100644 --- a/docs/guides/providers.md +++ b/docs/guides/providers.md @@ -41,7 +41,7 @@ Pass `--torch-backend ` when you need a specific build, for example `churr | local OCR with no API account | `hf` + `stanford-oval/churro-3B` | matches the quickest credential-free onboarding path | | hosted OCR | `litellm` + `vertex_ai/gemini-2.5-flash` | easiest hosted path with the standard builder interface | | layout-heavy local OCR | `hf` + `datalab-to/chandra-ocr-2` | built-in profile matches Chandra's layout-oriented defaults | -| higher-throughput local serving | `openai-compatible` + your own OpenAI-style server | good when you already run a served local backend such as vLLM | +| higher-throughput local serving | `openai-compatible` + your own OpenAI-style server | good when you already run a served local backend such as vLLM or llama.cpp | | managed OCR APIs | `azure` or `mistral` | provider-managed OCR without local model weights | ## Minimal Provider Examples @@ -94,8 +94,8 @@ backend = build_ocr_backend( ) ``` -If you want to use vLLM, serve it separately and point this backend at that server's OpenAI-compatible endpoint. -See the [official vLLM serving docs](https://docs.vllm.ai/en/stable/serving/openai_compatible_server.html). +If you want to use vLLM or llama.cpp, serve it separately and point this backend at that server's OpenAI-compatible endpoint. +See the [official vLLM serving docs](https://docs.vllm.ai/en/stable/serving/openai_compatible_server.html) or the [official llama.cpp serving docs](https://github.com/ggml-org/llama.cpp/blob/master/tools/server/README.md). ### Azure Document Intelligence From 44d6c50e48a20f52c6efbad87ee1684589e8472f Mon Sep 17 00:00:00 2001 From: Sina Date: Mon, 13 Apr 2026 06:04:12 +0000 Subject: [PATCH 09/58] feat(providers): add MinerU2.5 OCR backends Replace the earlier MinerU integration with the MinerU2.5 two-step pipeline for both Hugging Face and openai-compatible/vLLM providers. Add the supporting prompts, templates, CLI wiring, evaluation normalization, documentation, and regression coverage for the new backend flow. --- docs/guides/advanced-customization.md | 6 + docs/guides/providers.md | 6 +- src/churro_ocr/cli.py | 5 +- src/churro_ocr/prompts/__init__.py | 6 +- src/churro_ocr/prompts/ocr.py | 6 +- src/churro_ocr/providers/_mineru25.py | 1261 +++++++++++++++++++++++++ src/churro_ocr/providers/builder.py | 15 +- src/churro_ocr/providers/hf.py | 377 +++++++- src/churro_ocr/providers/ocr.py | 220 ++++- src/churro_ocr/providers/specs.py | 22 + src/churro_ocr/templates/__init__.py | 24 + src/churro_ocr/templates/presets.py | 39 + tests/test_cli.py | 39 + tests/test_hf_ocr.py | 360 ++++++- tests/test_providers.py | 146 ++- tests/test_tooling_benchmark.py | 48 +- tests/test_tooling_support.py | 12 + tooling/evaluation/normalization.py | 9 +- 18 files changed, 2573 insertions(+), 28 deletions(-) create mode 100644 src/churro_ocr/providers/_mineru25.py diff --git a/docs/guides/advanced-customization.md b/docs/guides/advanced-customization.md index 6880f92..bb2ae81 100644 --- a/docs/guides/advanced-customization.md +++ b/docs/guides/advanced-customization.md @@ -47,6 +47,11 @@ Use the [templates API](../api/templates.md) for exact signatures. | `DEEPSEEK_OCR_2_OCR_TEMPLATE` | Built-in template for `deepseek-ai/DeepSeek-OCR-2`. | | `DOTS_OCR_1_5_OCR_TEMPLATE` | Built-in template for `kristaller486/dots.ocr-1.5`. | | `DOTS_MOCR_OCR_TEMPLATE` | Built-in template for `rednote-hilab/dots.mocr`. | +| `MINERU2_5_2509_1_2B_OCR_TEMPLATE` | Built-in OCR template for `opendatalab/MinerU2.5-2509-1.2B`. | +| `MINERU2_5_2509_1_2B_LAYOUT_TEMPLATE` | MinerU2.5 layout-detection prompt template used by the built-in two-step backends. | +| `MINERU2_5_2509_1_2B_TABLE_TEMPLATE` | MinerU2.5 table-recognition prompt template used for block extraction. | +| `MINERU2_5_2509_1_2B_FORMULA_TEMPLATE` | MinerU2.5 formula-recognition prompt template used for block extraction. | +| `MINERU2_5_2509_1_2B_IMAGE_ANALYSIS_TEMPLATE` | MinerU2.5 image-analysis prompt template used for image and chart blocks. | | `PADDLEOCR_VL_1_5_OCR_TEMPLATE` | Built-in template for `PaddlePaddle/PaddleOCR-VL-1.5`. | | `OLMOCR_2_7B_1025_OCR_TEMPLATE` | Built-in template for the supported `olmOCR-2-7B-1025` checkpoints. | | `LFM2_5_VL_1_6B_OCR_TEMPLATE` | Built-in template for `LiquidAI/LFM2.5-VL-1.6B`. | @@ -71,6 +76,7 @@ Use the [prompts API](../api/prompts.md) for exact signatures. | `OLMOCR_V4_YAML_PROMPT` | Upstream olmOCR YAML-front-matter prompt used by the built-in olmOCR templates. | | `parse_chandra_response(...)` | Convert Chandra HTML-layout output to plain text and preserve raw HTML metadata. | | `parse_olmocr_response(...)` | Convert olmOCR YAML or markdown output into plain text plus parsed metadata. | +| `strip_rich_ocr_markup_to_plain_text(...)` | Normalize markdown or HTML OCR output into plain text without model-specific metadata parsing. | | `strip_ocr_output_tag(...)` | Remove the default OCR wrapper tag from model output. | ## Exact Reference diff --git a/docs/guides/providers.md b/docs/guides/providers.md index 50a9b9b..a55eedc 100644 --- a/docs/guides/providers.md +++ b/docs/guides/providers.md @@ -59,7 +59,11 @@ backend = build_ocr_backend( ) ``` -Built-in model-specific profiles are resolved automatically for known models such as `stanford-oval/churro-3B`, `datalab-to/chandra-ocr-2`, `deepseek-ai/DeepSeek-OCR-2`, `kristaller486/dots.ocr-1.5`, `rednote-hilab/dots.mocr`, `PaddlePaddle/PaddleOCR-VL-1.5`, `LiquidAI/LFM2.5-VL-1.6B`, and the supported `olmOCR` checkpoints. +Built-in model-specific profiles are resolved automatically for known models such as `stanford-oval/churro-3B`, `datalab-to/chandra-ocr-2`, `deepseek-ai/DeepSeek-OCR-2`, `kristaller486/dots.ocr-1.5`, `rednote-hilab/dots.mocr`, `opendatalab/MinerU2.5-2509-1.2B`, `PaddlePaddle/PaddleOCR-VL-1.5`, `LiquidAI/LFM2.5-VL-1.6B`, and the supported `olmOCR` checkpoints. + +For `opendatalab/MinerU2.5-2509-1.2B`, the built-in `hf` and `openai-compatible` backends both run the model's two-step layout-plus-block pipeline and return markdown with embedded HTML tables when needed. Repo-local benchmark evaluation normalizes that markdown or HTML back to plain text before metrics are computed. + +Use `provider="openai-compatible"` when you want to point Churro at a served OpenAI-style endpoint such as vLLM. The generic `litellm` provider is intentionally not supported for this model because MinerU2.5 needs multiple prompt stages instead of a single OCR call. ### LiteLLM diff --git a/src/churro_ocr/cli.py b/src/churro_ocr/cli.py index 9a00b21..70f061c 100644 --- a/src/churro_ocr/cli.py +++ b/src/churro_ocr/cli.py @@ -29,9 +29,8 @@ app = typer.Typer(help="churro-ocr library-first CLI") _INSTALL_TARGET_METAVAR = "{" + "|".join(INSTALL_TARGETS) + "}" -_MISTRAL_MODEL_OPTION_ERROR = ( - "--model is required for backend=mistral and must be one of: " - + ", ".join(MISTRAL_OCR_MODEL_IDS) +_MISTRAL_MODEL_OPTION_ERROR = "--model is required for backend=mistral and must be one of: " + ", ".join( + MISTRAL_OCR_MODEL_IDS ) diff --git a/src/churro_ocr/prompts/__init__.py b/src/churro_ocr/prompts/__init__.py index 19db07e..170e0b5 100644 --- a/src/churro_ocr/prompts/__init__.py +++ b/src/churro_ocr/prompts/__init__.py @@ -1,8 +1,6 @@ """Public prompt defaults used by churro-ocr backends.""" -from churro_ocr.prompts.layout import ( - DEFAULT_BOUNDARY_DETECTION_PROMPT, -) +from churro_ocr.prompts.layout import DEFAULT_BOUNDARY_DETECTION_PROMPT from churro_ocr.prompts.ocr import ( CHANDRA_OCR_LAYOUT_PROMPT, DEFAULT_MARKDOWN_OCR_USER_PROMPT, @@ -13,6 +11,7 @@ parse_chandra_response, parse_olmocr_response, strip_ocr_output_tag, + strip_rich_ocr_markup_to_plain_text, ) __all__ = [ @@ -25,5 +24,6 @@ "OLMOCR_V4_YAML_PROMPT", "parse_chandra_response", "parse_olmocr_response", + "strip_rich_ocr_markup_to_plain_text", "strip_ocr_output_tag", ] diff --git a/src/churro_ocr/prompts/ocr.py b/src/churro_ocr/prompts/ocr.py index a888d4a..6729ff2 100644 --- a/src/churro_ocr/prompts/ocr.py +++ b/src/churro_ocr/prompts/ocr.py @@ -153,7 +153,7 @@ def _extract_yaml_front_matter(text: str) -> tuple[dict[str, object], str]: return front_matter, body -def _strip_rich_ocr_markup_to_plain_text(text: str) -> str: +def strip_rich_ocr_markup_to_plain_text(text: str) -> str: """Best-effort plain-text conversion for OCR markdown/HTML output.""" cleaned = text.strip() if not cleaned: @@ -210,7 +210,7 @@ def _strip_rich_ocr_markup_to_plain_text(text: str) -> str: def parse_olmocr_response(text: str) -> tuple[str, dict[str, Any]]: """Extract plain text and metadata from an olmOCR YAML-front-matter response.""" front_matter, markdown_body = _extract_yaml_front_matter(text) - return _strip_rich_ocr_markup_to_plain_text(markdown_body), { + return strip_rich_ocr_markup_to_plain_text(markdown_body), { "front_matter": front_matter, "raw_markdown": markdown_body, } @@ -219,6 +219,6 @@ def parse_olmocr_response(text: str) -> tuple[str, dict[str, Any]]: def parse_chandra_response(text: str) -> tuple[str, dict[str, Any]]: """Extract plain text and metadata from a Chandra HTML-layout response.""" raw_html = text.strip() - return _strip_rich_ocr_markup_to_plain_text(raw_html), { + return strip_rich_ocr_markup_to_plain_text(raw_html), { "raw_html": raw_html, } diff --git a/src/churro_ocr/providers/_mineru25.py b/src/churro_ocr/providers/_mineru25.py new file mode 100644 index 0000000..19e971e --- /dev/null +++ b/src/churro_ocr/providers/_mineru25.py @@ -0,0 +1,1261 @@ +"""Helpers for MinerU2.5 two-step OCR pipelines.""" + +from __future__ import annotations + +import asyncio +import base64 +import html +import itertools +import math +import random +import re +from collections.abc import Awaitable, Callable +from dataclasses import dataclass, replace +from io import BytesIO +from typing import Literal, cast + +from PIL import Image, ImageDraw, ImageFont + +MINERU2_5_LAYOUT_IMAGE_SIZE = (1_036, 1_036) +MINERU2_5_MIN_IMAGE_EDGE = 28 +MINERU2_5_MAX_IMAGE_EDGE_RATIO = 50 +MINERU2_5_STOP_TOKENS = ("<|im_end|>", "<|endoftext|>") +MINERU2_5_PARATEXT_TYPES = { + "header", + "footer", + "page_number", + "aside_text", + "page_footnote", + "unknown", +} +_ANGLE_MAPPING: dict[str, Literal[0, 90, 180, 270]] = { + "<|rotate_up|>": 0, + "<|rotate_right|>": 90, + "<|rotate_down|>": 180, + "<|rotate_left|>": 270, +} +_LAYOUT_RE = re.compile( + r"<\|box_start\|>(\d+)\s+(\d+)\s+(\d+)\s+(\d+)" + r"<\|box_end\|><\|ref_start\|>(\w+?)<\|ref_end\|>" + r"(?:(<\|rotate_(?:up|right|down|left)\|>))?" + r"(.*?)(?=<\|box_start\|>|$)", + flags=re.DOTALL, +) +_CHAT_ROLE_PREFIXES = { + "assistant", + "assistant:", + "user", + "user:", + "system", + "system:", + "", + "", + "", + "<|assistant|>", + "<|assistant|>:", + "<|user|>", + "<|user|>:", + "<|system|>", + "<|system|>:", + "<|assistant|>", + "<|assistant|>:", + "<|user|>", + "<|user|>:", + "<|system|>", + "<|system|>:", +} +_TABLE_IMAGE_TOKEN_TEMPLATE = "[{idx}]" +_TABLE_IMAGE_TOKEN_LETTERS = "ACDGHKTWXYZ" +_TABLE_IMAGE_TOKEN_NUMBERS = "2345678" +_TABLE_IMAGE_TOKEN_LENGTH = 4 +_TABLE_IMAGE_TOKEN_CHARS = _TABLE_IMAGE_TOKEN_LETTERS + _TABLE_IMAGE_TOKEN_NUMBERS +_TABLE_IMAGE_TOKEN_MAP_KEY = "_table_image_token_map" +_TABLE_IMAGE_ABSORBED_KEY = "_absorbed_by_table" +_FONT_PATH_CANDIDATES = [ + "C:/Windows/Fonts/arial.ttf", + "/System/Library/Fonts/Supplemental/Arial.ttf", + "/Library/Fonts/Arial.ttf", + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + "/usr/share/fonts/dejavu/DejaVuSans.ttf", +] +_OTSL_NL = "" +_OTSL_FCEL = "" +_OTSL_ECEL = "" +_OTSL_LCEL = "" +_OTSL_UCEL = "" +_OTSL_XCEL = "" +_OTSL_TOKENS = {_OTSL_NL, _OTSL_FCEL, _OTSL_ECEL, _OTSL_LCEL, _OTSL_UCEL, _OTSL_XCEL} +_OTSL_PATTERN = re.compile( + "(" + + "|".join( + re.escape(token) for token in (_OTSL_NL, _OTSL_FCEL, _OTSL_ECEL, _OTSL_LCEL, _OTSL_UCEL, _OTSL_XCEL) + ) + + ")" +) +_IMAGE_ANALYSIS_TYPES = {"image", "chart"} +_IMAGE_CAPTION_CONTAINER_TYPES = {"image", "chart", "image_block"} +_INTERNAL_BLOCK_THRESHOLD = 0.9 +_SUPPORTED_BLOCK_TYPES = { + "text", + "title", + "table", + "equation", + "code", + "algorithm", + "aside_text", + "ref_text", + "phonetic", + "list_item", + "table_caption", + "image_caption", + "code_caption", + "table_footnote", + "image_footnote", + "header", + "footer", + "page_number", + "page_footnote", + "image", + "chart", + "list", + "image_block", + "equation_block", + "unknown", +} + + +@dataclass(slots=True, frozen=True) +class MinerU25SamplingParams: + """Sampling parameters used by the MinerU2.5 two-step pipeline.""" + + temperature: float | None = 0.0 + top_p: float | None = 0.01 + top_k: int | None = 1 + presence_penalty: float | None = 0.0 + frequency_penalty: float | None = 0.0 + repetition_penalty: float | None = 1.0 + no_repeat_ngram_size: int | None = 100 + max_new_tokens: int | None = None + + +DEFAULT_MINERU2_5_SAMPLING_PARAMS: dict[str, MinerU25SamplingParams] = { + "table": MinerU25SamplingParams(presence_penalty=1.0, frequency_penalty=0.005), + "equation": MinerU25SamplingParams(presence_penalty=1.0, frequency_penalty=0.05), + "image": MinerU25SamplingParams(presence_penalty=1.0, frequency_penalty=0.05), + "chart": MinerU25SamplingParams(presence_penalty=1.0, frequency_penalty=0.05), + "[default]": MinerU25SamplingParams(presence_penalty=1.0, frequency_penalty=0.05), + "[layout]": MinerU25SamplingParams(), +} + + +class MinerU25ContentBlock(dict[str, object]): + """Dictionary-backed content block compatible with MinerU-style postprocessing.""" + + def __init__( + self, + type: str, + bbox: list[float], + angle: Literal[None, 0, 90, 180, 270] = None, + content: str | None = None, + merge_prev: bool = False, + ) -> None: + super().__init__() + if type not in _SUPPORTED_BLOCK_TYPES: + raise ValueError(f"Unknown MinerU2.5 block type {type!r}.") + if len(bbox) != 4 or bbox[0] >= bbox[2] or bbox[1] >= bbox[3]: + raise ValueError(f"Invalid MinerU2.5 bbox {bbox!r}.") + self["type"] = type + self["bbox"] = bbox + self["angle"] = angle + self["content"] = content + if type == "text": + self["merge_prev"] = merge_prev + + @property + def type(self) -> str: + return str(self["type"]) + + @type.setter + def type(self, value: str) -> None: + if value not in _SUPPORTED_BLOCK_TYPES: + raise ValueError(f"Unknown MinerU2.5 block type {value!r}.") + merge_prev = self.get("merge_prev", False) + self["type"] = value + if value == "text": + self["merge_prev"] = bool(merge_prev) + else: + self.pop("merge_prev", None) + + @property + def bbox(self) -> list[float]: + bbox = self["bbox"] + if not isinstance(bbox, list): + raise TypeError(f"MinerU2.5 bbox payload must be a list, got {type(bbox).__name__}.") + return [float(coord) for coord in cast("list[int | float]", bbox)] + + @bbox.setter + def bbox(self, value: list[float]) -> None: + self["bbox"] = value + + @property + def angle(self) -> Literal[None, 0, 90, 180, 270]: + return cast("Literal[None, 0, 90, 180, 270]", self["angle"]) + + @angle.setter + def angle(self, value: Literal[None, 0, 90, 180, 270]) -> None: + self["angle"] = value + + @property + def content(self) -> str | None: + content = self.get("content") + return None if content is None else str(content) + + @content.setter + def content(self, value: str | None) -> None: + self["content"] = value + + @property + def merge_prev(self) -> bool: + return bool(self.get("merge_prev", False)) + + @merge_prev.setter + def merge_prev(self, value: bool) -> None: + if self.type != "text": + raise AttributeError("merge_prev is only valid for MinerU2.5 text blocks.") + self["merge_prev"] = bool(value) + + +@dataclass(slots=True, frozen=True) +class _TableCell: + text: str + row_span: int + col_span: int + start_row: int + start_col: int + + +def _load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont: + for path in _FONT_PATH_CANDIDATES: + try: + return ImageFont.truetype(path, size=size) + except OSError: + continue + try: + return ImageFont.load_default(size=size) + except TypeError: + return ImageFont.load_default() + + +def _get_optimal_pil_font( + text: str, + box_w: int, + box_h: int, + *, + fill_ratio: float = 0.7, + min_size: int = 4, + max_size: int = 256, +) -> tuple[ImageFont.FreeTypeFont | ImageFont.ImageFont, int, int]: + left, right = min_size, max_size + best_font = _load_font(left) + best_w = 0 + best_h = 0 + measure_draw = ImageDraw.Draw(Image.new("RGB", (1, 1))) + for _ in range(30): + if left > right: + break + mid = (left + right) // 2 + font = _load_font(mid) + bbox = measure_draw.textbbox((0, 0), text, font=font) + width = int(bbox[2] - bbox[0]) + height = int(bbox[3] - bbox[1]) + if width <= box_w * fill_ratio and height <= box_h * fill_ratio: + best_font = font + best_w = width + best_h = height + left = mid + 1 + else: + right = mid - 1 + return best_font, best_w, best_h + + +def _pil_image_to_jpg_data_uri(image: Image.Image) -> str: + with BytesIO() as buffer: + image.save(buffer, format="JPEG") + payload = base64.b64encode(buffer.getvalue()).decode("ascii") + return f"data:image/jpeg;base64,{payload}" + + +def _normalize_rotation_angle(angle: int | None) -> int: + return angle if angle in {90, 180, 270} else 0 + + +def _rotate_image_by_angle(image: Image.Image, angle: int | None) -> Image.Image: + normalized_angle = _normalize_rotation_angle(angle) + if normalized_angle == 0: + return image + return image.rotate(normalized_angle, expand=True) + + +def _rotate_box_in_image( + box: tuple[int, int, int, int], + image_size: tuple[int, int], + angle: int | None, +) -> tuple[int, int, int, int]: + x1, y1, x2, y2 = box + width, height = image_size + normalized_angle = _normalize_rotation_angle(angle) + if normalized_angle == 0: + return box + if normalized_angle == 90: + return (y1, width - x2, y2, width - x1) + if normalized_angle == 180: + return (width - x2, height - y2, width - x1, height - y1) + return (height - y2, x1, height - y1, x2) + + +def _get_average_color(image: Image.Image, box: tuple[int, int, int, int]) -> tuple[int, int, int]: + left, upper, right, lower = box + width, height = image.size + pad = 2 + mid_x = (left + right) // 2 + mid_y = (upper + lower) // 2 + points = [ + (left - pad, upper - pad), + (mid_x, upper - pad), + (right + pad, upper - pad), + (right + pad, mid_y), + (right + pad, lower + pad), + (mid_x, lower + pad), + (left - pad, lower + pad), + (left - pad, mid_y), + ] + pixels: list[tuple[int, int, int]] = [] + for px, py in points: + px = max(0, min(int(px), width - 1)) + py = max(0, min(int(py), height - 1)) + pixel = image.getpixel((px, py)) + if isinstance(pixel, int): + pixels.append((pixel, pixel, pixel)) + continue + pixel_channels = cast("tuple[int, ...]", pixel) + if len(pixel_channels) >= 3: + pixels.append( + ( + int(pixel_channels[0]), + int(pixel_channels[1]), + int(pixel_channels[2]), + ) + ) + continue + if pixel_channels: + channel = int(pixel_channels[0]) + pixels.append((channel, channel, channel)) + if not pixels: + return (255, 255, 255) + return ( + sum(pixel[0] for pixel in pixels) // len(pixels), + sum(pixel[1] for pixel in pixels) // len(pixels), + sum(pixel[2] for pixel in pixels) // len(pixels), + ) + + +def _get_contrast_text_color(bg_color: tuple[int, int, int]) -> tuple[int, int, int]: + red, green, blue = bg_color + luminance = 0.299 * red + 0.587 * green + 0.114 * blue + return (255, 255, 255) if luminance < 128 else (0, 0, 0) + + +def _bbox_intersection_area(a: list[float], b: list[float]) -> float: + x1 = max(a[0], b[0]) + y1 = max(a[1], b[1]) + x2 = min(a[2], b[2]) + y2 = min(a[3], b[3]) + if x2 <= x1 or y2 <= y1: + return 0.0 + return (x2 - x1) * (y2 - y1) + + +def _bbox_area(a: list[float]) -> float: + return max(0.0, a[2] - a[0]) * max(0.0, a[3] - a[1]) + + +def _bbox_cover_ratio(inner: list[float], outer: list[float]) -> float: + inner_area = _bbox_area(inner) + if inner_area == 0.0: + return 0.0 + return _bbox_intersection_area(inner, outer) / inner_area + + +def _generate_uid(length: int = _TABLE_IMAGE_TOKEN_LENGTH) -> str: + return "".join(random.choices(_TABLE_IMAGE_TOKEN_CHARS, k=length)) + + +def _build_table_image_map( + blocks: list[MinerU25ContentBlock], + *, + threshold: float = 0.9, + table_indices: list[int] | None = None, +) -> dict[int, list[int]]: + if table_indices is None: + table_indices = [index for index, block in enumerate(blocks) if block.type == "table"] + table_to_images = {table_index: [] for table_index in table_indices} + if not table_indices: + return table_to_images + + for image_index, block in enumerate(blocks): + if block.type != "image": + continue + best_table_index: int | None = None + best_ratio = threshold + best_area: float | None = None + for table_index in table_indices: + table_block = blocks[table_index] + ratio = _bbox_cover_ratio(block.bbox, table_block.bbox) + if ratio < threshold: + continue + area = _bbox_area(table_block.bbox) + if ( + best_table_index is None + or ratio > best_ratio + or (ratio == best_ratio and best_area is not None and area < best_area) + ): + best_table_index = table_index + best_ratio = ratio + best_area = area + if best_table_index is not None: + table_to_images[best_table_index].append(image_index) + + for _table_index, image_indices in table_to_images.items(): + image_indices.sort(key=lambda image_index: (blocks[image_index].bbox[1], blocks[image_index].bbox[0])) + return table_to_images + + +def _mark_absorbed_table_images(blocks: list[MinerU25ContentBlock], image_indices: list[int]) -> None: + for image_index in image_indices: + blocks[image_index][_TABLE_IMAGE_ABSORBED_KEY] = True + + +def _is_absorbed_table_image(block: MinerU25ContentBlock) -> bool: + return bool(block.get(_TABLE_IMAGE_ABSORBED_KEY)) + + +def _replace_table_image_tokens(content: str | None, token_map: dict[str, str] | None) -> str | None: + if not content or not token_map: + return content + for token, data_uri in token_map.items(): + token_inner = token[1:-1] + pattern = r"\[\s*" + re.escape(token_inner) + r"\s*\]" + content = re.sub(pattern, f'', content) + return content + + +def _replace_table_formula_delimiters(content: str | None, *, enabled: bool) -> str | None: + if not enabled or not content: + return content + + inline_pattern = re.compile(r"\\\((.+?)\\\)", flags=re.DOTALL) + block_pattern = re.compile(r"\\\[(.+?)\\\]", flags=re.DOTALL) + eq_tag_pattern = re.compile(r"(.*?)", flags=re.DOTALL) + + def _wrap_formula(pattern: re.Pattern[str], text: str) -> str: + def _replace(match: re.Match[str]) -> str: + inner_content = match.group(1).strip() + return f"{inner_content}" + + return pattern.sub(_replace, text) + + parts = eq_tag_pattern.split(content) + for index, part in enumerate(parts): + if not part or eq_tag_pattern.fullmatch(part): + continue + part = _wrap_formula(inline_pattern, part) + part = _wrap_formula(block_pattern, part) + parts[index] = part + return "".join(parts) + + +def _cleanup_table_image_metadata(blocks: list[MinerU25ContentBlock]) -> list[MinerU25ContentBlock]: + for block in blocks: + block.pop(_TABLE_IMAGE_TOKEN_MAP_KEY, None) + block.pop(_TABLE_IMAGE_ABSORBED_KEY, None) + return blocks + + +def _mask_and_encode_table_image( + page_image: Image.Image, + table_block: MinerU25ContentBlock, + image_entries: list[tuple[int, MinerU25ContentBlock]], + table_image: Image.Image, +) -> tuple[Image.Image, dict[str, str]]: + width, height = page_image.size + x1_t, y1_t, _, _ = table_block.bbox + abs_x1_t = int(x1_t * width) + abs_y1_t = int(y1_t * height) + original_table_size = table_image.size + masked_table_image = _rotate_image_by_angle(table_image.copy(), table_block.angle) + draw = ImageDraw.Draw(masked_table_image) + token_map: dict[str, str] = {} + used_token_codes: set[str] = set() + max_token_count = len(_TABLE_IMAGE_TOKEN_CHARS) ** _TABLE_IMAGE_TOKEN_LENGTH + font_cache: dict[tuple[int, int], tuple[ImageFont.FreeTypeFont | ImageFont.ImageFont, int, int]] = {} + + def _font_for_box( + box_w: int, + box_h: int, + token_text: str, + ) -> tuple[ImageFont.FreeTypeFont | ImageFont.ImageFont, int, int]: + bucket_height = int(box_h // 16) + key = (bucket_height, len(token_text)) + cached = font_cache.get(key) + if cached is not None and cached[1] <= box_w and cached[2] <= box_h: + return cached + resolved = _get_optimal_pil_font( + token_text, + box_w, + box_h, + fill_ratio=0.7, + min_size=4, + max_size=max(100, int(box_h * 0.7)), + ) + font_cache[key] = resolved + return resolved + + for _, image_block in image_entries: + ix1, iy1, ix2, iy2 = image_block.bbox + abs_ix1 = ix1 * width + abs_iy1 = iy1 * height + abs_ix2 = ix2 * width + abs_iy2 = iy2 * height + + rel_x1 = int(max(0, abs_ix1 - abs_x1_t)) + rel_y1 = int(max(0, abs_iy1 - abs_y1_t)) + rel_x2 = int(min(original_table_size[0], abs_ix2 - abs_x1_t)) + rel_y2 = int(min(original_table_size[1], abs_iy2 - abs_y1_t)) + if rel_x2 <= rel_x1 or rel_y2 <= rel_y1: + continue + + crop_box = (int(abs_ix1), int(abs_iy1), int(abs_ix2), int(abs_iy2)) + crop_image = page_image.crop(crop_box) + if crop_image.width < 1 or crop_image.height < 1: + continue + + if len(used_token_codes) >= max_token_count: + raise RuntimeError("Exhausted MinerU2.5 table image token space.") + + while True: + token_code = _generate_uid() + if token_code not in used_token_codes: + used_token_codes.add(token_code) + break + + token_text = _TABLE_IMAGE_TOKEN_TEMPLATE.format(idx=token_code) + rotated_crop_image = _rotate_image_by_angle(crop_image, table_block.angle) + token_map[token_text] = _pil_image_to_jpg_data_uri(rotated_crop_image) + + image_mask_bbox = _rotate_box_in_image( + (rel_x1, rel_y1, rel_x2, rel_y2), + original_table_size, + table_block.angle, + ) + average_color = _get_average_color(masked_table_image, image_mask_bbox) + draw.rectangle(image_mask_bbox, fill=average_color, outline=None) + + box_w = image_mask_bbox[2] - image_mask_bbox[0] + box_h = image_mask_bbox[3] - image_mask_bbox[1] + font, text_w, text_h = _font_for_box(box_w, box_h, token_text) + if text_w <= box_w and text_h <= box_h: + center_x = image_mask_bbox[0] + box_w / 2 + center_y = image_mask_bbox[1] + box_h / 2 + text_position = (center_x - text_w / 2, center_y - text_h / 2) + text_color = _get_contrast_text_color(average_color) + draw.text(text_position, token_text, fill=text_color, font=font) + + return masked_table_image, token_map + + +def _convert_bbox(raw_bbox: tuple[str, str, str, str]) -> list[float] | None: + x1, y1, x2, y2 = map(int, raw_bbox) + if any(coord < 0 or coord > 1_000 for coord in (x1, y1, x2, y2)): + return None + x1, x2 = (x2, x1) if x2 < x1 else (x1, x2) + y1, y2 = (y2, y1) if y2 < y1 else (y1, y2) + if x1 == x2 or y1 == y2: + return None + return [value / 1_000.0 for value in (x1, y1, x2, y2)] + + +def _parse_angle(token: str | None) -> Literal[None, 0, 90, 180, 270]: + if token is None: + return None + return _ANGLE_MAPPING.get(token) + + +def _parse_merge_prev(tail: str) -> bool: + return "txt_contd_tgt" in tail + + +def _get_rgb_image(image: Image.Image) -> Image.Image: + if image.mode == "RGB": + return image.copy() + return image.convert("RGB") + + +def _resize_image_by_need( + image: Image.Image, + *, + min_image_edge: int, + max_image_edge_ratio: float, +) -> Image.Image: + edge_ratio = max(image.size) / min(image.size) + if edge_ratio > max_image_edge_ratio: + width, height = image.size + if width > height: + new_width, new_height = width, math.ceil(width / max_image_edge_ratio) + else: + new_width, new_height = math.ceil(height / max_image_edge_ratio), height + padded = Image.new(image.mode, (new_width, new_height), (255, 255, 255)) + padded.paste(image, ((new_width - width) // 2, (new_height - height) // 2)) + image = padded + if min(image.size) < min_image_edge: + scale = min_image_edge / min(image.size) + image = image.resize( + (math.ceil(image.width * scale), math.ceil(image.height * scale)), + Image.Resampling.BICUBIC, + ) + return image + + +def _strip_leading_chat_scaffold(text: str, *, prompts: list[str]) -> str: + cleaned = text.strip() + if not cleaned: + return "" + normalized_prompts = [prompt.strip() for prompt in prompts if prompt and prompt.strip()] + for _ in range(8): + previous = cleaned + lowered = cleaned.casefold() + stripped_prompt = False + for prompt in normalized_prompts: + if lowered.startswith(prompt.casefold()): + cleaned = cleaned[len(prompt) :].lstrip() + stripped_prompt = True + break + if stripped_prompt: + continue + lines = cleaned.splitlines() + if not lines: + return "" + first_line = lines[0].strip() + if first_line.casefold() in _CHAT_ROLE_PREFIXES: + cleaned = "\n".join(lines[1:]).lstrip() + continue + if re.fullmatch(r"<\|?(?:assistant|user|system)\|?>", first_line, flags=re.IGNORECASE): + cleaned = "\n".join(lines[1:]).lstrip() + continue + if cleaned == previous: + break + return cleaned.strip() + + +def _trim_stop_strings(text: str) -> str: + cleaned = text + for stop in MINERU2_5_STOP_TOKENS: + cleaned = cleaned.split(stop, 1)[0] + return cleaned.strip() + + +def _extract_otsl_tokens_and_text(raw_text: str) -> tuple[list[str], list[str]]: + tokens = _OTSL_PATTERN.findall(raw_text) + text_parts = [part for part in _OTSL_PATTERN.split(raw_text) if part and part.strip()] + return tokens, text_parts + + +def _count_span_right(rows: list[list[str]], row_idx: int, col_idx: int, span_tokens: set[str]) -> int: + span = 0 + cursor = col_idx + while cursor < len(rows[row_idx]) and rows[row_idx][cursor] in span_tokens: + span += 1 + cursor += 1 + return span + + +def _count_span_down(rows: list[list[str]], row_idx: int, col_idx: int, span_tokens: set[str]) -> int: + span = 0 + cursor = row_idx + while cursor < len(rows) and col_idx < len(rows[cursor]) and rows[cursor][col_idx] in span_tokens: + span += 1 + cursor += 1 + return span + + +def convert_mineru2_5_otsl_to_html(otsl_content: str) -> str: + """Convert a MinerU2.5 OTSL table prediction to HTML.""" + if otsl_content.startswith(""): + return otsl_content + tokens, mixed_texts = _extract_otsl_tokens_and_text(otsl_content) + rows = [ + list(group) + for is_newline, group in itertools.groupby(tokens, lambda item: item == _OTSL_NL) + if not is_newline + ] + if not rows: + return otsl_content.strip() + + max_cols = max(len(row) for row in rows) + for row in rows: + while len(row) < max_cols: + row.append(_OTSL_ECEL) + + normalized_texts: list[str] = [] + text_idx = 0 + for row in rows: + for token in row: + normalized_texts.append(token) + if text_idx < len(mixed_texts) and mixed_texts[text_idx] == token: + text_idx += 1 + if text_idx < len(mixed_texts) and mixed_texts[text_idx] not in _OTSL_TOKENS: + normalized_texts.append(mixed_texts[text_idx]) + text_idx += 1 + normalized_texts.append(_OTSL_NL) + if text_idx < len(mixed_texts) and mixed_texts[text_idx] == _OTSL_NL: + text_idx += 1 + + cells: list[_TableCell] = [] + row_idx = 0 + col_idx = 0 + for index, part in enumerate(normalized_texts): + if part in {_OTSL_FCEL, _OTSL_ECEL}: + row_span = 1 + col_span = 1 + next_offset = 1 + cell_text = "" + if index + 1 < len(normalized_texts) and normalized_texts[index + 1] not in _OTSL_TOKENS: + cell_text = normalized_texts[index + 1].strip() + next_offset = 2 + next_right = ( + normalized_texts[index + next_offset] if index + next_offset < len(normalized_texts) else "" + ) + next_down = ( + rows[row_idx + 1][col_idx] + if row_idx + 1 < len(rows) and col_idx < len(rows[row_idx + 1]) + else "" + ) + if next_right in {_OTSL_LCEL, _OTSL_XCEL}: + col_span += _count_span_right(rows, row_idx, col_idx + 1, {_OTSL_LCEL, _OTSL_XCEL}) + if next_down in {_OTSL_UCEL, _OTSL_XCEL}: + row_span += _count_span_down(rows, row_idx + 1, col_idx, {_OTSL_UCEL, _OTSL_XCEL}) + cells.append( + _TableCell( + text=cell_text, + row_span=row_span, + col_span=col_span, + start_row=row_idx, + start_col=col_idx, + ) + ) + if part in _OTSL_TOKENS - {_OTSL_NL}: + col_idx += 1 + if part == _OTSL_NL: + row_idx += 1 + col_idx = 0 + + html_parts = [""] + for row in range(len(rows)): + html_parts.append("") + for col in range(max_cols): + cell = next((item for item in cells if item.start_row == row and item.start_col == col), None) + if cell is None: + continue + attrs: list[str] = [] + if cell.row_span > 1: + attrs.append(f' rowspan="{cell.row_span}"') + if cell.col_span > 1: + attrs.append(f' colspan="{cell.col_span}"') + html_parts.append(f"{html.escape(cell.text)}") + html_parts.append("") + html_parts.append("
") + return "".join(html_parts) + + +def wrap_mineru2_5_equation(content: str) -> str: + """Wrap a MinerU2.5 formula prediction as display math.""" + cleaned = content.strip() + if not cleaned: + return "" + if cleaned.startswith("\\["): + cleaned = cleaned[2:].strip() + if cleaned.endswith("\\]"): + cleaned = cleaned[:-2].strip() + return f"\\[\n{cleaned}\n\\]" + + +def _try_fix_equation_delimiters(latex: str) -> str: + cleaned = latex.strip() + if cleaned.startswith("\\["): + cleaned = cleaned[2:] + if cleaned.endswith("\\]"): + cleaned = cleaned[:-2] + return cleaned.strip() + + +def _try_convert_display_to_inline(text: str) -> str: + def _replace(match: re.Match[str]) -> str: + inner = match.group(1) + if re.fullmatch(r"[–\d\-,\s]+", inner): + return r"\[" + inner + r"\]" + return r"\(" + inner + r"\)" + + return re.sub(r"\\\[(.*?)\\\]", _replace, text, flags=re.DOTALL) + + +def _try_fix_macro_spacing_in_markdown(text: str) -> str: + known_macros = {r"\top", r"\int", r"\inf"} + target_macros = [r"\cong", r"\to", r"\times", r"\subset", r"\in"] + + def _fix_macro_spacing(value: str, macro: str) -> str: + pattern = re.escape(macro) + r"([a-zA-Z])(?![a-zA-Z])" + + def _replace(match: re.Match[str]) -> str: + letter = match.group(1) + if (macro + letter) in known_macros: + return match.group(0) + return macro + " " + letter + + return re.sub(pattern, _replace, value) + + result: list[str] = [] + parts = re.split(r"(\\\(.*?\\\))", text, flags=re.DOTALL) + for part in parts: + if part.startswith(r"\(") and part.endswith(r"\)"): + inner = part[2:-2] + for macro in target_macros: + inner = _fix_macro_spacing(inner, macro) + result.append(r"\(" + inner + r"\)") + continue + result.append(part) + return "".join(result) + + +def _try_move_underscores_outside(text: str) -> str: + def _process_match(match: re.Match[str]) -> str: + inner = match.group(1) + parts = re.split(r"(_{3,})", inner) + if len(parts) == 1: + return match.group(0) + result: list[str] = [] + for part in parts: + if re.fullmatch(r"_{3,}", part): + result.append(part) + elif part.strip(): + result.append(r"\(" + part + r"\)") + return " ".join(result) + + return re.sub(r"\\\((.+?)\\\)", _process_match, text, flags=re.DOTALL) + + +def _do_handle_equation_block(blocks: list[MinerU25ContentBlock]) -> list[MinerU25ContentBlock]: + equation_block_indices = [index for index, block in enumerate(blocks) if block.type == "equation_block"] + equation_indices = [index for index, block in enumerate(blocks) if block.type == "equation"] + combined_indices: dict[int, list[int]] = {} + for block_index in equation_block_indices: + covered = [ + equation_index + for equation_index in equation_indices + if _bbox_cover_ratio(blocks[block_index].bbox, blocks[equation_index].bbox) > 0.9 + ] + if len(covered) > 1: + combined_indices[block_index] = covered + + combined_equation_indices = {index for indices in combined_indices.values() for index in indices} + rendered_blocks: list[MinerU25ContentBlock] = [] + for index, block in enumerate(blocks): + if index in combined_equation_indices: + continue + if index in combined_indices: + contents = [blocks[covered_index].content or "" for covered_index in combined_indices[index]] + tag_count = sum(len(re.findall(r"\\tag\s*\{[^}]*\}", content)) for content in contents) + if tag_count > 1: + contents = [re.sub(r"\\tag\s*\{([^}]*)\}", r"(\1)", content) for content in contents] + combined_content = ( + "\\begin{array}{l} " + + " \\\\ ".join(content.strip() for content in contents) + + " \\end{array}" + ) + rendered_blocks.append( + MinerU25ContentBlock( + type="equation", + bbox=block.bbox, + angle=block.angle, + content=combined_content, + ) + ) + continue + if block.type == "equation_block": + continue + rendered_blocks.append(block) + return rendered_blocks + + +def json2md(blocks: list[MinerU25ContentBlock]) -> str: + """Render MinerU2.5 blocks to markdown-like text.""" + content_list: list[str] = [] + last_text_contd_index = -1 + for block in blocks: + content = block.content + if not content: + continue + if block.merge_prev and last_text_contd_index >= 0: + if re.search(r"[\u4e00-\u9fff\u3400-\u4dbf]", content) is not None: + content_list[last_text_contd_index] += content + else: + content_list[last_text_contd_index] += " " + content + continue + content_list.append(content) + if block.type == "text": + last_text_contd_index = len(content_list) - 1 + return "\n\n".join(content_list).strip() + + +class MinerU25PipelineHelper: + """Shared MinerU2.5 layout, extraction, and markdown postprocessing helper.""" + + def __init__( + self, + *, + prompts: dict[str, str], + system_prompt: str, + sampling_params: dict[str, MinerU25SamplingParams] | None = None, + layout_image_size: tuple[int, int] = MINERU2_5_LAYOUT_IMAGE_SIZE, + min_image_edge: int = MINERU2_5_MIN_IMAGE_EDGE, + max_image_edge_ratio: float = MINERU2_5_MAX_IMAGE_EDGE_RATIO, + simple_post_process: bool = False, + handle_equation_block: bool = True, + abandon_list: bool = False, + abandon_paratext: bool = False, + image_analysis: bool = False, + enable_table_formula_eq_wrap: bool = False, + ) -> None: + self.prompts = dict(prompts) + self.system_prompt = system_prompt + self.sampling_params = dict(DEFAULT_MINERU2_5_SAMPLING_PARAMS) + if sampling_params is not None: + self.sampling_params.update(sampling_params) + self.layout_image_size = layout_image_size + self.min_image_edge = min_image_edge + self.max_image_edge_ratio = max_image_edge_ratio + self.simple_post_process = simple_post_process + self.handle_equation_block = handle_equation_block + self.abandon_list = abandon_list + self.abandon_paratext = abandon_paratext + self.image_analysis = image_analysis + self.enable_table_formula_eq_wrap = enable_table_formula_eq_wrap + + def prompt_for(self, step_key: str) -> str: + return self.prompts.get(step_key) or self.prompts["[default]"] + + def sampling_for(self, step_key: str) -> MinerU25SamplingParams: + return self.sampling_params.get(step_key) or self.sampling_params["[default]"] + + def clean_response(self, text: str, *, step_key: str) -> str: + cleaned = _trim_stop_strings(text) + return _strip_leading_chat_scaffold( + cleaned, + prompts=[self.system_prompt, self.prompt_for(step_key), self.prompt_for(step_key).strip()], + ) + + def prepare_for_layout(self, image: Image.Image) -> Image.Image: + image = _get_rgb_image(image) + return image.resize(self.layout_image_size, Image.Resampling.BICUBIC) + + def parse_layout_output(self, output: str) -> list[MinerU25ContentBlock]: + blocks: list[MinerU25ContentBlock] = [] + for match in re.finditer(_LAYOUT_RE, output): + x1, y1, x2, y2, ref_type, rotate_token, tail = match.groups() + bbox = _convert_bbox((x1, y1, x2, y2)) + if bbox is None: + continue + ref_type = ref_type.lower() + if ref_type == "inline_formula" or ref_type not in _SUPPORTED_BLOCK_TYPES: + continue + angle = _parse_angle(rotate_token) + if ref_type == "text": + blocks.append( + MinerU25ContentBlock( + ref_type, + bbox, + angle=angle, + merge_prev=_parse_merge_prev(tail), + ) + ) + continue + blocks.append(MinerU25ContentBlock(ref_type, bbox, angle=angle)) + return blocks + + def _find_covered_block_indices( + self, + blocks: list[MinerU25ContentBlock], + *, + candidate_types: set[str], + container_types: set[str], + threshold: float = _INTERNAL_BLOCK_THRESHOLD, + ) -> set[int]: + container_indices = [idx for idx, block in enumerate(blocks) if block.type in container_types] + if not container_indices: + return set() + covered_indices: set[int] = set() + for idx, block in enumerate(blocks): + if block.type not in candidate_types: + continue + for container_idx in container_indices: + if idx == container_idx: + continue + if _bbox_cover_ratio(block.bbox, blocks[container_idx].bbox) >= threshold: + covered_indices.add(idx) + break + return covered_indices + + def _prepare_block_image( + self, + page_image: Image.Image, + block: MinerU25ContentBlock, + ) -> Image.Image: + image = _get_rgb_image(page_image) + width, height = image.size + left = max(0, min(width - 1, math.floor(block.bbox[0] * width))) + top = max(0, min(height - 1, math.floor(block.bbox[1] * height))) + right = max(left + 1, min(width, math.ceil(block.bbox[2] * width))) + bottom = max(top + 1, min(height, math.ceil(block.bbox[3] * height))) + cropped = image.crop((left, top, right, bottom)) + if block.angle in {90, 180, 270}: + cropped = cropped.rotate(block.angle, expand=True) + return _resize_image_by_need( + cropped, + min_image_edge=self.min_image_edge, + max_image_edge_ratio=self.max_image_edge_ratio, + ) + + def prepare_for_extract( + self, + image: Image.Image, + blocks: list[MinerU25ContentBlock], + *, + not_extract_list: list[str] | None = None, + ) -> list[tuple[int, Image.Image]]: + internal_caption_indices = self._find_covered_block_indices( + blocks, + candidate_types={"image_caption"}, + container_types=_IMAGE_CAPTION_CONTAINER_TYPES, + ) + if internal_caption_indices: + blocks[:] = [block for idx, block in enumerate(blocks) if idx not in internal_caption_indices] + + skip_types = {"list", "equation_block", "image_block"} + if not self.image_analysis: + skip_types.update(_IMAGE_ANALYSIS_TYPES) + if not_extract_list is not None: + skip_types.update(not_extract_list) + + table_indices = [ + idx for idx, block in enumerate(blocks) if block.type == "table" and block.type not in skip_types + ] + table_to_images = _build_table_image_map(blocks, table_indices=table_indices) + absorbed_image_indices = sorted( + {image_idx for image_indices in table_to_images.values() for image_idx in image_indices} + ) + _mark_absorbed_table_images(blocks, absorbed_image_indices) + + prepared: list[tuple[int, Image.Image]] = [] + rgb_image = _get_rgb_image(image) + width, height = rgb_image.size + for index, block in enumerate(blocks): + if block.type in skip_types: + continue + if block.type == "image" and _is_absorbed_table_image(block): + continue + x1, y1, x2, y2 = block.bbox + scaled_bbox = (x1 * width, y1 * height, x2 * width, y2 * height) + block_image = rgb_image.crop(scaled_bbox) + if block_image.width < 1 or block_image.height < 1: + continue + if block.type == "table": + image_entries = [ + (image_idx, blocks[image_idx]) for image_idx in table_to_images.get(index, []) + ] + block_image, token_map = _mask_and_encode_table_image( + rgb_image, + block, + image_entries, + block_image, + ) + if token_map: + block[_TABLE_IMAGE_TOKEN_MAP_KEY] = token_map + elif block.angle in {90, 180, 270}: + block_image = block_image.rotate(block.angle, expand=True) + block_image = _resize_image_by_need( + block_image, + min_image_edge=self.min_image_edge, + max_image_edge_ratio=self.max_image_edge_ratio, + ) + prepared.append((index, block_image)) + return prepared + + def post_process(self, blocks: list[MinerU25ContentBlock]) -> list[MinerU25ContentBlock]: + for block in blocks: + content = (block.content or "").strip() + if not content: + block.content = None + continue + if block.type == "table": + token_map_value = block.get(_TABLE_IMAGE_TOKEN_MAP_KEY) + token_map = token_map_value if isinstance(token_map_value, dict) else None + table_html = convert_mineru2_5_otsl_to_html(content) + table_html = _replace_table_image_tokens( + table_html, + cast("dict[str, str] | None", token_map), + ) + block.content = _replace_table_formula_delimiters( + table_html, + enabled=self.enable_table_formula_eq_wrap, + ) + continue + if block.type == "equation": + fixed = _try_fix_equation_delimiters(content) + block.content = wrap_mineru2_5_equation(fixed) + continue + if block.type == "text": + fixed = _try_convert_display_to_inline(content) + fixed = _try_fix_macro_spacing_in_markdown(fixed) + fixed = _try_move_underscores_outside(fixed) + block.content = fixed + + processed_blocks = blocks + if not self.simple_post_process and self.handle_equation_block: + processed_blocks = _do_handle_equation_block(processed_blocks) + + rendered_blocks: list[MinerU25ContentBlock] = [] + for block in processed_blocks: + if block.type == "equation_block": + continue + if block.type == "image" and _is_absorbed_table_image(block): + continue + if self.abandon_list and block.type == "list": + continue + if self.abandon_paratext and block.type in MINERU2_5_PARATEXT_TYPES: + continue + rendered_blocks.append(block) + return _cleanup_table_image_metadata(rendered_blocks) + + def render_markdown(self, blocks: list[MinerU25ContentBlock]) -> str: + return json2md(blocks) + + def run_two_step( + self, + image: Image.Image, + *, + infer_step: Callable[[Image.Image, str, MinerU25SamplingParams], str], + not_extract_list: list[str] | None = None, + ) -> tuple[str, list[MinerU25ContentBlock], dict[str, float | int]]: + from time import perf_counter + + started_at = perf_counter() + layout_started_at = perf_counter() + layout_output = infer_step( + self.prepare_for_layout(image), + "[layout]", + self.sampling_for("[layout]"), + ) + layout_finished_at = perf_counter() + layout_blocks = self.parse_layout_output(layout_output) + extract_started_at = perf_counter() + prepared_blocks = self.prepare_for_extract( + image, + layout_blocks, + not_extract_list=not_extract_list, + ) + for index, block_image in prepared_blocks: + layout_blocks[index].content = infer_step( + block_image, + layout_blocks[index].type, + self.sampling_for(layout_blocks[index].type), + ) + processed_blocks = self.post_process(layout_blocks) + markdown = self.render_markdown(processed_blocks) + finished_at = perf_counter() + return ( + markdown, + processed_blocks, + { + "layout_elapsed": layout_finished_at - layout_started_at, + "extract_elapsed": finished_at - extract_started_at, + "num_blocks": len(processed_blocks), + "total_elapsed": finished_at - started_at, + }, + ) + + async def arun_two_step( + self, + image: Image.Image, + *, + infer_step: Callable[[Image.Image, str, MinerU25SamplingParams], Awaitable[str]], + not_extract_list: list[str] | None = None, + ) -> tuple[str, list[MinerU25ContentBlock], dict[str, float | int]]: + from time import perf_counter + + started_at = perf_counter() + layout_started_at = perf_counter() + layout_output = await infer_step( + self.prepare_for_layout(image), + "[layout]", + self.sampling_for("[layout]"), + ) + layout_finished_at = perf_counter() + layout_blocks = self.parse_layout_output(layout_output) + extract_started_at = perf_counter() + prepared_blocks = self.prepare_for_extract( + image, + layout_blocks, + not_extract_list=not_extract_list, + ) + for index, block_image in prepared_blocks: + layout_blocks[index].content = await infer_step( + block_image, + layout_blocks[index].type, + self.sampling_for(layout_blocks[index].type), + ) + processed_blocks = await asyncio.to_thread(self.post_process, layout_blocks) + markdown = self.render_markdown(processed_blocks) + finished_at = perf_counter() + return ( + markdown, + processed_blocks, + { + "layout_elapsed": layout_finished_at - layout_started_at, + "extract_elapsed": finished_at - extract_started_at, + "num_blocks": len(processed_blocks), + "total_elapsed": finished_at - started_at, + }, + ) + + +def replace_sampling_param( + sampling: MinerU25SamplingParams, + **changes: float | int | None, +) -> MinerU25SamplingParams: + """Return a MinerU2.5 sampling config with selected fields replaced.""" + return replace(sampling, **changes) + + +__all__ = [ + "DEFAULT_MINERU2_5_SAMPLING_PARAMS", + "MINERU2_5_LAYOUT_IMAGE_SIZE", + "MINERU2_5_MAX_IMAGE_EDGE_RATIO", + "MINERU2_5_MIN_IMAGE_EDGE", + "MINERU2_5_PARATEXT_TYPES", + "MINERU2_5_STOP_TOKENS", + "MinerU25ContentBlock", + "MinerU25PipelineHelper", + "MinerU25SamplingParams", + "convert_mineru2_5_otsl_to_html", + "json2md", + "replace_sampling_param", + "wrap_mineru2_5_equation", +] diff --git a/src/churro_ocr/providers/builder.py b/src/churro_ocr/providers/builder.py index 09975ed..4d1f4bd 100644 --- a/src/churro_ocr/providers/builder.py +++ b/src/churro_ocr/providers/builder.py @@ -12,12 +12,14 @@ DotsOCR15OCRBackend, HuggingFaceVisionOCRBackend, LFM25VLOCRBackend, + MinerU25OCRBackend, PaddleOCRVL15OCRBackend, _default_dots_ocr_1_5_model_kwargs, ) from churro_ocr.providers.ocr import ( AzureDocumentIntelligenceOCRBackend, LiteLLMVisionOCRBackend, + MinerU25OpenAICompatibleOCRBackend, MistralOCRBackend, OpenAICompatibleOCRBackend, ) @@ -32,6 +34,7 @@ resolve_ocr_profile, validate_mistral_ocr_model, ) +from churro_ocr.templates import MINERU2_5_2509_1_2B_MODEL_ID def _merge_mapping( @@ -126,6 +129,11 @@ def _resolve_model_name(profile: OCRModelProfile, model: str | None, *, fallback def _build_litellm_backend(spec: OCRBackendSpec, profile: OCRModelProfile) -> OCRBackend: if spec.model is None: raise ConfigurationError("OCR provider 'litellm' requires `model`.") + if spec.model == MINERU2_5_2509_1_2B_MODEL_ID: + raise ConfigurationError( + "MinerU2.5 requires the built-in two-step pipeline. Use provider 'hf' for local " + "Transformers inference or provider 'openai-compatible' for a served vLLM endpoint." + ) transport_config = _merge_transport_config(profile.transport, spec.transport) return LiteLLMVisionOCRBackend( model=spec.model, @@ -146,7 +154,10 @@ def _build_openai_compatible_backend(spec: OCRBackendSpec, profile: OCRModelProf transport_config = _merge_transport_config(profile.transport, spec.transport) if not transport_config.api_base: raise ConfigurationError("OCR provider 'openai-compatible' requires `transport.api_base`.") - return OpenAICompatibleOCRBackend( + backend_cls: type[OpenAICompatibleOCRBackend] = OpenAICompatibleOCRBackend + if spec.model == MINERU2_5_2509_1_2B_MODEL_ID: + backend_cls = MinerU25OpenAICompatibleOCRBackend + return backend_cls( model=spec.model, model_prefix=options.model_prefix or "openai", model_name=_resolve_model_name(profile, spec.model, fallback=spec.model), @@ -175,6 +186,8 @@ def _build_huggingface_backend(spec: OCRBackendSpec, profile: OCRModelProfile) - backend_cls = DeepSeekOCR2OCRBackend elif options.backend_variant == "chandra-ocr-2": backend_cls = ChandraOCR2OCRBackend + elif options.backend_variant == "mineru2.5": + backend_cls = MinerU25OCRBackend elif options.backend_variant == "paddleocr-vl-1.5": backend_cls = PaddleOCRVL15OCRBackend elif options.backend_variant == "lfm2.5-vl": diff --git a/src/churro_ocr/providers/hf.py b/src/churro_ocr/providers/hf.py index 567f291..c10e55d 100644 --- a/src/churro_ocr/providers/hf.py +++ b/src/churro_ocr/providers/hf.py @@ -11,11 +11,19 @@ from types import MethodType from typing import Any, cast +from PIL import Image + +from churro_ocr._internal.image import ensure_rgb from churro_ocr._internal.install import install_command_hint from churro_ocr._internal.prompt_logging import log_prompt_payload_once from churro_ocr.errors import ConfigurationError, ProviderError from churro_ocr.ocr import OCRBackend, OCRResult from churro_ocr.page_detection import DocumentPage +from churro_ocr.providers._mineru25 import ( + MinerU25PipelineHelper, + MinerU25SamplingParams, + replace_sampling_param, +) from churro_ocr.providers._shared import ( build_ocr_result, normalize_media_inputs, @@ -43,6 +51,18 @@ DOTS_OCR_1_5_OCR_TEMPLATE, LFM2_5_VL_1_6B_MODEL_ID, LFM2_5_VL_1_6B_OCR_TEMPLATE, + MINERU2_5_2509_1_2B_FORMULA_PROMPT, + MINERU2_5_2509_1_2B_FORMULA_TEMPLATE, + MINERU2_5_2509_1_2B_IMAGE_ANALYSIS_PROMPT, + MINERU2_5_2509_1_2B_IMAGE_ANALYSIS_TEMPLATE, + MINERU2_5_2509_1_2B_LAYOUT_PROMPT, + MINERU2_5_2509_1_2B_LAYOUT_TEMPLATE, + MINERU2_5_2509_1_2B_MODEL_ID, + MINERU2_5_2509_1_2B_OCR_PROMPT, + MINERU2_5_2509_1_2B_OCR_TEMPLATE, + MINERU2_5_2509_1_2B_SYSTEM_PROMPT, + MINERU2_5_2509_1_2B_TABLE_PROMPT, + MINERU2_5_2509_1_2B_TABLE_TEMPLATE, PADDLEOCR_VL_1_5_MODEL_ID, PADDLEOCR_VL_1_5_OCR_TEMPLATE, OCRConversation, @@ -120,6 +140,22 @@ def _load_hf_auto_model_runtime() -> _HFRuntime: ) +def _load_hf_auto_processor_model_runtime() -> _HFRuntime: + _ensure_hf_torch_runtime() + try: + from transformers import AutoModel, AutoProcessor + except ImportError as exc: # pragma: no cover - optional extra path + raise ConfigurationError( + f"Hugging Face OCR requires the `hf` runtime. {_HF_EXTRA_INSTALL_HINT}" + ) from exc + + return _HFRuntime( + processor_cls=AutoProcessor, + model_cls=AutoModel, + process_vision_info=None, + ) + + def _ensure_deepseek_ocr_2_cuda_runtime() -> Any: _ensure_hf_torch_runtime() torch = import_module("torch") @@ -294,6 +330,31 @@ def _default_chandra_ocr_2_model_kwargs() -> dict[str, object]: return model_kwargs +def _default_mineru25_model_kwargs() -> dict[str, object]: + model_kwargs: dict[str, object] = {"device_map": "auto"} + dtype_key = "dtype" + transformers_version: str + try: + from transformers import __version__ as imported_transformers_version + + transformers_version = str(imported_transformers_version) + except ImportError: # pragma: no cover - transformers is installed via the hf runtime + transformers_version = "" + + version_parts = transformers_version.split(".") + if len(version_parts) >= 2: + try: + major = int(version_parts[0]) + minor = int(version_parts[1]) + except ValueError: + major = 0 + minor = 0 + if major < 4 or (major == 4 and minor < 56): + dtype_key = "torch_dtype" + model_kwargs[dtype_key] = "auto" + return model_kwargs + + def _deepseek_ocr_2_prompt_from_conversation(conversation: OCRConversation) -> str: prompt_lines: list[str] = [] has_image = False @@ -341,24 +402,81 @@ def _move_batch_to_model(batch: Any, model: Any) -> Any: def _decode_completion_texts(processor: Any, batch: Any, generated_ids: Any) -> list[str]: + return _decode_completion_texts_with_options( + processor, + batch, + generated_ids, + skip_special_tokens=True, + ) + + +def _completion_ids_from_generated_ids(batch: Any, generated_ids: Any) -> Any: batch_mapping = cast(dict[str, object], batch) attention_mask = batch_mapping.get("attention_mask") if attention_mask is not None and hasattr(attention_mask, "sum"): prompt_lengths = cast(Any, attention_mask).sum(dim=1).tolist() - completion_ids = [ + return [ output_ids[int(prompt_length) :] for prompt_length, output_ids in zip(prompt_lengths, generated_ids, strict=True) ] - else: - prompt_length = cast(Any, batch_mapping["input_ids"]).shape[1] - completion_ids = generated_ids[:, prompt_length:] + prompt_length = cast(Any, batch_mapping["input_ids"]).shape[1] + return generated_ids[:, prompt_length:] + + +def _decode_completion_texts_with_options( + processor: Any, + batch: Any, + generated_ids: Any, + *, + skip_special_tokens: bool, +) -> list[str]: + completion_ids = _completion_ids_from_generated_ids(batch, generated_ids) return processor.batch_decode( completion_ids, - skip_special_tokens=True, + skip_special_tokens=skip_special_tokens, clean_up_tokenization_spaces=False, ) +def _resolve_model_max_length(model: Any) -> int | None: + config = getattr(model, "config", None) + max_length = getattr(config, "max_position_embeddings", None) + if isinstance(max_length, int): + return max_length + text_config = getattr(config, "text_config", None) + text_max_length = getattr(text_config, "max_position_embeddings", None) + if isinstance(text_max_length, int): + return text_max_length + return None + + +_MINERU25_STEP_ALIASES = { + "[layout]": "layout", + "table": "table", + "equation": "equation", + "image": "image", + "chart": "chart", +} +_MINERU25_SAMPLING_FIELD_NAMES = ( + "temperature", + "top_p", + "top_k", + "presence_penalty", + "frequency_penalty", + "repetition_penalty", + "no_repeat_ngram_size", + "max_new_tokens", +) +_MINERU25_SCOPED_PREFIXES = ( + "layout_", + "table_", + "equation_", + "image_", + "chart_", + "default_", +) + + def _paddleocr_vl_processor_kwargs(*, processor: Any, padding: bool) -> dict[str, object]: processor_kwargs: dict[str, object] = { "text_kwargs": { @@ -957,6 +1075,254 @@ class DotsMOCROCRBackend(DotsOCR15OCRBackend): model_name: str | None = "dots.mocr" +def _default_mineru25_helper() -> MinerU25PipelineHelper: + return MinerU25PipelineHelper( + prompts={ + "[default]": MINERU2_5_2509_1_2B_OCR_PROMPT, + "[layout]": MINERU2_5_2509_1_2B_LAYOUT_PROMPT, + "table": MINERU2_5_2509_1_2B_TABLE_PROMPT, + "equation": MINERU2_5_2509_1_2B_FORMULA_PROMPT, + "image": MINERU2_5_2509_1_2B_IMAGE_ANALYSIS_PROMPT, + "chart": MINERU2_5_2509_1_2B_IMAGE_ANALYSIS_PROMPT, + }, + system_prompt=MINERU2_5_2509_1_2B_SYSTEM_PROMPT, + ) + + +@dataclass(slots=True) +class MinerU25OCRBackend(HuggingFaceVisionOCRBackend): + """Preset OCR backend for ``opendatalab/MinerU2.5-2509-1.2B``.""" + + model_id: str = MINERU2_5_2509_1_2B_MODEL_ID + template: OCRPromptTemplateLike = MINERU2_5_2509_1_2B_OCR_TEMPLATE + layout_template: OCRPromptTemplateLike = MINERU2_5_2509_1_2B_LAYOUT_TEMPLATE + table_template: OCRPromptTemplateLike = MINERU2_5_2509_1_2B_TABLE_TEMPLATE + formula_template: OCRPromptTemplateLike = MINERU2_5_2509_1_2B_FORMULA_TEMPLATE + image_analysis_template: OCRPromptTemplateLike = MINERU2_5_2509_1_2B_IMAGE_ANALYSIS_TEMPLATE + model_name: str | None = "MinerU2.5-2509-1.2B" + image_preprocessor: ImagePreprocessor = ensure_rgb + _helper: MinerU25PipelineHelper = field(default_factory=_default_mineru25_helper, init=False, repr=False) + + def __post_init__(self) -> None: + """Preserve user-supplied MinerU2.5 generation overrides without generic defaults.""" + self.generation_kwargs = dict(self.generation_kwargs) + + def _load_runtime(self) -> _HFRuntime: + return _load_hf_runtime() + + def _get_model(self, runtime: _HFRuntime) -> Any: + if self._model is None: + with self._init_lock: + if self._model is None: + self._model = runtime.model_cls.from_pretrained( + self._get_model_source(), + trust_remote_code=self.trust_remote_code, + **{ + **_default_mineru25_model_kwargs(), + **self.model_kwargs, + }, + ) + eval_method = getattr(self._model, "eval", None) + if callable(eval_method): + eval_method() + return self._model + + def _template_for_step(self, step_key: str) -> OCRPromptTemplateLike: + if step_key == "[layout]": + return self.layout_template + if step_key == "table": + return self.table_template + if step_key == "equation": + return self.formula_template + if step_key in {"image", "chart"}: + return self.image_analysis_template + return self.template + + def _resolve_rendered_prompt(self, rendered: object) -> str: + if isinstance(rendered, tuple): + if not rendered: + raise ProviderError("MinerU2.5 returned an empty chat template render.") + rendered = rendered[0] + if not isinstance(rendered, str): + raise ProviderError("MinerU2.5 chat template did not render text.") + return rendered + + def _resolve_step_sampling(self, step_key: str) -> MinerU25SamplingParams: + effective_step = _MINERU25_STEP_ALIASES.get(step_key, "default") + sampling = self._helper.sampling_for(step_key) + changes: dict[str, float | int | None] = {} + for field_name in _MINERU25_SAMPLING_FIELD_NAMES: + global_value = self.generation_kwargs.get(field_name) + if isinstance(global_value, (int, float)): + changes[field_name] = global_value + step_value = self.generation_kwargs.get(f"{effective_step}_{field_name}") + if isinstance(step_value, (int, float)): + changes[field_name] = step_value + return replace_sampling_param(sampling, **changes) if changes else sampling + + def _resolve_generation_kwargs(self, *, step_key: str, model: Any) -> dict[str, object]: + sampling = self._resolve_step_sampling(step_key) + do_sample = ((sampling.temperature or 0.0) > 0.0) and ((sampling.top_k or 1) > 1) + + generation_kwargs: dict[str, object] = { + "do_sample": do_sample, + } + if do_sample and sampling.temperature is not None: + generation_kwargs["temperature"] = sampling.temperature + if do_sample and sampling.top_p is not None: + generation_kwargs["top_p"] = sampling.top_p + if do_sample and sampling.top_k is not None: + generation_kwargs["top_k"] = sampling.top_k + if sampling.repetition_penalty is not None: + generation_kwargs["repetition_penalty"] = sampling.repetition_penalty + if sampling.no_repeat_ngram_size is not None: + generation_kwargs["no_repeat_ngram_size"] = sampling.no_repeat_ngram_size + if sampling.max_new_tokens is not None: + generation_kwargs["max_new_tokens"] = sampling.max_new_tokens + else: + max_length = self.generation_kwargs.get( + "max_length", + _resolve_model_max_length(model), + ) + if isinstance(max_length, str): + max_length = int(max_length) + if isinstance(max_length, int): + generation_kwargs["max_length"] = max_length + + extra_kwargs = dict(self.generation_kwargs) + extra_kwargs.pop("max_length", None) + for prefix in ("", *_MINERU25_SCOPED_PREFIXES): + for field_name in _MINERU25_SAMPLING_FIELD_NAMES: + extra_kwargs.pop(f"{prefix}{field_name}" if prefix else field_name, None) + generation_kwargs.update(extra_kwargs) + return generation_kwargs + + def _infer_step( + self, + *, + runtime: _HFRuntime, + processor: Any, + model: Any, + image: Image.Image, + step_key: str, + batch_size: int, + ) -> str: + rendered, conversation = render_ocr_prompt( + processor, + self._template_for_step(step_key), + DocumentPage.from_image(image), + add_generation_prompt=True, + ) + rendered_prompt = self._resolve_rendered_prompt(rendered) + self._log_prompt_payload( + rendered_prompt=rendered_prompt, + conversation=conversation, + batch_size=batch_size, + ) + image_inputs, video_inputs = self._build_vision_inputs(runtime, conversation) + batch_kwargs: dict[str, object] = { + "text": [rendered_prompt], + "images": normalize_media_inputs(image_inputs), + "return_tensors": "pt", + "padding": True, + } + normalized_video_inputs = normalize_media_inputs(video_inputs) + if normalized_video_inputs is not None: + batch_kwargs["videos"] = normalized_video_inputs + batch = processor(**batch_kwargs) + batch = _move_batch_to_model(batch, model) + generated_ids = model.generate( + **self._generation_inputs(batch), + **self._resolve_generation_kwargs(step_key=step_key, model=model), + ) + text = _decode_completion_texts_with_options( + processor, + batch, + generated_ids, + skip_special_tokens=False, + )[0] + return self._helper.clean_response(text, step_key=step_key) + + def _build_result( + self, + *, + markdown: str, + blocks: list[dict[str, object]], + metrics: dict[str, float | int], + ) -> OCRResult: + return build_ocr_result( + markdown, + provider_name=self.provider_name, + model_name=self.model_name or self.model_id, + text_postprocessor=self.text_postprocessor, + metadata={ + "output_format": "markdown", + "blocks": blocks, + "pipeline_metrics": metrics, + }, + ) + + def _ocr_sync(self, page: DocumentPage) -> OCRResult: + prepared_page = preprocess_backend_page( + page, + image_preprocessor=self.image_preprocessor, + ) + runtime = self._load_runtime() + processor = self._get_processor(runtime) + model = self._get_model(runtime) + + markdown, blocks, metrics = self._helper.run_two_step( + prepared_page.image, + infer_step=lambda image, step_key, _sampling: self._infer_step( + runtime=runtime, + processor=processor, + model=model, + image=image, + step_key=step_key, + batch_size=1, + ), + ) + return self._build_result( + markdown=markdown, + blocks=[dict(block) for block in blocks], + metrics=metrics, + ) + + def _ocr_batch_sync(self, pages: list[DocumentPage]) -> list[OCRResult]: + if not pages: + return [] + + runtime = self._load_runtime() + processor = self._get_processor(runtime) + model = self._get_model(runtime) + batch_size = len(pages) + results: list[OCRResult] = [] + for page in pages: + prepared_page = preprocess_backend_page( + page, + image_preprocessor=self.image_preprocessor, + ) + markdown, blocks, metrics = self._helper.run_two_step( + prepared_page.image, + infer_step=lambda image, step_key, _sampling: self._infer_step( + runtime=runtime, + processor=processor, + model=model, + image=image, + step_key=step_key, + batch_size=batch_size, + ), + ) + results.append( + self._build_result( + markdown=markdown, + blocks=[dict(block) for block in blocks], + metrics=metrics, + ) + ) + return results + + @dataclass(slots=True) class PaddleOCRVL15OCRBackend(HuggingFaceVisionOCRBackend): """Preset OCR backend for ``PaddlePaddle/PaddleOCR-VL-1.5``.""" @@ -1219,5 +1585,6 @@ def _ocr_batch_sync(self, pages: list[DocumentPage]) -> list[OCRResult]: "DotsOCR15OCRBackend", "HuggingFaceVisionOCRBackend", "LFM25VLOCRBackend", + "MinerU25OCRBackend", "PaddleOCRVL15OCRBackend", ] diff --git a/src/churro_ocr/providers/ocr.py b/src/churro_ocr/providers/ocr.py index 452b47d..8c3e703 100644 --- a/src/churro_ocr/providers/ocr.py +++ b/src/churro_ocr/providers/ocr.py @@ -9,7 +9,9 @@ from threading import Lock from typing import Any -from churro_ocr._internal.image import image_to_base64 +from PIL import Image + +from churro_ocr._internal.image import ensure_rgb, image_to_base64 from churro_ocr._internal.install import install_command_hint from churro_ocr._internal.litellm import LiteLLMTransport from churro_ocr._internal.prompt_logging import log_prompt_payload_once @@ -17,6 +19,10 @@ from churro_ocr.errors import ConfigurationError, ProviderError from churro_ocr.ocr import OCRBackend, OCRResult from churro_ocr.page_detection import DocumentPage +from churro_ocr.providers._mineru25 import ( + MinerU25PipelineHelper, + MinerU25SamplingParams, +) from churro_ocr.providers._shared import build_ocr_result, preprocess_backend_page from churro_ocr.providers.specs import ( DEFAULT_OCR_MAX_TOKENS, @@ -29,6 +35,18 @@ ) from churro_ocr.templates import ( DEFAULT_OCR_TEMPLATE, + MINERU2_5_2509_1_2B_FORMULA_PROMPT, + MINERU2_5_2509_1_2B_FORMULA_TEMPLATE, + MINERU2_5_2509_1_2B_IMAGE_ANALYSIS_PROMPT, + MINERU2_5_2509_1_2B_IMAGE_ANALYSIS_TEMPLATE, + MINERU2_5_2509_1_2B_LAYOUT_PROMPT, + MINERU2_5_2509_1_2B_LAYOUT_TEMPLATE, + MINERU2_5_2509_1_2B_MODEL_ID, + MINERU2_5_2509_1_2B_OCR_PROMPT, + MINERU2_5_2509_1_2B_OCR_TEMPLATE, + MINERU2_5_2509_1_2B_SYSTEM_PROMPT, + MINERU2_5_2509_1_2B_TABLE_PROMPT, + MINERU2_5_2509_1_2B_TABLE_TEMPLATE, OCRPromptTemplateLike, build_ocr_conversation, ) @@ -152,6 +170,206 @@ def __init__( ) +def _clone_transport_config( + config: LiteLLMTransportConfig, + *, + completion_kwargs: dict[str, object], +) -> LiteLLMTransportConfig: + return LiteLLMTransportConfig( + api_base=config.api_base, + api_key=config.api_key, + api_version=config.api_version, + image_detail=config.image_detail, + completion_kwargs=completion_kwargs, + cache_dir=config.cache_dir, + ) + + +def _mineru25_completion_kwargs(sampling: MinerU25SamplingParams) -> dict[str, object]: + kwargs: dict[str, object] = {"skip_special_tokens": False} + if sampling.temperature is not None: + kwargs["temperature"] = sampling.temperature + if sampling.top_p is not None: + kwargs["top_p"] = sampling.top_p + if sampling.top_k is not None: + kwargs["top_k"] = sampling.top_k + if sampling.presence_penalty is not None: + kwargs["presence_penalty"] = sampling.presence_penalty + if sampling.frequency_penalty is not None: + kwargs["frequency_penalty"] = sampling.frequency_penalty + if sampling.repetition_penalty is not None: + kwargs["repetition_penalty"] = sampling.repetition_penalty + if sampling.no_repeat_ngram_size is not None: + kwargs["vllm_xargs"] = { + "no_repeat_ngram_size": sampling.no_repeat_ngram_size, + "debug": False, + } + if sampling.max_new_tokens is not None: + kwargs["max_completion_tokens"] = sampling.max_new_tokens + return kwargs + + +def _merge_completion_kwargs( + step_defaults: dict[str, object], + overrides: dict[str, object], +) -> dict[str, object]: + merged = dict(step_defaults) + for key, value in overrides.items(): + existing = merged.get(key) + if isinstance(existing, dict) and isinstance(value, dict): + merged[key] = {**existing, **value} + continue + merged[key] = value + return merged + + +class MinerU25OpenAICompatibleOCRBackend(OpenAICompatibleOCRBackend): + """Two-step MinerU2.5 OCR backend for OpenAI-compatible servers such as vLLM.""" + + __slots__ = ( + "layout_template", + "table_template", + "formula_template", + "image_analysis_template", + "_helper", + ) + + layout_template: OCRPromptTemplateLike + table_template: OCRPromptTemplateLike + formula_template: OCRPromptTemplateLike + image_analysis_template: OCRPromptTemplateLike + _helper: MinerU25PipelineHelper + + def __init__( + self, + *, + model: str, + transport: LiteLLMTransport | None = None, + model_prefix: str = "openai", + template: OCRPromptTemplateLike = MINERU2_5_2509_1_2B_OCR_TEMPLATE, + layout_template: OCRPromptTemplateLike = MINERU2_5_2509_1_2B_LAYOUT_TEMPLATE, + table_template: OCRPromptTemplateLike = MINERU2_5_2509_1_2B_TABLE_TEMPLATE, + formula_template: OCRPromptTemplateLike = MINERU2_5_2509_1_2B_FORMULA_TEMPLATE, + image_analysis_template: OCRPromptTemplateLike = MINERU2_5_2509_1_2B_IMAGE_ANALYSIS_TEMPLATE, + image_preprocessor: ImagePreprocessor = ensure_rgb, + text_postprocessor: TextPostprocessor = identity_text_postprocessor, + model_name: str | None = "MinerU2.5-2509-1.2B", + ) -> None: + """Create a two-step MinerU2.5 OCR backend for an OpenAI-compatible server.""" + super().__init__( + model=model, + transport=transport, + model_prefix=model_prefix, + template=template, + image_preprocessor=image_preprocessor, + text_postprocessor=text_postprocessor, + model_name=model_name or model, + ) + self.layout_template = layout_template + self.table_template = table_template + self.formula_template = formula_template + self.image_analysis_template = image_analysis_template + self._helper = MinerU25PipelineHelper( + prompts={ + "[default]": MINERU2_5_2509_1_2B_OCR_PROMPT, + "[layout]": MINERU2_5_2509_1_2B_LAYOUT_PROMPT, + "table": MINERU2_5_2509_1_2B_TABLE_PROMPT, + "equation": MINERU2_5_2509_1_2B_FORMULA_PROMPT, + "image": MINERU2_5_2509_1_2B_IMAGE_ANALYSIS_PROMPT, + "chart": MINERU2_5_2509_1_2B_IMAGE_ANALYSIS_PROMPT, + }, + system_prompt=MINERU2_5_2509_1_2B_SYSTEM_PROMPT, + ) + + def __post_init__(self) -> None: + """Skip the generic max-token injection for the MinerU2.5 two-step pipeline.""" + + def _template_for_step(self, step_key: str) -> OCRPromptTemplateLike: + if step_key == "[layout]": + return self.layout_template + if step_key == "table": + return self.table_template + if step_key == "equation": + return self.formula_template + if step_key in {"image", "chart"}: + return self.image_analysis_template + return self.template + + def _transport_for_step(self, sampling: MinerU25SamplingParams) -> LiteLLMTransport: + config = self.transport.config + return LiteLLMTransport( + _clone_transport_config( + config, + completion_kwargs=_merge_completion_kwargs( + _mineru25_completion_kwargs(sampling), + config.completion_kwargs, + ), + ) + ) + + async def _infer_step( + self, + image: Image.Image, + step_key: str, + sampling: MinerU25SamplingParams, + ) -> str: + conversation = build_ocr_conversation( + self._template_for_step(step_key), + DocumentPage.from_image(image), + ) + step_transport = self._transport_for_step(sampling) + messages = await asyncio.to_thread( + step_transport.prepare_messages_from_conversation, + conversation, + ) + log_prompt_payload_once( + payload={ + "step_key": step_key, + "conversation": conversation, + "messages": messages, + }, + provider_name=self.provider_name, + has_logged=lambda: self._has_logged_prompt, + lock=self._prompt_log_lock, + set_logged=lambda: setattr(self, "_has_logged_prompt", True), + ) + text = await step_transport.complete_text( + model=self.model, + messages=messages, + allow_empty=True, + ) + return self._helper.clean_response(text, step_key=step_key) + + async def ocr(self, page: DocumentPage) -> OCRResult: + """Run the MinerU2.5 two-step OCR pipeline for one page.""" + prepared_page = preprocess_backend_page( + page, + image_preprocessor=self.image_preprocessor, + ) + markdown, blocks, metrics = await self._helper.arun_two_step( + prepared_page.image, + infer_step=self._infer_step, + ) + return build_ocr_result( + markdown, + provider_name=self.provider_name, + model_name=self.model_name or MINERU2_5_2509_1_2B_MODEL_ID, + text_postprocessor=self.text_postprocessor, + metadata={ + "output_format": "markdown", + "blocks": [dict(block) for block in blocks], + "pipeline_metrics": metrics, + }, + ) + + async def ocr_batch(self, pages: list[DocumentPage]) -> list[OCRResult]: + """Run the MinerU2.5 two-step OCR pipeline for multiple pages.""" + results: list[OCRResult] = [] + for page in pages: + results.append(await self.ocr(page)) + return results + + @dataclass(slots=True) class AzureDocumentIntelligenceOCRBackend(OCRBackend): """Azure Document Intelligence OCR backend. diff --git a/src/churro_ocr/providers/specs.py b/src/churro_ocr/providers/specs.py index cc6fec8..024f1ce 100644 --- a/src/churro_ocr/providers/specs.py +++ b/src/churro_ocr/providers/specs.py @@ -33,6 +33,8 @@ DOTS_OCR_1_5_OCR_TEMPLATE, LFM2_5_VL_1_6B_MODEL_ID, LFM2_5_VL_1_6B_OCR_TEMPLATE, + MINERU2_5_2509_1_2B_MODEL_ID, + MINERU2_5_2509_1_2B_OCR_TEMPLATE, OLMOCR_2_7B_1025_FP8_MODEL_ID, OLMOCR_2_7B_1025_MODEL_ID, OLMOCR_2_7B_1025_OCR_TEMPLATE, @@ -516,6 +518,23 @@ def paddleocr_vl_1_5_profile() -> OCRModelProfile: ) +def mineru2_5_2509_1_2b_profile() -> OCRModelProfile: + """Return the built-in ``opendatalab/MinerU2.5-2509-1.2B`` OCR profile.""" + return OCRModelProfile( + profile_name=MINERU2_5_2509_1_2B_MODEL_ID, + template=MINERU2_5_2509_1_2B_OCR_TEMPLATE, + image_preprocessor=ensure_rgb, + text_postprocessor=identity_text_postprocessor, + display_name="MinerU2.5-2509-1.2B", + huggingface=HuggingFaceOptions( + processor_kwargs={ + "use_fast": True, + }, + backend_variant="mineru2.5", + ), + ) + + def _olmocr_profile(*, profile_name: str, display_name: str) -> OCRModelProfile: return OCRModelProfile( profile_name=profile_name, @@ -581,6 +600,7 @@ def _profile_registry() -> dict[str, OCRModelProfile]: dots_mocr = dots_mocr_profile() dots_profile = dots_ocr_1_5_profile() lfm2_5_vl_profile = lfm2_5_vl_1_6b_profile() + mineru2_5_profile = mineru2_5_2509_1_2b_profile() olmocr_profile = olmocr_2_7b_1025_profile() olmocr_fp8_profile = olmocr_2_7b_1025_fp8_profile() paddleocr_vl_profile = paddleocr_vl_1_5_profile() @@ -592,6 +612,7 @@ def _profile_registry() -> dict[str, OCRModelProfile]: dots_mocr.profile_name: dots_mocr, dots_profile.profile_name: dots_profile, lfm2_5_vl_profile.profile_name: lfm2_5_vl_profile, + mineru2_5_profile.profile_name: mineru2_5_profile, olmocr_profile.profile_name: olmocr_profile, olmocr_fp8_profile.profile_name: olmocr_fp8_profile, paddleocr_vl_profile.profile_name: paddleocr_vl_profile, @@ -645,6 +666,7 @@ def resolve_ocr_profile( "MistralOCRModel", "MISTRAL_OCR_MODEL_IDS", "MistralOptions", + "mineru2_5_2509_1_2b_profile", "olmocr_image_preprocessor", "olmocr_text_postprocessor", "paddleocr_vl_1_5_profile", diff --git a/src/churro_ocr/templates/__init__.py b/src/churro_ocr/templates/__init__.py index 755da05..16f3fc7 100644 --- a/src/churro_ocr/templates/__init__.py +++ b/src/churro_ocr/templates/__init__.py @@ -25,6 +25,18 @@ DOTS_OCR_1_5_OCR_TEMPLATE, LFM2_5_VL_1_6B_MODEL_ID, LFM2_5_VL_1_6B_OCR_TEMPLATE, + MINERU2_5_2509_1_2B_FORMULA_PROMPT, + MINERU2_5_2509_1_2B_FORMULA_TEMPLATE, + MINERU2_5_2509_1_2B_IMAGE_ANALYSIS_PROMPT, + MINERU2_5_2509_1_2B_IMAGE_ANALYSIS_TEMPLATE, + MINERU2_5_2509_1_2B_LAYOUT_PROMPT, + MINERU2_5_2509_1_2B_LAYOUT_TEMPLATE, + MINERU2_5_2509_1_2B_MODEL_ID, + MINERU2_5_2509_1_2B_OCR_PROMPT, + MINERU2_5_2509_1_2B_OCR_TEMPLATE, + MINERU2_5_2509_1_2B_SYSTEM_PROMPT, + MINERU2_5_2509_1_2B_TABLE_PROMPT, + MINERU2_5_2509_1_2B_TABLE_TEMPLATE, OLMOCR_2_7B_1025_FP8_MODEL_ID, OLMOCR_2_7B_1025_MODEL_ID, OLMOCR_2_7B_1025_OCR_TEMPLATE, @@ -49,6 +61,18 @@ "DOTS_OCR_1_5_MODEL_ID", "DOTS_OCR_1_5_OCR_PROMPT", "DOTS_OCR_1_5_OCR_TEMPLATE", + "MINERU2_5_2509_1_2B_FORMULA_PROMPT", + "MINERU2_5_2509_1_2B_FORMULA_TEMPLATE", + "MINERU2_5_2509_1_2B_IMAGE_ANALYSIS_PROMPT", + "MINERU2_5_2509_1_2B_IMAGE_ANALYSIS_TEMPLATE", + "MINERU2_5_2509_1_2B_LAYOUT_PROMPT", + "MINERU2_5_2509_1_2B_LAYOUT_TEMPLATE", + "MINERU2_5_2509_1_2B_MODEL_ID", + "MINERU2_5_2509_1_2B_OCR_PROMPT", + "MINERU2_5_2509_1_2B_OCR_TEMPLATE", + "MINERU2_5_2509_1_2B_SYSTEM_PROMPT", + "MINERU2_5_2509_1_2B_TABLE_PROMPT", + "MINERU2_5_2509_1_2B_TABLE_TEMPLATE", "LFM2_5_VL_1_6B_MODEL_ID", "LFM2_5_VL_1_6B_OCR_TEMPLATE", "PADDLEOCR_VL_1_5_MODEL_ID", diff --git a/src/churro_ocr/templates/presets.py b/src/churro_ocr/templates/presets.py index 6e31226..4ecccee 100644 --- a/src/churro_ocr/templates/presets.py +++ b/src/churro_ocr/templates/presets.py @@ -15,6 +15,7 @@ DEEPSEEK_OCR_2_MODEL_ID = "deepseek-ai/DeepSeek-OCR-2" DOTS_OCR_1_5_MODEL_ID = "kristaller486/dots.ocr-1.5" DOTS_MOCR_MODEL_ID = "rednote-hilab/dots.mocr" +MINERU2_5_2509_1_2B_MODEL_ID = "opendatalab/MinerU2.5-2509-1.2B" PADDLEOCR_VL_1_5_MODEL_ID = "PaddlePaddle/PaddleOCR-VL-1.5" OLMOCR_2_7B_1025_MODEL_ID = "allenai/olmOCR-2-7B-1025" OLMOCR_2_7B_1025_FP8_MODEL_ID = "allenai/olmOCR-2-7B-1025-FP8" @@ -47,6 +48,32 @@ system_message=None, user_prompt=DOTS_MOCR_OCR_PROMPT, ) +MINERU2_5_2509_1_2B_SYSTEM_PROMPT = "You are a helpful assistant." +MINERU2_5_2509_1_2B_LAYOUT_PROMPT = "\nLayout Detection:" +MINERU2_5_2509_1_2B_TABLE_PROMPT = "\nTable Recognition:" +MINERU2_5_2509_1_2B_FORMULA_PROMPT = "\nFormula Recognition:" +MINERU2_5_2509_1_2B_IMAGE_ANALYSIS_PROMPT = "\nImage Analysis:" +MINERU2_5_2509_1_2B_OCR_PROMPT = "\nText Recognition:" +MINERU2_5_2509_1_2B_LAYOUT_TEMPLATE = HFChatTemplate( + system_message=MINERU2_5_2509_1_2B_SYSTEM_PROMPT, + user_prompt=MINERU2_5_2509_1_2B_LAYOUT_PROMPT, +) +MINERU2_5_2509_1_2B_TABLE_TEMPLATE = HFChatTemplate( + system_message=MINERU2_5_2509_1_2B_SYSTEM_PROMPT, + user_prompt=MINERU2_5_2509_1_2B_TABLE_PROMPT, +) +MINERU2_5_2509_1_2B_FORMULA_TEMPLATE = HFChatTemplate( + system_message=MINERU2_5_2509_1_2B_SYSTEM_PROMPT, + user_prompt=MINERU2_5_2509_1_2B_FORMULA_PROMPT, +) +MINERU2_5_2509_1_2B_IMAGE_ANALYSIS_TEMPLATE = HFChatTemplate( + system_message=MINERU2_5_2509_1_2B_SYSTEM_PROMPT, + user_prompt=MINERU2_5_2509_1_2B_IMAGE_ANALYSIS_PROMPT, +) +MINERU2_5_2509_1_2B_OCR_TEMPLATE = HFChatTemplate( + system_message=MINERU2_5_2509_1_2B_SYSTEM_PROMPT, + user_prompt=MINERU2_5_2509_1_2B_OCR_PROMPT, +) PADDLEOCR_VL_1_5_OCR_PROMPT = "OCR:" PADDLEOCR_VL_1_5_OCR_TEMPLATE = HFChatTemplate( system_message=None, @@ -78,6 +105,18 @@ "DOTS_OCR_1_5_MODEL_ID", "DOTS_OCR_1_5_OCR_PROMPT", "DOTS_OCR_1_5_OCR_TEMPLATE", + "MINERU2_5_2509_1_2B_FORMULA_PROMPT", + "MINERU2_5_2509_1_2B_FORMULA_TEMPLATE", + "MINERU2_5_2509_1_2B_IMAGE_ANALYSIS_PROMPT", + "MINERU2_5_2509_1_2B_IMAGE_ANALYSIS_TEMPLATE", + "MINERU2_5_2509_1_2B_LAYOUT_PROMPT", + "MINERU2_5_2509_1_2B_LAYOUT_TEMPLATE", + "MINERU2_5_2509_1_2B_MODEL_ID", + "MINERU2_5_2509_1_2B_OCR_PROMPT", + "MINERU2_5_2509_1_2B_OCR_TEMPLATE", + "MINERU2_5_2509_1_2B_SYSTEM_PROMPT", + "MINERU2_5_2509_1_2B_TABLE_PROMPT", + "MINERU2_5_2509_1_2B_TABLE_TEMPLATE", "PADDLEOCR_VL_1_5_MODEL_ID", "PADDLEOCR_VL_1_5_OCR_PROMPT", "PADDLEOCR_VL_1_5_OCR_TEMPLATE", diff --git a/tests/test_cli.py b/tests/test_cli.py index c564195..eb88e0c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -25,6 +25,8 @@ DEFAULT_OCR_TEMPLATE, DOTS_MOCR_OCR_TEMPLATE, DOTS_OCR_1_5_OCR_TEMPLATE, + MINERU2_5_2509_1_2B_MODEL_ID, + MINERU2_5_2509_1_2B_OCR_TEMPLATE, OLMOCR_2_7B_1025_MODEL_ID, OLMOCR_2_7B_1025_OCR_TEMPLATE, PADDLEOCR_VL_1_5_MODEL_ID, @@ -336,6 +338,43 @@ def test_build_ocr_backend_aligns_templates_for_paddleocr_vl() -> None: } +def test_build_ocr_backend_aligns_templates_for_mineru2_5() -> None: + hf_backend = cli_module._build_ocr_backend( + backend="hf", + model=MINERU2_5_2509_1_2B_MODEL_ID, + endpoint=None, + api_key=None, + base_url=None, + api_version=None, + ) + openai_backend = cli_module._build_ocr_backend( + backend="openai-compatible", + model=MINERU2_5_2509_1_2B_MODEL_ID, + endpoint=None, + api_key=None, + base_url="http://127.0.0.1:8000/v1", + api_version=None, + ) + + assert hf_backend.template == MINERU2_5_2509_1_2B_OCR_TEMPLATE + assert hf_backend.template == openai_backend.template + assert hf_backend.model_name == "MinerU2.5-2509-1.2B" + assert openai_backend.model_name == "MinerU2.5-2509-1.2B" + assert openai_backend.transport.config.completion_kwargs == {} + + +def test_build_ocr_backend_rejects_mineru2_5_for_litellm() -> None: + with pytest.raises(ConfigurationError, match="MinerU2.5 requires the built-in two-step pipeline"): + cli_module._build_ocr_backend( + backend="litellm", + model=MINERU2_5_2509_1_2B_MODEL_ID, + endpoint=None, + api_key=None, + base_url=None, + api_version=None, + ) + + def test_build_ocr_backend_uses_generic_defaults_for_qwen_3_5_0_8b() -> None: litellm_backend = cli_module._build_ocr_backend( backend="litellm", diff --git a/tests/test_hf_ocr.py b/tests/test_hf_ocr.py index 70bdb71..9bd5731 100644 --- a/tests/test_hf_ocr.py +++ b/tests/test_hf_ocr.py @@ -20,6 +20,11 @@ parse_olmocr_response, ) from churro_ocr.providers import OCRBackendSpec, build_ocr_backend +from churro_ocr.providers._mineru25 import ( + MinerU25PipelineHelper, + convert_mineru2_5_otsl_to_html, + wrap_mineru2_5_equation, +) from churro_ocr.providers.hf import ( ChandraOCR2OCRBackend, Churro3BOCRBackend, @@ -28,6 +33,7 @@ DotsOCR15OCRBackend, HuggingFaceVisionOCRBackend, LFM25VLOCRBackend, + MinerU25OCRBackend, PaddleOCRVL15OCRBackend, ) from churro_ocr.providers.specs import ( @@ -50,6 +56,18 @@ DOTS_OCR_1_5_OCR_TEMPLATE, LFM2_5_VL_1_6B_MODEL_ID, LFM2_5_VL_1_6B_OCR_TEMPLATE, + MINERU2_5_2509_1_2B_FORMULA_PROMPT, + MINERU2_5_2509_1_2B_FORMULA_TEMPLATE, + MINERU2_5_2509_1_2B_IMAGE_ANALYSIS_PROMPT, + MINERU2_5_2509_1_2B_IMAGE_ANALYSIS_TEMPLATE, + MINERU2_5_2509_1_2B_LAYOUT_PROMPT, + MINERU2_5_2509_1_2B_LAYOUT_TEMPLATE, + MINERU2_5_2509_1_2B_MODEL_ID, + MINERU2_5_2509_1_2B_OCR_PROMPT, + MINERU2_5_2509_1_2B_OCR_TEMPLATE, + MINERU2_5_2509_1_2B_SYSTEM_PROMPT, + MINERU2_5_2509_1_2B_TABLE_PROMPT, + MINERU2_5_2509_1_2B_TABLE_TEMPLATE, OLMOCR_2_7B_1025_MODEL_ID, OLMOCR_2_7B_1025_OCR_TEMPLATE, PADDLEOCR_VL_1_5_MODEL_ID, @@ -106,6 +124,75 @@ def test_deepseek_ocr_2_template_builds_image_before_prompt() -> None: assert conversation[0]["content"][1]["text"] == DEEPSEEK_OCR_2_OCR_PROMPT +def test_mineru2_5_template_matches_upstream_prompt_shape() -> None: + page = DocumentPage.from_image(Image.new("RGB", (20, 20), color="white")) + + conversation = MINERU2_5_2509_1_2B_OCR_TEMPLATE.build_conversation(page) + + assert conversation[0]["role"] == "system" + assert conversation[0]["content"][0]["text"] == MINERU2_5_2509_1_2B_SYSTEM_PROMPT + assert conversation[1]["role"] == "user" + assert conversation[1]["content"][0]["type"] == "image" + assert conversation[1]["content"][1]["text"] == MINERU2_5_2509_1_2B_OCR_PROMPT + + +def test_mineru2_5_end_to_end_templates_cover_layout_table_formula_and_image_prompts() -> None: + page = DocumentPage.from_image(Image.new("RGB", (20, 20), color="white")) + + layout_conversation = MINERU2_5_2509_1_2B_LAYOUT_TEMPLATE.build_conversation(page) + table_conversation = MINERU2_5_2509_1_2B_TABLE_TEMPLATE.build_conversation(page) + formula_conversation = MINERU2_5_2509_1_2B_FORMULA_TEMPLATE.build_conversation(page) + image_conversation = MINERU2_5_2509_1_2B_IMAGE_ANALYSIS_TEMPLATE.build_conversation(page) + + assert layout_conversation[0]["content"][0]["text"] == MINERU2_5_2509_1_2B_SYSTEM_PROMPT + assert layout_conversation[1]["content"][1]["text"] == MINERU2_5_2509_1_2B_LAYOUT_PROMPT + assert table_conversation[1]["content"][1]["text"] == MINERU2_5_2509_1_2B_TABLE_PROMPT + assert formula_conversation[1]["content"][1]["text"] == MINERU2_5_2509_1_2B_FORMULA_PROMPT + assert image_conversation[1]["content"][1]["text"] == MINERU2_5_2509_1_2B_IMAGE_ANALYSIS_PROMPT + + +def test_parse_and_render_mineru2_5_end_to_end_blocks() -> None: + helper = MinerU25PipelineHelper( + prompts={ + "[default]": MINERU2_5_2509_1_2B_OCR_PROMPT, + "[layout]": MINERU2_5_2509_1_2B_LAYOUT_PROMPT, + "table": MINERU2_5_2509_1_2B_TABLE_PROMPT, + "equation": MINERU2_5_2509_1_2B_FORMULA_PROMPT, + "image": MINERU2_5_2509_1_2B_IMAGE_ANALYSIS_PROMPT, + "chart": MINERU2_5_2509_1_2B_IMAGE_ANALYSIS_PROMPT, + }, + system_prompt=MINERU2_5_2509_1_2B_SYSTEM_PROMPT, + ) + blocks = helper.parse_layout_output( + "<|box_start|>0 0 1000 100<|box_end|><|ref_start|>header<|ref_end|>\n" + "<|box_start|>0 100 1000 400<|box_end|><|ref_start|>text<|ref_end|>\n" + "<|box_start|>0 400 1000 500<|box_end|><|ref_start|>text<|ref_end|>txt_contd_tgt\n" + "<|box_start|>0 500 1000 800<|box_end|><|ref_start|>table<|ref_end|>\n" + "<|box_start|>0 800 1000 1000<|box_end|><|ref_start|>equation<|ref_end|><|rotate_right|>\n" + "<|box_start|>1001 0 1100 10<|box_end|><|ref_start|>bad<|ref_end|>" + ) + blocks[0].content = "Page header" + blocks[1].content = "Body" + blocks[2].content = "text" + blocks[3].content = "YearValue190042" + blocks[4].content = "x = y" + + assert [block.type for block in blocks] == ["header", "text", "text", "table", "equation"] + assert blocks[2].merge_prev is True + assert blocks[4].angle == 90 + assert convert_mineru2_5_otsl_to_html(blocks[3].content or "") == ( + "
YearValue
190042
" + ) + assert wrap_mineru2_5_equation("x = y") == "\\[\nx = y\n\\]" + processed = helper.post_process(blocks) + assert helper.render_markdown(processed) == ( + "Page header\n\n" + "Body text\n\n" + "
YearValue
190042
\n\n" + "\\[\nx = y\n\\]" + ) + + def test_parse_olmocr_response_extracts_plain_text_and_metadata() -> None: text, metadata = parse_olmocr_response( "---\n" @@ -287,6 +374,29 @@ def test_build_ocr_backend_uses_paddleocr_vl_profile_defaults_for_hf() -> None: } +def test_build_ocr_backend_uses_mineru2_5_profile_defaults_for_hf() -> None: + backend = cast( + "MinerU25OCRBackend", + build_ocr_backend( + OCRBackendSpec( + provider="hf", + model=MINERU2_5_2509_1_2B_MODEL_ID, + ) + ), + ) + + assert isinstance(backend, MinerU25OCRBackend) + assert backend.template == MINERU2_5_2509_1_2B_OCR_TEMPLATE + assert backend.model_name == "MinerU2.5-2509-1.2B" + assert backend.trust_remote_code is False + assert backend.processor_kwargs == {"use_fast": True} + assert backend.model_kwargs == {} + preprocessed_image = backend.image_preprocessor(Image.new("RGBA", (32, 16), color=(255, 255, 255, 255))) + assert preprocessed_image.size == (32, 16) + assert preprocessed_image.mode == "RGB" + assert backend.generation_kwargs == {} + + def test_build_ocr_backend_uses_dots_mocr_profile_defaults_for_hf() -> None: backend = cast( "DotsMOCROCRBackend", @@ -1167,6 +1277,252 @@ def test_deepseek_ocr_2_backend_uses_expected_defaults() -> None: assert backend.crop_mode is True +@pytest.mark.asyncio +async def test_mineru2_5_huggingface_backend_uses_two_step_generation_contract( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured: dict[str, Any] = {} + + class FakeBatch(dict[str, object]): + def to(self, device: object) -> FakeBatch: + captured["device"] = device + return self + + class FakeProcessor: + def apply_chat_template( + self, + conversation: object, + *, + add_generation_prompt: bool, + tokenize: bool, + ) -> object: + captured.setdefault("chat_calls", []).append( + { + "conversation": conversation, + "add_generation_prompt": add_generation_prompt, + "tokenize": tokenize, + } + ) + if not tokenize: + conversation_messages = cast("list[dict[str, object]]", conversation) + user_content = cast("list[dict[str, object]]", conversation_messages[1]["content"]) + prompt = cast("str", user_content[1]["text"]) + return (f"",) + raise AssertionError("tokenized chat template should not be used for MinerU2.5") + + def __call__(self, **kwargs: object) -> FakeBatch: + captured.setdefault("processor_call_kwargs", []).append(kwargs) + batch = FakeBatch( + { + "input_ids": object(), + "attention_mask": object(), + "pixel_values": object(), + } + ) + return batch + + class FakeProcessorCls: + @staticmethod + def from_pretrained(model_id: str, **kwargs: object) -> FakeProcessor: + captured["processor_model_id"] = model_id + captured["processor_from_pretrained_kwargs"] = kwargs + return FakeProcessor() + + class FakeModel: + device = "fake-device" + dtype = "fake-bfloat16" + config = SimpleNamespace(max_position_embeddings=8_192) + + def eval(self) -> FakeModel: + captured["eval_called"] = True + return self + + def generate(self, **kwargs: object) -> list[list[int]]: + captured.setdefault("generate_kwargs", []).append(kwargs) + return [[101, 102, 103]] + + class FakeModelCls: + @staticmethod + def from_pretrained(model_id: str, **kwargs: object) -> FakeModel: + captured["model_model_id"] = model_id + captured["model_from_pretrained_kwargs"] = kwargs + return FakeModel() + + def _fake_process_vision_info( + conversation: object, + **_: object, + ) -> tuple[list[object], None, None]: + conversation_messages = cast("list[dict[str, object]]", conversation) + user_content = cast("list[dict[str, object]]", conversation_messages[1]["content"]) + return ([user_content[0]["image"]], None, None) + + monkeypatch.setattr( + "churro_ocr.providers.hf._load_hf_runtime", + lambda: SimpleNamespace( + processor_cls=FakeProcessorCls, + model_cls=FakeModelCls, + process_vision_info=_fake_process_vision_info, + ), + ) + monkeypatch.setattr( + "churro_ocr.providers.hf._default_mineru25_model_kwargs", + lambda: {"device_map": "auto", "dtype": "auto"}, + ) + decode_responses = [ + "<|box_start|>0 0 1000 100<|box_end|><|ref_start|>header<|ref_end|>\n" + "<|box_start|>0 100 1000 550<|box_end|><|ref_start|>text<|ref_end|>\n" + "<|box_start|>0 550 1000 800<|box_end|><|ref_start|>table<|ref_end|>\n" + "<|box_start|>0 800 1000 1000<|box_end|><|ref_start|>equation<|ref_end|>", + "Page header<|im_end|><|endoftext|>", + "Body text<|im_end|><|endoftext|>", + "YearValue190042<|im_end|><|endoftext|>", + "x = y<|im_end|><|endoftext|>", + ] + monkeypatch.setattr( + "churro_ocr.providers.hf._decode_completion_texts_with_options", + lambda _processor, _batch, _generated_ids, *, skip_special_tokens: ( + [decode_responses.pop(0)] + if skip_special_tokens is False + else (_ for _ in ()).throw( + AssertionError("MinerU2.5 should preserve special tokens during decode") + ) + ), + ) + + backend = cast( + "MinerU25OCRBackend", + build_ocr_backend( + OCRBackendSpec( + provider="hf", + model=MINERU2_5_2509_1_2B_MODEL_ID, + ) + ), + ) + result = await backend.ocr( + DocumentPage.from_image(Image.new("RGBA", (32, 16), color=(255, 255, 255, 255))) + ) + + assert result.text == ( + "Page header\n\n" + "Body text\n\n" + "
YearValue
190042
\n\n" + "\\[\nx = y\n\\]" + ) + assert result.metadata["output_format"] == "markdown" + assert cast("dict[str, object]", result.metadata["pipeline_metrics"])["num_blocks"] == 4 + block_metadata = cast("list[dict[str, object]]", result.metadata["blocks"]) + assert [block["type"] for block in block_metadata] == ["header", "text", "table", "equation"] + assert block_metadata[0]["content"] == "Page header" + assert block_metadata[2]["content"] == ( + "
YearValue
190042
" + ) + assert block_metadata[3]["content"] == "\\[\nx = y\n\\]" + assert captured["processor_model_id"] == MINERU2_5_2509_1_2B_MODEL_ID + assert captured["model_model_id"] == MINERU2_5_2509_1_2B_MODEL_ID + assert captured["processor_from_pretrained_kwargs"] == { + "trust_remote_code": False, + "use_fast": True, + } + assert captured["model_from_pretrained_kwargs"] == { + "trust_remote_code": False, + "device_map": "auto", + "dtype": "auto", + } + assert captured["eval_called"] is True + chat_calls = cast("list[dict[str, object]]", captured["chat_calls"]) + render_conversation = chat_calls[0]["conversation"] + assert cast("list[dict[str, object]]", render_conversation)[0]["role"] == "system" + assert cast("list[dict[str, object]]", render_conversation)[1]["role"] == "user" + user_content = cast( + "list[dict[str, object]]", + cast("list[dict[str, object]]", render_conversation)[1]["content"], + ) + assert user_content[0]["type"] == "image" + assert user_content[1] == {"type": "text", "text": MINERU2_5_2509_1_2B_LAYOUT_PROMPT} + processor_calls = cast("list[dict[str, object]]", captured["processor_call_kwargs"]) + prompt_texts = [cast("list[str]", call["text"])[0] for call in processor_calls] + assert prompt_texts == [ + f"", + f"", + f"", + f"", + f"", + ] + assert processor_calls[0]["return_tensors"] == "pt" + assert processor_calls[0]["padding"] is True + assert cast("list[Image.Image]", processor_calls[0]["images"])[0].size == (1_036, 1_036) + assert captured["device"] == "fake-device" + generate_kwargs = cast("list[dict[str, object]]", captured["generate_kwargs"]) + assert len(generate_kwargs) == 5 + assert all(kwargs["do_sample"] is False for kwargs in generate_kwargs) + assert all(kwargs["no_repeat_ngram_size"] == 100 for kwargs in generate_kwargs) + assert all(kwargs["repetition_penalty"] == 1.0 for kwargs in generate_kwargs) + assert all(kwargs["max_length"] == 8_192 for kwargs in generate_kwargs) + + +def test_mineru2_5_backend_uses_expected_defaults() -> None: + backend = MinerU25OCRBackend() + + assert backend.model_id == MINERU2_5_2509_1_2B_MODEL_ID + assert backend.template == MINERU2_5_2509_1_2B_OCR_TEMPLATE + assert backend.layout_template == MINERU2_5_2509_1_2B_LAYOUT_TEMPLATE + assert backend.table_template == MINERU2_5_2509_1_2B_TABLE_TEMPLATE + assert backend.formula_template == MINERU2_5_2509_1_2B_FORMULA_TEMPLATE + assert backend.image_analysis_template == MINERU2_5_2509_1_2B_IMAGE_ANALYSIS_TEMPLATE + assert backend.model_name == "MinerU2.5-2509-1.2B" + assert backend.trust_remote_code is False + assert backend.processor_kwargs == {} + assert backend.model_kwargs == {} + assert backend.generation_kwargs == {} + assert backend.image_preprocessor(Image.new("RGBA", (10, 10), color=(255, 255, 255, 255))).mode == "RGB" + + +def test_decode_completion_texts_can_preserve_special_tokens() -> None: + captured: dict[str, object] = {} + + class FakeProcessor: + def batch_decode( + self, ids: object, *, skip_special_tokens: bool, clean_up_tokenization_spaces: bool + ) -> list[str]: + captured["ids"] = ids + captured["skip_special_tokens"] = skip_special_tokens + captured["clean_up_tokenization_spaces"] = clean_up_tokenization_spaces + return ["<|box_start|>0 0 1000 100<|box_end|>"] + + class FakeMask: + def sum(self, dim: int) -> object: + assert dim == 1 + return SimpleNamespace(tolist=lambda: [2]) + + batch = { + "attention_mask": FakeMask(), + "input_ids": SimpleNamespace(shape=(1, 2)), + } + generated_ids = [[11, 12, 13, 14]] + + decoded = hf_module._decode_completion_texts_with_options( + FakeProcessor(), + batch, + generated_ids, + skip_special_tokens=False, + ) + + assert decoded == ["<|box_start|>0 0 1000 100<|box_end|>"] + assert captured["ids"] == [[13, 14]] + assert captured["skip_special_tokens"] is False + assert captured["clean_up_tokenization_spaces"] is False + + +def test_resolve_model_max_length_supports_qwen2vl_text_config() -> None: + model = SimpleNamespace( + config=SimpleNamespace( + text_config=SimpleNamespace(max_position_embeddings=16_384), + ) + ) + + assert hf_module._resolve_model_max_length(model) == 16_384 + + def test_dots_mocr_backend_uses_expected_defaults() -> None: backend = DotsMOCROCRBackend() @@ -1571,9 +1927,7 @@ class WrappedFakeDotsModel(FakeDotsOwner): pass cast("Any", FakeDotsOwner).prepare_inputs_for_generation = shared_prepare_inputs_for_generation - cast("Any", WrappedFakeDotsModel).prepare_inputs_for_generation = ( - shared_prepare_inputs_for_generation - ) + cast("Any", WrappedFakeDotsModel).prepare_inputs_for_generation = shared_prepare_inputs_for_generation model = WrappedFakeDotsModel() diff --git a/tests/test_providers.py b/tests/test_providers.py index c7c9828..eadc8a6 100644 --- a/tests/test_providers.py +++ b/tests/test_providers.py @@ -11,7 +11,7 @@ import churro_ocr._internal.retry as retry_module from churro_ocr._internal.litellm import LiteLLMTransport -from churro_ocr.errors import ProviderError +from churro_ocr.errors import ConfigurationError, ProviderError from churro_ocr.page_detection import DocumentPage from churro_ocr.prompts import ( CHANDRA_OCR_LAYOUT_PROMPT, @@ -36,6 +36,7 @@ from churro_ocr.providers.ocr import ( AzureDocumentIntelligenceOCRBackend, LiteLLMVisionOCRBackend, + MinerU25OpenAICompatibleOCRBackend, MistralOCRBackend, OpenAICompatibleOCRBackend, ) @@ -53,6 +54,9 @@ DEEPSEEK_OCR_2_OCR_TEMPLATE, DEFAULT_OCR_TEMPLATE, DOTS_MOCR_OCR_TEMPLATE, + MINERU2_5_2509_1_2B_MODEL_ID, + MINERU2_5_2509_1_2B_OCR_PROMPT, + MINERU2_5_2509_1_2B_OCR_TEMPLATE, OLMOCR_2_7B_1025_FP8_MODEL_ID, OLMOCR_2_7B_1025_MODEL_ID, OLMOCR_2_7B_1025_OCR_TEMPLATE, @@ -798,6 +802,34 @@ def test_build_ocr_backend_uses_paddleocr_vl_profile_defaults_for_openai_compati } +def test_build_ocr_backend_uses_mineru2_5_profile_defaults_for_openai_compatible() -> None: + backend = cast( + "MinerU25OpenAICompatibleOCRBackend", + build_ocr_backend( + OCRBackendSpec( + provider="openai-compatible", + model=MINERU2_5_2509_1_2B_MODEL_ID, + transport=LiteLLMTransportConfig(api_base="http://127.0.0.1:8000/v1"), + ) + ), + ) + + assert isinstance(backend, MinerU25OpenAICompatibleOCRBackend) + assert backend.template == MINERU2_5_2509_1_2B_OCR_TEMPLATE + assert backend.model_name == "MinerU2.5-2509-1.2B" + assert backend.transport.config.completion_kwargs == {} + + +def test_build_ocr_backend_rejects_mineru2_5_for_litellm() -> None: + with pytest.raises(ConfigurationError, match="MinerU2.5 requires the built-in two-step pipeline"): + build_ocr_backend( + OCRBackendSpec( + provider="litellm", + model=MINERU2_5_2509_1_2B_MODEL_ID, + ) + ) + + def test_build_ocr_backend_uses_dots_mocr_profile_defaults_for_openai_compatible() -> None: backend = cast( "OpenAICompatibleOCRBackend", @@ -1159,6 +1191,118 @@ async def _fake_complete_text( assert user_content[1] == {"type": "text", "text": PADDLEOCR_VL_1_5_OCR_PROMPT} +@pytest.mark.asyncio +async def test_openai_compatible_backend_uses_mineru2_5_two_step_pipeline( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls: list[dict[str, object]] = [] + captured: dict[str, object] = {"calls": calls} + + def _fake_prepare_messages_from_conversation( + self: LiteLLMTransport, + conversation: list[dict[str, object]], + ) -> list[dict[str, object]]: + calls.append( + { + "conversation": conversation, + "completion_kwargs": dict(self.config.completion_kwargs), + } + ) + return [{"role": "user", "content": [{"type": "text", "text": "prompt"}]}] + + async def _fake_complete_text( + self: LiteLLMTransport, + *, + model: str, + messages: list[dict[str, object]], + timeout_seconds: int = 600, + output_json: bool = False, + allow_empty: bool = False, + ) -> str: + captured["model"] = model + captured["messages"] = messages + captured["timeout_seconds"] = timeout_seconds + captured["output_json"] = output_json + captured["allow_empty"] = allow_empty + call_index = len(calls) - 1 + call = calls[call_index] + conversation = cast("list[dict[str, object]]", call["conversation"]) + user_content = cast("list[dict[str, object]]", conversation[1]["content"]) + prompt = cast("str", user_content[1]["text"]) + if prompt == MINERU2_5_2509_1_2B_OCR_PROMPT: + return "plain body<|im_end|><|endoftext|>" + return "<|box_start|>0 100 1000 400<|box_end|><|ref_start|>text<|ref_end|>\n" + + monkeypatch.setattr( + LiteLLMTransport, + "prepare_messages_from_conversation", + _fake_prepare_messages_from_conversation, + ) + monkeypatch.setattr(LiteLLMTransport, "complete_text", _fake_complete_text) + + backend = cast( + "MinerU25OpenAICompatibleOCRBackend", + build_ocr_backend( + OCRBackendSpec( + provider="openai-compatible", + model=MINERU2_5_2509_1_2B_MODEL_ID, + transport=LiteLLMTransportConfig(api_base="http://127.0.0.1:8000/v1"), + ) + ), + ) + result = await backend.ocr(DocumentPage.from_image(Image.new("RGB", (10, 10), color="white"))) + + assert result.text == "plain body" + assert result.model_name == "MinerU2.5-2509-1.2B" + assert result.metadata["output_format"] == "markdown" + pipeline_metrics = cast("dict[str, object]", result.metadata["pipeline_metrics"]) + assert pipeline_metrics["num_blocks"] == 1 + assert cast("float", pipeline_metrics["layout_elapsed"]) >= 0.0 + assert cast("float", pipeline_metrics["extract_elapsed"]) >= 0.0 + assert cast("float", pipeline_metrics["total_elapsed"]) >= cast( + "float", pipeline_metrics["extract_elapsed"] + ) + assert [block["type"] for block in cast("list[dict[str, object]]", result.metadata["blocks"])] == ["text"] + assert captured["model"] == f"openai/{MINERU2_5_2509_1_2B_MODEL_ID}" + assert captured["messages"] == [{"role": "user", "content": [{"type": "text", "text": "prompt"}]}] + assert captured["timeout_seconds"] == 600 + assert captured["output_json"] is False + assert captured["allow_empty"] is True + assert len(calls) == 2 + layout_conversation = cast("list[dict[str, object]]", calls[0]["conversation"]) + assert layout_conversation[0]["role"] == "system" + layout_system_content = cast("list[dict[str, object]]", layout_conversation[0]["content"]) + assert layout_system_content[0]["text"] == "You are a helpful assistant." + assert layout_conversation[1]["role"] == "user" + layout_user_content = cast("list[dict[str, object]]", layout_conversation[1]["content"]) + assert layout_user_content[0]["type"] == "image" + assert layout_user_content[1]["text"] == "\nLayout Detection:" + assert calls[0]["completion_kwargs"] == { + "skip_special_tokens": False, + "temperature": 0.0, + "top_p": 0.01, + "top_k": 1, + "presence_penalty": 0.0, + "frequency_penalty": 0.0, + "repetition_penalty": 1.0, + "vllm_xargs": {"no_repeat_ngram_size": 100, "debug": False}, + } + ocr_conversation = cast("list[dict[str, object]]", calls[1]["conversation"]) + ocr_user_content = cast("list[dict[str, object]]", ocr_conversation[1]["content"]) + assert ocr_user_content[0]["type"] == "image" + assert ocr_user_content[1] == {"type": "text", "text": MINERU2_5_2509_1_2B_OCR_PROMPT} + assert calls[1]["completion_kwargs"] == { + "skip_special_tokens": False, + "temperature": 0.0, + "top_p": 0.01, + "top_k": 1, + "presence_penalty": 1.0, + "frequency_penalty": 0.05, + "repetition_penalty": 1.0, + "vllm_xargs": {"no_repeat_ngram_size": 100, "debug": False}, + } + + @pytest.mark.asyncio async def test_llm_page_detector_uses_prompt_transport( monkeypatch: pytest.MonkeyPatch, diff --git a/tests/test_tooling_benchmark.py b/tests/test_tooling_benchmark.py index 98bd750..0d6c7d3 100644 --- a/tests/test_tooling_benchmark.py +++ b/tests/test_tooling_benchmark.py @@ -17,6 +17,7 @@ DEEPSEEK_OCR_2_OCR_TEMPLATE, DOTS_MOCR_OCR_TEMPLATE, DOTS_OCR_1_5_OCR_TEMPLATE, + MINERU2_5_2509_1_2B_OCR_TEMPLATE, PADDLEOCR_VL_1_5_OCR_TEMPLATE, ) from tooling.benchmarking import benchmark @@ -411,6 +412,47 @@ def test_build_ocr_backend_uses_paddleocr_vl_preset_for_openai_compatible() -> N } +def test_build_ocr_backend_uses_mineru2_5_preset_for_hf() -> None: + backend = cast( + "HuggingFaceVisionOCRBackend", + benchmark._build_ocr_backend( + benchmark.BenchmarkOptions( + backend="hf", + dataset_split="dev", + model="opendatalab/MinerU2.5-2509-1.2B", + ) + ), + ) + + assert backend.model_name == "MinerU2.5-2509-1.2B" + assert backend.processor_kwargs == {"use_fast": True} + assert backend.trust_remote_code is False + assert backend.model_kwargs == { + "device_map": "auto", + "torch_dtype": "auto", + } + assert backend.generation_kwargs == {} + + +def test_build_ocr_backend_uses_mineru2_5_preset_for_openai_compatible() -> None: + backend = cast( + "LiteLLMVisionOCRBackend", + benchmark._build_ocr_backend( + benchmark.BenchmarkOptions( + backend="openai-compatible", + dataset_split="dev", + model="opendatalab/MinerU2.5-2509-1.2B", + base_url="http://127.0.0.1:8000/v1", + ) + ), + ) + + assert backend.provider_name == "openai-compatible" + assert backend.model_name == "MinerU2.5-2509-1.2B" + assert backend.template == MINERU2_5_2509_1_2B_OCR_TEMPLATE + assert backend.transport.config.completion_kwargs == {} + + def test_build_ocr_backend_uses_churro_preset_template_for_openai_compatible() -> None: backend = cast( "LiteLLMVisionOCRBackend", @@ -504,9 +546,9 @@ async def fake_predict(ds, options, *, total_pages): # noqa: ANN001 assert selected[0]["example_id"] == "1" assert options.max_concurrency == 2 assert total_pages is None - return [ - benchmark._build_evaluation_example(selected[0]) - ], [{"text": "prediction", "metadata": {"raw_html": "

prediction

"}}] + return [benchmark._build_evaluation_example(selected[0])], [ + {"text": "prediction", "metadata": {"raw_html": "

prediction

"}} + ] monkeypatch.setattr(benchmark, "_predict_texts", fake_predict) diff --git a/tests/test_tooling_support.py b/tests/test_tooling_support.py index 32b3ad6..e8c87b8 100644 --- a/tests/test_tooling_support.py +++ b/tests/test_tooling_support.py @@ -53,6 +53,18 @@ def test_normalize_text_for_evaluation_handles_markdown_linebreaks_and_substitut assert normalized == "aword foobar - baz s st" +def test_normalize_text_for_evaluation_converts_markdown_with_embedded_html_to_plain_text() -> None: + text = ( + "# Heading\n\n" + "
YearValue
190042
\n\n" + "- Bullet item" + ) + + normalized = normalization_module.normalize_text_for_evaluation(text) + + assert normalized == "heading year | value 1900 | 42 bullet item" + + def test_normalize_text_for_evaluation_supports_arabic_normalization_and_missing_dependency( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tooling/evaluation/normalization.py b/tooling/evaluation/normalization.py index 2c6f9bd..882efc0 100644 --- a/tooling/evaluation/normalization.py +++ b/tooling/evaluation/normalization.py @@ -7,6 +7,8 @@ from collections.abc import Callable from typing import cast +from churro_ocr.prompts import strip_rich_ocr_markup_to_plain_text + normalize_hamza: Callable[..., str] | None strip_harakat: Callable[..., str] | None @@ -68,6 +70,8 @@ def normalize_characters(text: str, *, keep_long_s: bool = True) -> str: def normalize_text_for_evaluation(text: str, *, normalize_arabic: bool = False) -> str: """Normalize raw OCR text before metric computation.""" + text = strip_rich_ocr_markup_to_plain_text(text) + if normalize_arabic: if ( strip_tashkeel is None @@ -94,14 +98,11 @@ def normalize_text_for_evaluation(text: str, *, normalize_arabic: bool = False) text = text.lower() text = re.sub(r"[*_`~#]", "", text) text = re.sub(r"[–—−‑‒―‐]", "-", text) - text = re.sub(r"!\[[^\]]*\]\([^)]*\)", "", text) - text = re.sub(r"^\s*\[.*\]\s*$", "", text, flags=re.MULTILINE) text = re.sub(r"\[figure\s+\d+\]", "", text) - text = re.sub(r"^>\s+", "", text, flags=re.MULTILINE) text = re.sub(r"-{3,}", "", text) text = re.sub(r"\s+([.,?!;:])", r"\1", text) text = re.sub(r"(\w+)-\s*\n\s*(\w+)", r"\1\2", text) text = text.strip("-") text = normalize_characters(text, keep_long_s=False) text = re.sub(r"\s+", " ", text).strip() - return text \ No newline at end of file + return text From 759a70644b4c4202835884bd3385b2d691934008 Mon Sep 17 00:00:00 2001 From: Sina Date: Mon, 13 Apr 2026 06:36:22 +0000 Subject: [PATCH 10/58] test(retry): add tests for retryable connection errors --- src/churro_ocr/_internal/retry.py | 7 +++++++ tests/test_internal_helpers.py | 18 ++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/src/churro_ocr/_internal/retry.py b/src/churro_ocr/_internal/retry.py index 4595461..d3a7680 100644 --- a/src/churro_ocr/_internal/retry.py +++ b/src/churro_ocr/_internal/retry.py @@ -19,14 +19,19 @@ { "APIConnectionError", "APITimeoutError", + "ClientConnectionError", + "ClientConnectorError", + "ClientOSError", "ConnectError", "ConnectTimeout", + "ConnectionError", "PoolTimeout", "RateLimitError", "ReadTimeout", "RemoteProtocolError", "ServiceRequestError", "ServiceResponseError", + "ServerDisconnectedError", "WriteTimeout", } ) @@ -111,6 +116,8 @@ def is_retryable_api_error(exc: BaseException) -> bool: """Return whether a provider exception should be retried.""" if isinstance(exc, TimeoutError): return True + if isinstance(exc, ConnectionError): + return True status_code = get_error_status_code(exc) if status_code is not None: diff --git a/tests/test_internal_helpers.py b/tests/test_internal_helpers.py index 427bdb4..d74e097 100644 --- a/tests/test_internal_helpers.py +++ b/tests/test_internal_helpers.py @@ -97,6 +97,24 @@ def test_prepare_messages_from_conversation_converts_images_and_preserves_unknow ] +def test_is_retryable_api_error_accepts_builtin_connection_error() -> None: + assert retry_module.is_retryable_api_error(ConnectionError("Connection lost")) + + +def test_is_retryable_api_error_accepts_aiohttp_client_oserror() -> None: + client_oserror_type = type( + "ClientOSError", + (OSError,), + {"__module__": "aiohttp.client_exceptions"}, + ) + + assert retry_module.is_retryable_api_error(client_oserror_type("Broken pipe")) + + +def test_is_retryable_api_error_rejects_generic_oserror() -> None: + assert not retry_module.is_retryable_api_error(OSError("not retryable")) + + def test_render_ocr_prompt_supports_transformers_v5_chat_template_contract() -> None: captured: dict[str, object] = {} From cf8a9b3bcd70fe64255c29d4ef2089346c6bf772 Mon Sep 17 00:00:00 2001 From: Sina Date: Mon, 13 Apr 2026 06:36:39 +0000 Subject: [PATCH 11/58] docs(benchmark): add GPT-5.4 model to benchmarking example commands --- docs/benchmarking.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/benchmarking.md b/docs/benchmarking.md index 934bec4..701c7f2 100644 --- a/docs/benchmarking.md +++ b/docs/benchmarking.md @@ -54,4 +54,5 @@ If you want to benchmark a model using vLLM or llama.cpp, run the server separat | Model | Model ID | Backend | Full command | | --- | --- | --- | --- | | Gemini 2.5 Pro | `vertex_ai/gemini-2.5-pro` | `litellm` | `pixi run python -m tooling.benchmarking.benchmark --backend litellm --dataset-split test --model vertex_ai/gemini-2.5-pro --output-dir workdir/results/test/litellm_vertex_ai_gemini-2.5-pro` | +| GPT-5.4 | `gpt-5.4` | `litellm` | `pixi run python -m tooling.benchmarking.benchmark --backend litellm --dataset-split test --model gpt-5.4 --api-key "$OPENAI_API_KEY" --max-concurrency 16 --output-dir workdir/results/test/litellm_gpt-5.4` | | Qwen 3.5-0.8B | `Qwen/Qwen3.5-0.8B` | `openai-compatible` | `pixi run python -m tooling.benchmarking.benchmark --backend openai-compatible --dataset-split test --model Qwen/Qwen3.5-0.8B --base-url http://127.0.0.1:8000/v1 --output-dir workdir/results/test/openai-compatible_Qwen_Qwen3.5-0.8B` | From f32f78398b7bdaec1c6759aee3076de43301260f Mon Sep 17 00:00:00 2001 From: Sina Date: Mon, 13 Apr 2026 08:08:37 +0000 Subject: [PATCH 12/58] docs(benchmark): update score formatting to one decimal place --- docs/_static/js/benchmark-leaderboard.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/_static/js/benchmark-leaderboard.js b/docs/_static/js/benchmark-leaderboard.js index 240a202..47f959b 100644 --- a/docs/_static/js/benchmark-leaderboard.js +++ b/docs/_static/js/benchmark-leaderboard.js @@ -1,7 +1,7 @@ (function () { const SCORE_FORMATTER = new Intl.NumberFormat(undefined, { - minimumFractionDigits: 2, - maximumFractionDigits: 2, + minimumFractionDigits: 1, + maximumFractionDigits: 1, }); const COLUMN_DEFINITIONS = [ From 40f51e16cce371cecfd4672f172516bacb4b5f88 Mon Sep 17 00:00:00 2001 From: Sina Date: Mon, 13 Apr 2026 08:08:49 +0000 Subject: [PATCH 13/58] docs(benchmark): add GPT-5.4 model to benchmark results --- benchmark_results.json | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/benchmark_results.json b/benchmark_results.json index 3ba3891..8edc4fd 100644 --- a/benchmark_results.json +++ b/benchmark_results.json @@ -53,6 +53,16 @@ "handwritten": 55.0, "total": 67.1 }, + { + "modelName": "GPT-5.4", + "modelId": "gpt-5.4-2026-03-05", + "modelUrl": "https://platform.openai.com/docs/models/gpt-5.4", + "iconPath": "_static/img/openai-symbol.svg", + "iconInvertDark": true, + "printed": 78.4, + "handwritten": 55.2, + "total": 65.9 + }, { "modelName": "Gemini 2.5 Flash", "modelId": "gemini-2.5-flash", From 18854d14e0cb68a2a9c8cb5fd68c5bb223f06825 Mon Sep 17 00:00:00 2001 From: Sina Date: Mon, 13 Apr 2026 08:14:07 +0000 Subject: [PATCH 14/58] docs(benchmark): add icons for various models in benchmark results --- benchmark_results.json | 16 ++++++++-------- docs/_static/img/dots-logo.png | Bin 0 -> 29480 bytes docs/_static/img/internvl-logo.png | Bin 0 -> 50595 bytes docs/_static/img/liquidai-logo.png | Bin 0 -> 14479 bytes docs/_static/img/mimo-logo.png | Bin 0 -> 4570 bytes docs/_static/img/minicpm-logo.png | Bin 0 -> 5871 bytes docs/_static/img/numarkdown-logo.png | Bin 0 -> 39465 bytes docs/_static/img/phi4-logo.png | Bin 0 -> 2020 bytes 8 files changed, 8 insertions(+), 8 deletions(-) create mode 100644 docs/_static/img/dots-logo.png create mode 100644 docs/_static/img/internvl-logo.png create mode 100644 docs/_static/img/liquidai-logo.png create mode 100644 docs/_static/img/mimo-logo.png create mode 100644 docs/_static/img/minicpm-logo.png create mode 100644 docs/_static/img/numarkdown-logo.png create mode 100644 docs/_static/img/phi4-logo.png diff --git a/benchmark_results.json b/benchmark_results.json index 8edc4fd..63f3920 100644 --- a/benchmark_results.json +++ b/benchmark_results.json @@ -48,7 +48,7 @@ "modelName": "dots.mocr", "modelId": "rednote-hilab/dots.mocr", "modelUrl": "https://huggingface.co/rednote-hilab/dots.mocr", - "iconPath": null, + "iconPath": "_static/img/dots-logo.png", "printed": 81.2, "handwritten": 55.0, "total": 67.1 @@ -94,7 +94,7 @@ "modelName": "NuMarkdown", "modelId": "numind/NuMarkdown-8B-Thinking", "modelUrl": "https://huggingface.co/numind/NuMarkdown-8B-Thinking", - "iconPath": null, + "iconPath": "_static/img/numarkdown-logo.png", "printed": 72.7318, "handwritten": 51.2224, "total": 61.1498 @@ -214,7 +214,7 @@ "modelName": "LiquidAI LFM2.5 VL (1.6B)", "modelId": "LiquidAI/LFM2.5-VL-1.6B", "modelUrl": "https://huggingface.co/LiquidAI/LFM2.5-VL-1.6B", - "iconPath": null, + "iconPath": "_static/img/liquidai-logo.png", "printed": 40.2, "handwritten": 25.5, "total": 32.3 @@ -337,7 +337,7 @@ "modelName": "MiMo VL", "modelId": "XiaomiMiMo/MiMo-VL-7B-RL-2508", "modelUrl": "https://huggingface.co/XiaomiMiMo/MiMo-VL-7B-RL-2508", - "iconPath": null, + "iconPath": "_static/img/mimo-logo.png", "printed": 54.8469, "handwritten": 34.6299, "total": 43.9608 @@ -374,7 +374,7 @@ "modelName": "MiniCPM-V 4.5", "modelId": "openbmb/MiniCPM-V-4_5", "modelUrl": "https://huggingface.co/openbmb/MiniCPM-V-4_5", - "iconPath": null, + "iconPath": "_static/img/minicpm-logo.png", "printed": 49.7495, "handwritten": 32.0729, "total": 40.2314 @@ -411,7 +411,7 @@ "modelName": "InternVL 3.5 (30B-A3B)", "modelId": "OpenGVLab/InternVL3_5-30B-A3B", "modelUrl": "https://huggingface.co/OpenGVLab/InternVL3_5-30B-A3B", - "iconPath": null, + "iconPath": "_static/img/internvl-logo.png", "printed": 35.8059, "handwritten": 26.3691, "total": 30.7246 @@ -457,7 +457,7 @@ "modelName": "dots.ocr 1.5", "modelId": "kristaller486/dots.ocr-1.5", "modelUrl": "https://huggingface.co/kristaller486/dots.ocr-1.5", - "iconPath": null, + "iconPath": "_static/img/dots-logo.png", "printed": 22.2, "handwritten": 10.4, "total": 15.9 @@ -466,7 +466,7 @@ "modelName": "Phi 4 Multimodal", "modelId": "microsoft/Phi-4-multimodal-instruct", "modelUrl": "https://huggingface.co/microsoft/Phi-4-multimodal-instruct", - "iconPath": null, + "iconPath": "_static/img/phi4-logo.png", "printed": 9.0269, "handwritten": 5.3393, "total": 7.0413 diff --git a/docs/_static/img/dots-logo.png b/docs/_static/img/dots-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..f25cf821bc93dd2a41b55fe3cd8e2fa423a6a1a2 GIT binary patch literal 29480 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 literal 0 HcmV?d00001 diff --git a/docs/_static/img/internvl-logo.png b/docs/_static/img/internvl-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..e2579766af457c9afc9ce025cbbf27246fa84527 GIT binary patch literal 50595 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 Yr9z7+umeY3c4s3Y| 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 literal 0 HcmV?d00001 diff --git a/docs/_static/img/minicpm-logo.png b/docs/_static/img/minicpm-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..c3438f4308b49ee43c21c37ee07a09b2267a7e67 GIT binary patch literal 5871 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 ZXTsQpU5j!Eq5DT$>u4F?uhFoN{4b%XOOgNp literal 0 HcmV?d00001 diff --git a/docs/_static/img/numarkdown-logo.png b/docs/_static/img/numarkdown-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..c5efa3c5fbafe1492d30d407aedd992121224760 GIT binary patch literal 39465 zcmaHRbx_>y_ciXaxWfXAJH>r*cQ5WP3&jg%f#UA&lu}%Zw75f&;;xGommEc92^|BlA^5kTO9fCKtp*u*BI6oyajX*MI*1b z_}>N(7w%8;b_0M@k_GGf<(=fC2F)I1-_yi5FPbTI)Gj`n*Dac@9xJ{>e`nLGrZD52 z`(yHiFI1`zqhBWQ+;a3Pjbl88VY#v83xhx0aww8?&(hFpR(?1fc=x&c+&LFfnj8Hn z86a6Wbw_XX-IjKvuHfL|dE@RG{@0Mp|7U;74-+2z?}0peJLK&6Wg((C9c~uf_1BAR zF^^5ckm8X&NQ@y*v@s`Ayfl(kF+L$Ll|!?wrd<_MjTxd-4Tf$9uja9uX2tv`tx^<~ z8lsy$;(EhBJubD#I{svXR=tus6{wxxDX~N_NwJKlcMqnI^@?U$c$~)GUi#{2QVoRg zWYXb%m10>8Q+G!R`;9;@mc@edj=4piyHd|$#<94%5kpmPUKf!16@@*0&teFot7pV4 zi$gcs3`m9i!r-N#N%ni1!quUEJ_g}R+ynQxa5oi_$I=KR@)VL)JTj7;ds~`m2)loA z$1MKKaxKu(7d(*jIi<;~7Q~?)N-u2X?Rt7(9eRHsY;T)0&**GxOcFnsD%JZTLqD5}?O_cW1TM(@c z?Vb`zIu32~PFZZ4)%7#SH*sd+^4@_ zKUlBA_HPC1Fx0aUG^<28+JCKRS^8wz)Y-AmMECHe0r|L2u`9(vAF^E8qCYN9Dm{HI zpdnejw3@6)F&xt)-_)g#aT}05Ek9EJm8uSYTCsD?IVD7aR|Cp-j!*IGFN44FKymJP zX2nNbqWLLkqW!qQFSi%g%_QxwU8rAy1(F>`6CqditP2@oEoCTJW(jAnm<->)dr*8R|;^5 z_|t66oMGO?DY((`@j1 zWb&A_as}{Os`C@Q?Kn?0pM4Bu6mFM#l%ir?@%C4n%m`%y$7zfa3L`@-s_%~prt2jj z92JxON}WdpALvnAF_&Xz{S}nBR}Cpgg#BI+Q1~HqgiONhLEB)-)fJ6S;$)RWbK_J zOm5VbI(5*N1VZHN_94^z<2NmvNC*G$a!#GFg=*w-IXR=Q02bT5s$mxAjJi^>s~b=g zkIYYVu&~bVdaWI0$bxa#rHue3jo>G zKF#A*1pQZik9kT2-&4yr4NHsKst@%xU!SWlf(K?8=uaYBFe(^t$pM7PW-&q=8hArP z%rUZDl`D_nY#}Kv1_kCZ?kZZnzx`D4 z5mix|tKYiL#a`^o|51zY+8*#&o2`I-eZJbl5VrDrgGh$#$MGq+wFX18oPi5|}D|ZmvRObWjHZ)1rhA za9md`V|@DpK$fkijNPJhg`X7Iz!lO^H#5 z#%-*f%09AwECFZ4y)cZKe_lx4P|MFg*ps?{4(c3Oj|iJD1nr$|$aFcQ4(ch9RdL3X z2Voin;Ys}v8=eSEyl~C;_)>q{vhSh`hpj$z{V_Rs&Pk++=!+H$Q{-hEk(0Nx7PPhY z6)pF&7WH;lw^rg!V=@b&qTXmkE?Aox_p_P(<`Q@zG`{kWk$F`?DwPb?-w$_0`nd-; zA&9CeiDvWJAWoBzY6vl?O$G00Gg=rFd%Mr0xagTw@+YUrp<{^UUxJc1RV2&M&5M$@ zyXH;ybK23l^WnKV@B@T$w6tH5`i!1%?xk3er_-=vB4wn=fc!|uvY2{L;n96CXuJuN zM=lh7`oULQc7vk`>(oNLy*<`6*iZ7hR~C@r%@E2Yr^zZ^?{dV-K4NT`U<+(L&e#t9 zo|b0H#v)Cd*jFgs$A;3Lg!q21uKv%9=9E)fc<-3=`?a3L+W-D0y3qktynQ)Ji?Z z>SH)N^$4MoO&T38wmLrL7sdXtHyC5B0ylb-h9OyE$uh)$`$J~OM<+moIAH;DJZBs5BSaJ3g7_&4aW_wYDdbgAT)j>{F z5hGx?XrObU;dKF&S=NMA>Dby;GdM9q>(-n8N5(3Ky62UOVTyjB(oMMv&+>8&2+z~ z5KrTpqLkS?r5{Nnm9AO>5L*@jan45ZZC)&;)hTI4tTAFPMnK7CvOQtxbg`#pX!y^B zAKEhM;!pCe1S4oDDlAx-b9$JOyQx8p2q=*lNJ`#3k~j+Ykw zG?d&*YVrXA(qIcgtc_MoZD;d{M)P*k_&&G#jEa|uN$jIM=pj04VTMurU5d@Wi~281 ziEDCQ(xpO14k_i=lPR~N9zM_;C=%v%E#pVytmQ1)Nn#K;Z=Y_tcJ-f3fUa1g6gkxo zFeP<^;zyalaqY*4WLbAqdbv zDoy$a-kBVL)0a7A1P6m_K}8ECka=gCR1W{F_@YFHkoJWNx*{n}3sFUv?uBU90}0tt zUpjERKX*X^_S`1wdqlm9iYQ}{FmYsh;?*=#mLy*FKQtfT&xyb?k%xlq)la&wCvk|@jS_e>7YkkFA(%U>8*)XAzt%)f6z<@9Sf*_!w%r}=;c z5bc(0Xw)X^!iclx#y|W(RB0^JKWJP3KEiKqQs^=$KuF8nXymSzfM@h5jiiuRw3(5D zM8245(AofKwk~&7;*-#*xKP;5K}&IHo`{%-HrdJ4`|s2;SCgK)TCKaZH4k!?c>lV> z9}-e`5vBRNG4Dg1t>PtwMiR_uY({kJKFKy9{|cZo?boVhE_Jwp#CW8KUwPvt_OS?$ znY37Ov2&Tpuoz_M5Hh06aV@5*4ya!KSnB=2Q@PL3ZAVlg*b*gJ@_9#ga#E@2ozVQR zSFZSl0FxVNbaN$e#RU+laY->Af}H*eIJU|b_DMuj3(U!A-qX0&ST^?WOk-e zSaUG12F+cITkq)uzIB{c5k0h8Lexw`@SDoau716KBXSFWk^Czaq@JZUaAX&2!^P8r zA)H2Wb3kNc8Vz^HYke_yrDZs3iu>(B?i_!hl25w*J7pgQe$tlBk|9pz4rABOR@rw= zCFR}rY@}YodJvilgz`>g(I{p`YDAAa8q@^MBg!^R!BBg8O)JejV&;~Wop%7ttfml*o+i14A;^L~91R2Gowai`F;l`Ci zDKOPphz5@YClr7;NvlXLS-`M4%cP(_7S_qS7(4vBU4g1sorA5E5I^e~H=;GHbnr)b z)Wp=WWNQO3YT3Aetu;rtuIqp=9_ti4)5!@I#KpWiS8gFkGsI#1{E-t}2{KIDv7$|L zGekeum%QWzk6@Y9=p)-!@#d&SQNn*MjNkiYxA6{FWYAN03VWYR3LxF3)Qo3&hF;M#BM` z1u^Yu<6gwdQ4ZrXMJ6%MMCul=8zuy=>0s|+=Q1S$8;`R;wHz2nZZq^S{BpfbfgS7U zNL&$H(nYfHd6YzH(njgH{4|75ZMjBxkPim%$3QE=TOr>PR?jLA%J&yBJP0P^L#@=J zz6Oc>LLuf}aPcEoj#Vl0lfiQ_yyHtFtch%)kgbPc-=&exmN}L-NYt z8=b4ivVtbJW>v91KhpO~6o-AhfY{nhx**<3zCaXhIU&+Z20c9NxOs=nlN;~t^_gTY z>c~7lBESq{R?3{(;z@Ad!iez>4N9)8E!^rs-&l+Ol#XWQ8(`#QDm=m`o#br)4y}1u zC(U6@0bPCr!Qk3V|6#~8>9bjg;c^R&rw0}11Qxv|1b1iHN4o;Iu4^jPNC-8=p6p+R z(A^z>uejpIN@}Wt&z_oTsp4sfcn$i=lUt)5$=s#R-GT@HE{>yM)i8tZ`ah4ZWCEG+ zBVnyt{5z1GR!sV$dc!4R8o3r*6;UgZhk42%p|PCl2-}IQTHbwA{9tlmGwxiYH?Hxv z-ec1*dsc1sXL>3Nzh(kIISoYnXD9B%8$vpCJefK@ov}T;dgig>F++GeHL{bR?j)34 z;=SHp_*<;;qWMo)ZcC|espu;k&fXCohvx;uUyNg--|5tCMUsW}+EQwr`lBo%5WX{_?aNn;M@Sfj*2`E zcHFmzeE+ch6Emc*HAXba+VoJ^*Ym`n1JzT&PZjy9d%zIe+1wT%th`Vw#xUZ&4}_3< zle3~0&sY>gZ^Y^;AW*T{el=+wr4u`njA?!8cO(8yXpVE9D8DShnw`uS3UpDsFBrr} znPrfNa_WLi2|8MuaDEAr6ld|@s%ZGQ;)I)p9QF(F4 zv`kx9Fm8yM(!7r~6-t7p(-0{5IwQlxw`?FY2% z6a`JWedZb4+Ziv`CqEito~+dH$0q!gUWp=Z9k2SAyM{wjIZDzg6&V8I{t7TC#w~Ee zdecAMXjM)B_IagWDL$Y)e`HhzR9wqfx@B#ZEqxQ9)iT7kFlq>{nGw4QymUTk1oMKMQZUQ znX8U=aoh2k>6M&y_Pihv&g2Utj~ID*hiN4--_4_qs88w-n(xns% z16Wyr;V`c>wDZJmj=*%_?I0*K9inWCSOz^){9ihi10~R3wLI7mYO9A8B7e$Kt=^2^ z)cVZr28GUd+Xkw2=p_HD`JN!_}{l`VQGrtG<;46QaE= zo-o#d3v><0%L^YM`}SIboGe=(^b(p!7f=MmJZBq8sbd>#ICW_OT{?EsnJ%#Q>}l}0 zb*az^qY6swx-7>6!l6wHUG03yO-eeI>UqC*@xP+0T+vKbtM+BpMlMg=*r)4dN!jb` z#O>+n0>YLNDq^a9(RUn$CbcolQ3bmLZDSp-o1+dsVhhC*{%QXmlnRPp>zm^`DcACN z(V${`*7ep;TKEn?g7Pr;cSZ`|3XCU_BCFY4z<=BarYIV%-w-@l7s z{#xe(MdCwCT@>;=ec#y_4?E!{(|izFbJ!hzJ%g$iw)Mwe^^_XtRw9eHpN1-tW!mw^JQFQ`?`{kLi<8!g(fjClnp7P>%WK^~Vneq) zXUwN~iyy@cWz4eL?D42oVxi+G^e zxL+W&WJDGj`X3ieuOZe-wan4CJ^!|FK7~Bm5B;n4`7Tf&X-Z6G`8=~YHq-e`tkWYp z1oKSbJ#V>rR}&W1zA-o22d%HCbn#pS{P0KP85|AP{LHj98bgw8hy%C}3{F567W-iK zMmRv5Ow1>j{F8nmt5^zmjk+I)1q4628-8vTx6*FX2wvIgg+>AI178iGc_RPxnl=ir z6?y%6-;)Tgit7iT6a-izObc=TSOH+xT5x+aitCccTP!$fp!0Szd{xrJk&i%kQh5)L z**MCfUMLv51;N;MQJSopm07sI1$A|*GhpmMI7XfZFjKG?X=lhoTN8ouO-*n)H!oAs z(qYfgCA09+LXHSN0jY7$pxk%f=L)==4sy1Oms3tuQ#~ST6isc`^s5c8+^Hu&m$#}A z3%|tAtWlOBtqH`m4uTyWRiwO}DPL~>=-=ii^+q7i8{o4T)^+xR0>B&&|q}6Onm7 zX@5f{c(qNcIkH)OaCF!zhjBEj(*3)B(LszOo|ulfefPZkBFTl8$Wf{y4OR)3rX<#H4GU(q5M(O_31{% z?ZIxYC((X%RJ?KWM?&F~BAWEOXyE61kUzU(Eqtb_8g#3-qh!C^F^#Hd(L3zN?@>Bv z(#ODga(V|WTU~rQG<(N1y(uMWQ^y?x9Ws!6NMfmxJ-W8X&K*1^ufG1tqcMXd0@_`9 z;UN}2daD%QPDJC4TL#a3Sm3AVALT42pJh?5cP@Hy!a4|7iCa}n1jIO`V{t6H^Nnfv*C z@LaAI&tC7z_~r9EGCqF(28l~3KsVXd#Y=%^tGYqAKv)%Nv_K|_(&#u872>;G5mwz{ zuwh9poIu(VPj68NdcW)Ul~NX8L5H4?Z^e!f25u?%s$#cv-wtp8kw|0&m}u(YAP^jT(C2-6@@RvP&vMWNiBH-Yn4cV~gFm0QVb>@w}$n)dJG0N2e2J zANd-$%^a;=fx53C2Vg?A$8}?ZT*)Ad4j0lZRIELn$;7Yq$f!ZN38U{-e=PR%vSAky zv4G16;g?&GGHs+ly?gV{We-CT*cVyhI^cch2>+-^l4;T*2zs*Cew~I@*ZuO3Nh3+B z2UEiQorlO>qYAGz5dAJA?ZY{Jq`OASHANNZL3TrJQivq3AsNcrL1KBfHl2)!HBJx% zPkW}OZCt{U_B|>fT-CS;IohnsBf zKBJmNIHWr?kap~5tnUy&C!P8$G(^(=Y()X*T;I)QY4B>~-N{OdlwMOw*vUMN0PerLBO{J{!c+@8kk$ z9a*fV_#T_v3<^V>u%Hp$LTMEqc7g{AFa8)p=5q9kNsCXsO=NR+n1eR#r6#vv7!Z+U zoQEUITRDjxFFcQfB+`f0)Q{U!w?MK}6@S@WsEQOrS zA+_Jg(c`YPrsJSEgW}cq2k=%qwTx=sQ`!hi#55tX6!=B*^LeU^b`Ju~S+F@qq{`fK z85!?Th1znPH#+{2jcz0b7pNGloV4EZ?fG>{Hz)j<4B@l$vu8bNTI%87v~h_jN+fpF zKvR4CvhCkG&=Hw1#Z=OwD-Oq|D?}*A|fp%8HhKBf!RMRvb z83AE=79dEHg!LcIGZtY^SpZ#RNnmfq|F{5Vd=X_$AvfQb#aH%HY3b^{Z(etFvKwnkL18^V%(6H*`K4CB^r1oDx-023+% zgr#g!!;av2#1BVq@DYU9lyLpcdf*DE4Pi*To9~mn(5;yiDwSu9upnL{OnHMx<|#_C zHg;Su8*8%#?n6iCv+-hG6Zj1tw>55CyQ()|ob;{q0KZl+Vj5KC+E4nA|7wEo^Nj>D z!qFO~7kR?RtGd|nWbwp5fqFZUO)b!}OLhqilj}E52pOI}AEiCaE-~#?jl7 zs*&$p<1+o0GJoJ^2dW2&eTl)MGqt*b6Xd>N(Ax*DkI(`f zL_ah;b(D#u3*-AI4R`^a&ff@$&QFioG~11?k+oiFzn;XS$4jAf0=&EUrrP^>6)IIt zdp+XANOiW4Ka(Ex>SIMiqE%vB4+-(WC#DcU#rL7n)KVj%)TG{g@sG9Iv1PleHu(n; zC*F#GK0uh$aVo!tMipLz@S;X!a2it3Luq8wvXO-KFw{AxrS%IWH-Q)jCS#nsM))_o z2W{kD7ta{H{MZtkbZ2y#hWFUZ>4WPI_D9%^K+*|>FIbAicq2cI=aO1br% zY#RvuAx3bCpopCEX1sheB0qfGq~D)YMq;y=heCO_hs#KD?5OO-?GWWZX<1iYoVRZE zrVZVh4^A}{VzFN3$x;_O5#%`eFpUGv@@(?f919O=I)L#h%vHOn82lzr?&-E8URc%} zKQ+PXTumd%jdog0PRt2LJ~4*a~C3#Z_P*|s`@F@S~C=ZN+}mNqN8XKVxPcQU_7 zwlasbI`@%upt$SN6EQgD2Hgg?29u*u(O&^QUY$o34>|CtkInXR8qc%rj#x}lk<63r zMdM>w1`5M(F=2YQJq8RthUB~tGC~JiE%dFxu#tk+i5;x$B40H7B>Q^yJ$3crna?G+ ztCv$jZN@WlJSXZn~dGsnGri6`lJuIfQN11T9-}1YC1OkBtf1A(Nx%gnzy+BzCX)|6` zN0)}7`3@Bk3GSq$HN@qkf+^RU)MXMlw>5$w!8^~@2Y$HVLobWp37#KC&sZd@0JfY~7}avNdx+a9cwf{fsR*CilF~S{TkAzw$i^ z>hTKcTrIAam&gPb>H|d}vRa?^h5e za@0Oaj&?g`4v@@WL7o3ZOw@cpn5r}=7&2mvFqY+k?MFrfg}(ul=tbLgl!TPVmKUG= z3ZX?T;7R&XEV8(F!jj*#D7!mxURMi#H0APhpe+j?HQ?=G_ zupO*RhWNQTol#41Iy49QYb%97IDXrKAyn@Ed+a8H+E2R+q6gZTJ)v7ht7iUl+Yi=w z)ghI(m+cV7b{9kwc&un+dHbQUd1HX0%|V0-DFN#4c;O{?LLB|)`m(6yo%*JrKX9Us zN(~!*G^FIt3@)r6@kACR5lvHk&}y%PC(X#a%99E3j0TGjTpJISB+umt34f~kkg^E1 zQ7wxE^J*|^sGFi4@F{Jy0^fNAgR6gjcPRy_COolROzpnD`WiaD_;8b^Yuf!oLbsZx zW9E=nEpW$Oc3-5!#(K$ETti&*PJ154Ky2FZCv{O%sHCLL%pCi@bE^$je53Br(sQ$9 zag^R1U>h%tJCV1xo=V{nF0$cT4jTZ99}R<_u)Ot=kfG__Xq9AE972y=TYKf+6EpwPC7 z!8w(q+u&0%->@b*!M;=^PJz&tA(1DnbPS7}T+*8A3$e#7TL3;VVk_RwcXD6~-_z(s zUrN2NuA8k-6#g`wQkX$bD7odQ=!arw)4QCC7SkyUaH9r)bKMCkO?f1!Fy3r2uUgsjBv!0Jpl!C`GRMTyZWbTl(%Rldd=e;z42;4^mSeE=KaQQ*60^>Q?#guW)5?zfYIGO$f zho%Q_Xrw!Zb(Oo!ucD@EDu)bURkPL9vI}=Ir5Xh4XxXr?gPd68suvR#q8QwKXCG zNZ>&M703-ao6yonJej}~q-)%4bs!{glS4G67w!Hh<>)&Q&N%sqdr~a z+-7e2?yuqwMd6B(Kca6X9naQwb0><{y2>XCx7O|xntPk+@$|X`L zCa8iF#Lm*HbkT*!6-lqEbZE)DDHsrJVi+q@MhqIK7J?M)P%x4A{Q$gxNI0pRC1u`E zv@?6FHmul}1$}ai9gQ;rSS1CXt<%glnL=;H-G0eT&IH{i zTbD?NPEBMs0nd9}QR}~@JeN9C`S_DR+7x%yhMJYloI02DA?NfQ`C4fqq{$mdQi?{C zc7X?~{mz)9N=0irYAthw>nRnJ$fX#5-*vh#EW?_qtYs5OphGq0H&um^BH*<{Eq7TW zN^|T<%Ga+9uS)F1{dqF77&$-Zhd2ve$6lK`9d=@ZQ<7hdn1fbTMNuDuV$^BN zD)&%g6u)K5vWs##S(6P`>uArRV9RL;2I_!6l>5i9#w@$^_whj+ej@xzpZ_9Y&1|r{ zSjp1^c)gv%zH)m^6fegtuJe=N!1dg3tMUocNF2{YgkA8o_Yn%4sH&?La1L+EBn7yk z=($GOo6ZonUnG>e$%r!3I9e6cSpE))lJJM}@d;>yNAyghL9#fB-KS|l*BUTn!u5Vd zFu1>7@NWZ_>D$snT?t%$jH0VcZH!Tr-rHi_P?)-OtH3=tBl#Q?8dxdUY!fp$Ia(F_*3YI$4H*?9)C2w^iE5;qu59Wd8I%p8qatJXvS_?I0NNl| z+KYRrw*Y7NHwxKY0mQn_NDjrj6s1TfuSIxX{yos=v*ZsVZxmCM8gFMilut~Vd4!JM z{{sZdp8kA#J}*%}u47)aA9>!$!DIIOMi+VRJDq-mQ`RUjc5|vUIE zj%X0_Jw>)lpH#jLR(3ri5jUElZ(=onT^i9bgS0$K&95}G$bYd5Om(ql-4o^fyXt!s zhK7HkPzgi=-^p*;h%D_)ip!>0nLd)@(TaZ9e|{tC<%W`&xzpy$ae+z>ijcY?Ttoa@ zJ@bELGEE)mK64WNLHZ>29Bk`2K9l|tX$?*)8R`Wlt=Q9eG^YB{Tn6jo6;H72k89!W# zOdTNRUoq*5zGIzT;pDn69^XkxSfl8!$ZZ2+HI;1mzNntj|ERXVSUed%{}^^jRB0Pd zodCTy6T~Dn;BJ#<@rbZu-oxZV1j`mBVIB$;Ldx-x=p?$*dt=iV&G3j`C53)m}*KJTj}l%Ne8QHn3IZz}ruOy#!q znzY#LFOR${U}is(N|;fHF)Q14Le(nU`Si~XN#O3>DU8;~XEOht{Bed~T9J;-lBxxFq8dU&pP0J|v)N&YaG5cHMe!KF&#_;pG& zf$fs{x(Llg>W1U>BDq$h5Y(Cno%wxHPnJ(Fxf48hMgd>R0t~eLD9B~v4)k5ajldp! ztx2{`w0_MZ11YwGSq=Z=0-C!~#=aNRP?SF29;M1C;;rOM@-x8r!0I6`j9}ejaYd>E z6Z2XZno&oc$I;qImAFf{>F^ns;v__*t~VQ|Nma2&xXIYCDWH7~P5UeL00y37npKc< zPpf=D;l;nwMKjN~8%|Opv)7BPLzL$C!KSqao*#WQe*z_IeZn71EM_n1@$pkW(16~+|UGD-rHFI=?J&44$W2q*n6Uu7lai?uBzW2iWv861Pyl= z6P;+xh1_gzb^8Lq8^YQ4l^s6)pH7virgly6h>!H28`9-7 zz9{6-;BeBp;1Pa}c^mu?)B|-{GWVqDgTmzZVlfd* z9)#HyUW)#xn*>Y*qN-@G)AEO~P0mW^w&xnr->gS6kkUqe9z~hgQTSO2Bvt{zZgYH2Yi{Du^Y2S95FWBQ>nD;5l>nu zU<8M;ZwW2s`?)xs+wnsvuh=JBp-~qPAOPKuP&iYDg*ciy^KIg$f3gC612gGD4Qo-N zr4bS0q1xs~=(joKLX@x6&OiP(aRbeDc`Vxq;;-B%6E28L#Z%qxnAM0kcFKlO-TIMww2}X$nxbVgvpB$4RQc+p?(3oV&pEfu?YU{(Y9e&NUmK zVF&jjTV5hsf69A|K&05lM?c^FMAkEK|Bk3n;%M+~U?b%x7y0;hs_{$mWTZ^vX2Ay_ z^v%^u0L}}r#T5D@!u5)^0};^viS4FrnonU;3+GY7;@4IY$`Ff{?0DQ=@$HpP2DuUc zVs5AW0{PdZ__O^KeEV%1kML&?yMV5?4+mfTli_+YC8ivJsm#X|l9x;+%|v!%qLe^x zg>X!`>l(a0wf?Y1WCz(GonXV}sI?N>ZE0!vREl(VPE}?%8S6D?83HIpaJ|Y;Qh9IR zkCR3_27wU`c?vgJPB8ENyz*oOl>ClEd&lAIJQh{AD!#yiCVR_wX9y-Mr)3B!y)d!$Kv{VHl z)Y!3%S@&vO22j_TyyMBIDG}L)Wl$1=;2#;mp|<;)H#fk=e;PPyF^L}3c}@)@VNz62 z6AAab{5aw3zK+EZQTl7BLd2uPb^7Gu)0f}R(If*-)7rl(lraKasEhPkn^5n);7ga- zx``~okT58+J*G4nky&B?#K&Nhc2ex9eF@2t?%=cVAlkO}Vy@+d7DI7{DO%slCjZ=` zbPuO&-t1nLw_aJ1@c|uA_Lu4S92?UkbB#KJ(+6SZbjGKasH^{AeGvPHA(hYzV2F3I z?1L+jdTj+2hqjK8LsYk|kb9^2KNd=40{yp;BpY5k3dJ+wsYQCd+x~&xZRSsvwHhW}su#GJxwLZ5YHV1-Nld-c z)eRcZnwugSz(h}EC8OtemdOV7>XMibbD1x{qYVoD%|MJ7e)A3#Sed$O^WcI#`~#8P zB+9mGY#YC>V8_L!C};+mb`hR7Ew_b`_wu*R9P}jf!_Ns60~t7n{C1eP<_B)kowjQzl1$nCP8lnxSDcLNa13g!1*5-rB-qDBGKhX_3$lS(t5-c)&_P zs$B_pRrnQIco>B^);(T-t)M@$q zrZVI%a9|K9MFAhkkhbx_s^36sZMqI_TJyHFx_`^5|8V0z$*_nlqMbMoUs$2?LFSN(fu;p^qSu)oc?#KETt!&1?nNib z8%Z=t6QhaIB=m82MLV}4sCu}% z5srg_YX2Tc@z$h!#Iep)V1o8{9{UpFbS6mZ;Nwj0P+}!=!DAt3!Uv3v(e+C&n_Y1> zL2_pRW9YEtkZu(q&0s;sHlE6f?U#yM#cjduA--M*^1Lpa)LC+TeXaxGV>eD>diNQZ z`CQ5{r9xj0c0Dlzv=E+?#qS1YI2&)~NU9~>*wRHsSk*9>>gozYN40T_k)*hBEUq{E zA>kpYdcSjn&2!t3^%!fX$igJ$D=st|TaMZ*IfbWerq~hk#50m72u8|(bIK;^%r1w8 z5o)=-S>DG_Wb!V01pNQ?e38BMwBW|uJqrY|n|Bdrus=|+`$L3n_jbRrG%u#fW_fzAoA|~45c;UEH&DpuQqfhzw;)3S?8-jFx z9BzZ9N=hb-Lc6(^XY!lBvE7;k-Tq(r{n_y7&8a(4^akq24O*vY$zX4ghzZ;i2)8R@ zXM~v`8<3rZm_yM@#by7KJ}Jo986YFtk4D{H6ur$rHhuQD?EX~<4!#Z$d>}_(CjrVrcOKY`mUB|kGf zd>5Ki7^_~?Jta`2q#iSm0+!ipeU?`=iU3(QhMLgKwA@k&B1Z;qLGqBkIxt;tf6+&@ zd}#dQLDPtkA^Fg%RN$AxOL34`1>w+r@9jYkXC1q*0eI{;Fp`1h7K$?WmlOE91!Ljf zUpFZH%;IJ5O6Gm`t8+1<))A`icqiFsUjI+H_k5dd!@aJCV8Ayuf7iJ!RH~C3p@4ri zD>P-@TiTYMctCqridLL+oBqvls_p(=8*Ec?f{@Unr6X-lbrwI61t?vi z5+s*Z@!iB5n>8>{NSi_~kXiMwRS#pR`;XrOJO}xz@B<4l`WWA36zD zX20&CZ*cUZ%K0V{g!v^+$(#?)1*GUih7Au$7N6{L6x~Gc6WuBj+iXs+Ma$n`2lWp< z*7<$CsSWYEMCkUo4*RqL#&T{?r^YMSo}1GoXV=G=WPK!etr?-hmW%9yO|+#9 zA9t|N&2iaokuce?Fi2eTXL`*mSB(zT`Q1z%kV!qosSJaxw{)WpBrOUZEZnhf+*4H2!wydK`n#nNHCfxS{+%P9FAjx1PT2;)elf~`{DO~o5$;UdIvrMj_?W8MWI&`;Y_q;K3 z8r3An&-a*z*>~v6HKJzkz2+s|f}YHauNW-|y%w~U!o_}rc3RRBgXR`8W%x5n%m0!) z+?rt%g+8#yhEUJRx^cQYbf+pNNH=hVHe%yS| z*KD|oLOEa%DMWlR2t_5760wx>k&ilDR@LClK?Et`022!WSdj13#cw-Y5uoK&;e3LW zM{t=Hor^57g>O_P7ysM($Tr}ndNSI~0VE6#%zQ~Te@{V|7)Gk_=stC1?CbpWCfJG) zquJL6Eq>puAQu_u66C1-UgofH4ot`w&LQVQMsaDiSGuV1VF-fDsyv2Fa2&>bvAz}y zk_U!ZAo+L-Qe|zRYr6y9Yr)@g4a*!^2pB>9T5)_;^l1_PX*7b?Fla;Lvfzuz3zaB-{diU(+XkGUCOC z$zZ0C35*s~6_jaJa0Pzr_O8DQj!PG@u^8LO#0%(&Ln6qqVE#o3f=jIYKKX43XOV&3 zMc02GUAF)Va0Nri7XXjL1%Y4^9$!NF$On<5*5-gi6U!OC=BvRJUT|f3bkUxM_&g#= z{z3(*OOt-&nijEdJ1D}teke`sJ%$HAxxmeNFOnmSjAV&Sir}bL9oY+x);C%9mLm`h zxMv0V5aMh>D$4X=(y4Ko_1~=Vbw36yM%jMF&3Hi1$&yTRu^D8q^cI>yFuPBb|K`)^ znt6bjpJfm^L;~T*L69>KtC33>G}@iu^E|+6cKjJCgV#P(pP=V9a^w(hRe0`ssSC_U zR{y+$Ep!uVjNY=ZK6U5U?HxY{UScdE?>9eZ&mdF=st`%YN9Qr1AJ|W)z~eHD@7r}w zLFDcGNSXDPF5LIe=HxIKaf5!(-~dD-eh3CzFZJ8^u@7=3EdsefklBoHFTrM|y zY!L7Sz~}OTtJw}@0}gb@UARYKF@A(!PgGD0m5OpO2()0(l!IPvhEyOSwBa^p2v+m5=BRMHNW!Crz$h!aM$i01_pe-W(bAaF8&Sp zgojycppeVU5lQs;mI(5!>>fDeeVR2Qo=*g+B!Ucz=Vd=cL0m+T_Iv+@l`Z=dxLO^Q zU~`a4(%kB#pj&?m=(eB13uZ0z2Urp!h(%@sooW&yM<$enL?S`?&;?nxbj>7Kj!BkL zo_c&O<=FUBj7Zbf;7H8%fGn}=QF-*bTzv|E z9lv_Hxam321-AcElNzIY>yj*T4ncA?+W9Og{&-lDuG*@LQEu^+W-<7NGLT4LMAq&VM5! zT!}*iB0L{e zMHgmG={x3>rEUjRa%6p3_W>jd_MWR*&#E#9Nr_b^?K}#i^c(Cz$Pa|j6ti)0Av2jN zT9b-i5LAmlm5qjS@RQlSIR;}dZ!R2>-TW_uyg7%-NkbwD2F+Y*otr`4-0T76ZA6eq zi6BQLx3LRlzk&bH-g$txS)332l|F?6rIfNqOIabyOR|P-d1e7&Ln&nx=pV{#8HF-J z67QYiZOfMT-b1!5@4dI3ID6QR_g--ljr-r7r8GDRPHO3X3eu)mR9z7dsBS8aZK-nMe*{a_y_HTJw7fF z^5Mmi?L;cfWlN!3nyV9Jz{P@&g1W8TGpmL<8$AcU%# z3(n)6@y*gl0XasRz#L5Vq99sAkM3=t+E(OPOc9xw)?zk0k zr-$MkCaB0+hHkP$Z^kFjLR``eBTH8RAvs%4SuR(rTzp~DY3I$H2^{x#yYRM+`BK+9{Hx6orPkM7Vtk0D-sepR1(E^<@h0jkX8l@qQR|;hDhR{z0oqF z9Ls`S({gb^P}rM{R*=8-dy0r4swD`bB7*dIT4s;A{2qoK z?_r~j&jEVuMFYsj!>9H3QK{gOMunZb3hK7itl|-eLq!9~p6OjbL z^(N`(}gc6mu2Evmc1^0|~tUYor1n2RK!ir2#pqzVE1kp}jq!Sp4AmIgq z$jBlQtoh3qj9YF;bztrQ^2+E^IVz4LjB01$7;?_(e={TI6kwR_lq?`*WX4mL%hf8E z5G3|}rq5v$;7a)vt)Py^YPylNoy|Opg;)+26tZpUkiD65+}(!kmf}_Irw@6q4{%7G z3kvXq;GhV64TB>N4?(ZzI?kYz0LI+S+_bHSl8B*|LMI8`&2h}d#P8%b13329JIug` zAA^^#Cq=T$hqB~yF+sj`0Jc{y54;t|4+Z7&rm-Xlhgcfiid+>zw32A(NP8}KI#_PB z&LOi2b`>6Yl?dXK)uYC8XwDPl10qOT)XcIV*Q{JzkW)S{u?L+W2OQ3r47iaH%UH7n zK~&SbHW5MIsSPlMA;t4dpuaCj0{y8R5n*v~!sSsYeuu*zaOA^?tI5Myf_{?2;H$cu zoTW(e87v5fwklH|=4ilmv zU-sqIfZ?QNWB?&0Ba5;;-Ti8nOSB+%z(%$Jhj$pSIYDOg+FMYLxrix?O)dG)1`lG* zfRlwryP1ROKKCt<{`MnGm`@ZCq`qO42!9`tdhdoaxnicn`wqY&{NJ#r&AHq|Cx|xr zSI@ebqiH9z)4V(){UU4lRqk_2SA5v-su_uDql~pYZ za?Q%c1<`E7g1D@JE+^9myPeI>Xa&*y072AP5Y)d-I5J`{zURQsrvWwXW-SyX+|L_A zefGdU-@5=Ef18EnVB^-Sn#h5}E!D!E9mi}1N+QXyi|MB)zBK?OeVI{xVF;;Xgz@1ISjq7#Av8(+yQp;Q9@1(^as_H4LqIm~Y72rfRz(+EAgSEUAxvQo z{|v8K;Zb;^%DrF`Ipz$X@hl%i4tXd1c4i>(Oh+79iX13u8Iad8B8N+Jh( z?Kj@nwcUDBo1AMVx7r}WVuJKL7*mcri&ZCZMaGr75d-MG*8siyDwaV4VZOoOCkdhi z287U!(cArQgV|gmLyn5Wa{Nu(ikv5in9^lyLRTn`Ic`4M^R@WqsrQXRt_Q4LzHUHR za#<*dJj;klOxl#!(sB?vbX-kAf^+zq@O)Dgl4CmHm&Ut)TUu1C(Psbv z5CBO;K~y_9<{W&E2;!DMp%WxtD+seJ$h9gL7i4m#J!vi4zC+d>$0Px6pp5s;UGK6OG91;pGNF_eE z-|i(^fbS3_TE&C`fdpjr{9rK_wz$#M!oba@`?U1vm zU;7EJ>9-dl!eW99Iv7%pJByU$T=qUk2FY2B+fsgf~g$RC<(w_z;elang~)7moKK>v6q(PLO}wvxJa5y#yEr;a=(}$p_zPw zhV1Q&83{_ac!&t%qBthToOLPcMz-?92U8l*b$&7g*QpSTHKzteZVKdNCocUB_=T56 z1w`p0BWbY@c3K5cI`oZ1m>q(6`+r za>{)p5kz^uAljBhLj=K0obce@f6#F)?AXq*<6_q>{S68Ui>0JwtcHP+cUWOLTnNcB zxF<-qYCKe-N4sMe$sx{16CxK7K_)RL$vqhm{Jx)$Ofg5%X_h3^QibJ_nZD+}f!U^D zo6-)u3%Y`Q%g4xZYvbdQ#7&qozm zNRD4K-irC0++#tMiwP2wZbd>f4G|w?HM3vth;892t5@O zuxDbJ{b^exm19|urI*D733=^4Mk~lYeGa0rD-}dH@~sslW5jk7oN|Abt-$|v>FJji zB)_D93X=U6kJ)SY0`$0BL=1ax`i>w3()COP>2VY(hdeBaAlr!`m3EJE_IZnz{sx7H zMM9873cCvLhTTQ?Fp?ZY2+B6F4XZRo-nn|;Te%kqq9zp_Uk|&-JiG!Y-a_g8lHBiY zL40#~$Um2l{N+Y;K~;32If}?LO${k90skBxIOp}hFKJSuZH0Y**-<jjVLgK*ix&TaM*N^ zjF#i)U9u%2g80dei5!$*nXx*mKui+$r-&p^*`=}Fi-z0mN{77c%Z9CXI z*^rUCydGq!WuYK#?fWQGho6T-6{gqElyO(Lh20E8uBH!RLC*dFK{Wdv_!_zcyoa^v zVYind`7f)OzyMDOm3n_i5JFyY5tZb`Apq4WhSmGEHJtp)>dpaovq20IM18&>Q`>QJ z{;di5o2VcI&Nj(cRS^H!XxJ4R0+SP4SeY3&f>g2oW=W3KiQqgVvJ>LvhFB`C9CvEUH7h&=DoqRC9>$>GkUFjPmB(51S~5}DCWUIm9>XKv>P*W43w&w`Vn z!8wR5CkImGGDyu`UUIwCvcI*N(%Cl8@Sr}NnlWLW?i9dS<2v3*hv@6d;iOS)ws_v*XHu$8JRLd<-pjJ;?Hxgo3xfEyzMa@N1%=Tmu!^drK-e-nL7zAAF0ClEZi2!Giog3uks`RVr4ZECVv~ zm)Cl~#=Ah#00?4ZQ|hj9p9OZiFN5hUQ(n`9u!Ql?XCuYoVHQ+f2qn zVlaUI`4tJ2nz+Nt1W`!qAWlOpTL>jFR`&4M39^bzl}r?)x`uM`OSHP_+C{( zLZiYc-iqg#J@RKDNX~yfyoiK2dMdKGX<;|+LZmqYRb;*y3eGl556Kb3NB+P*f;O`J zOO3&=gm(uPWQa&|>0CtnyR?Gnro&5fL?~QgHXW$o>3e7C!5&%T&E7f9HwFa+LU51- z$iXX8zn{S&k3aFUueO!a*jxv-^;Mue!(*K|b{CAct-EDnuffD@rTM3=;dcS9gwKrb zw4`e~Nb*g3j|ht;>D~5*swru6rfb8kMFk;9viK?@Nl>0~KHLMW?UDJ+wun3`2a$xxK_po&ODpYF zO;nKj#u{jCZvuo&nBzz9gyEJoU#L!)AuPztL=gN*{gze`)fEV$8Sykl1FnMEZco#P z_U+JvX_trCgWK)~A76JW$wEN_1ATzS0v0-2!orepuppIMK~(dH`FvN96F3hdg7iBY zsz==gXxLF?-EV6Q%9v-Eo}I#_zd`Y#kq{9O3_HI4H9IX?4B^?lj5xA$yUau-$rLUo zh$^g1j6#Y{@@2B8Ve8i0+3C>G)p2#ClMq#OQgmuCWso#fdcdSe@;o+49Urx z7%E3pJUH3j!j`33;XK6bejH*=XnV6R5@uam5OQ+W?gB3IDl(qJau@_Au7l?iILzOR z?t$Qt2=MijT!nYwmtE?rnxVCE9~DHY5inB+9tA9k9g$-w*?A*OD};=S4g` z9yU@u0loXVj|?2*kIlB5p~+_9%w(zXf%eF|V8GQhX?(Xet@QIDmWc9w;nn?8PBF0i1p5uMB$kV+fA$1^Uonc%t|I<7QU*kL=gNpq2O%%x5y1NVI?9ISi~Rn&NW!&oniq&vW?8L zAWJK#=XDmUv4-9fy_>Kk?Q?C$Xr|fd>~7UNHCL!~qe zi;#KYG>LMB|XDI>x zlwW!&sw<^bSI*O1Uk$JJDC3KAsahb|@v zmP3O<+A$52ce1X7_}M4ep6;$d@bK^ecXxMi_Hd!N+e6@!v}0}DO%A_V&cQ33*dDN8PPrPk*QlCYQ{e(6RU?GoH+%w0r~0`ymw zhnou!h#cn&g5`kB?r+#yLCm#+Xs{fbA0kKy*}62{P=muP8g2!p8GhuOxE9J47MwWg zgE#@)Me*GeO!=jkg@ROZPSc+uLVH-5tZlc6O5Ggc74lKzjEXyNW zLGrbNs1_51gh`|BuD@$=QB z)%i~k)x`xl?rN?RM1y}GO*&f6_J6|{b>h~e&u23mZGIZimgldHAm0{5s^f<>9mfZETI}#bh639VXq{E8~pwBU~s^W<+>BM7M{+q+hr%k!+oCc z2tVMg09Hcct3dG2UsWY7Ga$05<_8ZK4nAr_xl4ywI_NYPzZ{XqM<#g1H_Z@mUbMn4 z_bzZMxDR}H?*hL)S0PFJnU|9C2B@j;fXa$|sH!Z0xvH0-F?j8Yv3dbaG>W!rTMiY@ zHxL$rgjH%R3&N5dL1TYn4t%M90~X^L>5l!a0sLt!7^b!#`nkoQAM^+|}fR6(dD!37qCvpDQ~nn)7d#$B-^=MD(=_XCOFRrmz? znU~szy--ozLggTmOqD*znJ#-CX4}kPm}wQ$u!`gzS~*AyqD#=Tu_S;i@LE!tyK(HV zP0}wZ2(HK)ZH1o1=;4;(g_CwRWxe+6)gxZk2unh~uOiRsUBKlCf>HME+C(Jr>~gUI zP4pWKk>kH22fD#@V z38CTPFf#TTD>>B&{PK!DgKxw$SNwkbuXPU>S5@H9stU&I_%K?-k2zY#9kDlucImThBm7@Q4*eknc==QOeM2Z- z{_`xkED*$fF+nh@Foij}HXNAxlI6C`ozy6nH*P(4xjSAUNLWM!6(mz8gp@QcgXI9< zJl<}yAvP97xl}=X^5-SNg5bYNJQtX3@IpY6KDY;LTmd&;d0qcs6QsPZhDw4)US_77 zOaKe==+F^<&uj+|O*HGPbu9;xW4wirrdx$-)j=yF&Ywy5FK#(@P43xDKIN6Vkm!3_ zkcFuFosCojE?mt~EX!eMb8^1O6${eu#8-~mn&7;{T+#j&?|Kqip#z?*8t;v8%>{{1 zd>Imw{=x((MBtw*{70-pfWmQ-PL6mEkY7{~H4y~2Bu8*da$~Oi7MPiSniU_D05OrV z5E&6oAzKL%65~=JEjbhN^Te=YJFwHro&;&Sseym?y3=G)7MYOv9~lG}2(tKst^dpo zBP09~1?FTO{a;C^1BNp`_C8CNr3Yz|iI5Z;3*l07+(Q^dgvUTkbOMx>RzYb=B@~nw zKwfDcloXaiaX|?b73EWY>ZPW(fl^;rPB~ED0_~M`P~T7n%`H{Xy7x~|T)v*uS7!qK z^`?JNxA6C?nvKzPi;-%o*^smtRFIK6B88P^v`vi0+Re|Rvh}M`zL>NiWh6n@O)>7`W}@ew9q(-EUm>7sIHPAI!V-65_11Jkt8F14MYAuB1e31 z6cCZ;|IS#5j*6#p6c?3KITR%dD#y=RkdCT)Xsf7&w)R^5crg@|SaFCXqpeo=OmwVo zBXXQ+n3rRsQ6Fi0u5^N^r#h|CNUPaFbnsPHv-^#=VL=ATv1z2j_mEHi%a`OF7i)Pg zB?ys&tVAS&^f>U86W-Qn(8II>%kjG-4tzNEtucFcD-QxB-ryhPb?pV&JPYtD`VBig z-v|OSSLp@h8lEMyd9}MEen$>6<%2+45p5pwRCwxR5Sji26BZu;kx3GWmBv#-qk}2r z^ipy-V`h9D$PyDEJzb9}C@=vZx!6WhL}uxlXwGj%mM93)PN`RECGpvf1%dm4)i(4M zBh6V4*CT)%5DOwAB@l@PE-5O8;=&@xD=MQD7b+-~iXzHSy_7VxQR=Jbsi>`is`@&Z zYPErpdNBu!0bP~G4~(>%?wvkpjmBDq%Fz}9nralGiFyGVBa&c7nkcHd{pKXpN*Ip3oaRAc+V1+6S0COW2dSTy6}*!|$^qDxUz~T=V?MeDm+LB3D5Wog_*kNkA@Ffw?0j z+Xh@C^j81^)^%8t=ol%Lgvjx~BuH6x6;wCW!^v{v6+{voR;?Rrvp#m__-2G9Q4u+Y zao#}$AuJF?dFG%c5#$q;_A(rG=hiY}tvuPU;)E}2bx zKDrh}D@>Qa-00TOViOo1e~(2rpNo%*p@c_cNpRQ|mzqpTNs6Onr9TU)Nn4os()AFo zFxx@|2}tLvi3}mxLUew(wFE(QD5Hx^P-rgim^4jn9-LwdNtt{`gD;@^#+SO2! zp9_V#*-%rM3sw0!P+Cw#$t%vM{M1WTZ5ss@y-Asw=CL$tbI$l)0Sq)5*r0aNFwQ&B zXuC)?(k4X1Z6=iIMgf{>5~8UNV>H%+!>yK8n(6knXu8f&bEZYWN6GKNsOQE4ZT_IT zCUQ_g=Fb};XRvqM2{c`HJSACof?JZb&M(&8pvyUk6yYKM=LP`x1n;3-b3t@#(qoZ1 z^UZ-8d50yHxWb{arcUhY(0pJjHX23Sf8*^5Lw1^ zpvZX%j-TGjN~+idIK(Q%q7X^w8uY6y2>zS(Rag)dowNRIScb({gA<>#Qe;B#D0`68 zh~8$E7T^{mPY#uN*-%@g_}K|kS09Qvdh zYraPi4Y@|<8jJ>KtXdNww}L(CCY=9kHG#L=Od&Elf|8h&K2LH2MJG&hk`YsoZw4D*@RS$S+H*4$3i8Mc|w#P{Aw2JR~VL2mgE%##jC{lhN@Z ze0J&+cyHwIP?ldrQ52U@e(L4;!L`tJSPzaB{E;)=%7^hby%iJf`d-QdmS}FTnHneJ zYLbvoHkqKQW>YE!;Y{Od%2+cO4L2B|nO3e+x!(k-8-#I_C2L{Ck^B6VyJ$(tIqkvH za;fj*M4a?ise7G`RD#E^K!QLcHn ze0)d*q(sDmEL$H^(^fH94v;7ey<-)YL=ffo2tq@x*aH0NoM+VN9BXi^U$zCj*(Bh+ z)huL%hD#wVBJ=`5VpEc+AViLl;m=sO)c}klB2PTuid+Lhg5*|YQ)~^9B)rrNN%Hv< zJ2MSG_R2BlWbXY4YKI+|Z_zhwBFWE9kkeBe*!7inP&r2H4IUnBc(mg5Apz;SRgsB@ zqjhW1c!M6A!1)G|WT7BL4y_<6)d5qasu#RHUbGq}Tm^o!J8A2oyq1DsIaIw)e1zpV z)91qd8-5IjcI!F8d={(M_9=R9yO+-{I#ZjlTeADVD@b^FFocCkATz}nY`3!5;c`9% zXBs{fUSd4vD_cbd-*8JIMiRLg1!kKfKO6=TeB`E-5V=65-IKS$H%hhRB#8YBNs4+uVq-xd zw8$`CT4+GzP+em|=(1m}C@4DDOf5+@iVaC#voa2UI1Hcq2P;9ghDl9&hDlF(j>-`n z9ZTg1$E{*yLJ&x!eIY75nhuO!Ye6b1=OJ>CK}?kYCY(-uhB=;V&Wu)VAq$=EBQtVI zNHyDTLgrPebaGsdAR|rdh$M9W6>f2AM^Z%4;d{R^IZyDU9j)va%)eTSAli|14UuDT zmj&u|u&nKKw0eNZG3>N~)9KEGeQw-yBjP{0Ad(0P{!|QskdRo4ybxeceH|+xS06Ab zp@r7SKilN2cCTI(oNYKyrqw*UY3TEksL6SQ{uzeNSdw?WQ`X%ZQe*(|2Vf~Oe~9iMCkyE9God~z&iLbJ>fMngvO3D-!FbFGO)ZmJ5+w?HAe z#bcW^m*~RzSf<@X57?pr~*NjVHZyu@qZRCyJ}v z4vL$LE5*abj*^|7O;KdXDLJxq3OV0|AkQzPl*)6zLxo&MIdKf2rwagOJPF9(h#4$> zkeRGq14k?GWBQv{zK2_m5i)m*%%Q>+Tz$SAmm|mo87QnXRL&hTMWd~Tj&os8K#zxD z`>3}KnOu0m`j0C|Ou@g4a?)X)y3a{pHMnyF>UK5{>v7t2!;$TlP`qP3r{3ck*yH63 ze*Qra;O#{xoBrs6L?uR1Ny5SsC`6F6=mwS~-w50i|9Epij%fvv1j}(2qq&qIx*gte zm_-lej3_W81C1&_`KsqbON<~c;Y}t!@hymoOQ4dFmLwuB5mGZZK}yC(#xK_jFm75w zl>a3`aLb5um28m;3!;fDw?v`2Ml)EFkl-{c18Ig0q-PmHO1eJyDr`93aw`U|V^~Cz zn1m!M`n49Mw+Dx!XMl~OEtz-`uue4@zghvVnuz08M1{j6GJgtPsUTPm z%?$q8$-Ve{?OfAY>0{8p+q8;&x}R|3U&-{6%MnC1?9M|&Zrszy9ZWyKfuU{Z|dHX?-uMb_W@Z$>-Dy4*mMN`5e{UI_Y2)1oCVn;{53}IPoe;b_k zXl_!e2!-e9slxH6ZeXeb*@_1XVu2P;HVn=oi>%1@KE|iX;ha%<#?g@q9weqe%4Fsm zKuolh5*HakNiPMMM8Jy376IngV7bxJuzWrW$Pue03d?IQ2+glBfpV?}qm<;CXrzVs z>pbCEuPj4FV5Z;&GUqH{u#Z5F6cx0#^XdQs=mtUtnti{+YOXNxmrB4M|KaLL`f-F9Rqu$2afS<2}>8&Q{| z)xi@EmV$oU4RGjRLe5$If1jEI5a8ts!G50L>+eMg@C~H=@byFmxndD%0=6lKeJ9t`EbL@3R&Pl2rka zfpe5_nUD#~5|Fb=UXLuod5+9f6_RiL-xOq?T%pE4hp!A!2sF}SF(PYUg0h4sd^5Sb z0&@hWJ_?iT{PN8pJ!2IlB|ijj?fNZySH3wXE_oyK&5pa+zya0{=N;he;zzP)THo^T_k*2!3f{HkIVpzXg^o_Z1Er@L~_jUW1V;1DI(NK61K7U!rL; zo)@mF<@h0j%rxL&>CLxm#v1we(kZh~QxRNa>u-sJRRlrkY5tQfV zZCxmeI;6l1g%+7VnU(YkV3_#GU?7CWMo_|olOR%>0UE zN*AQn6V3 zTkwZLMg|SJP|Ur6qVIu*w%5=sQg&c26AV(;-A4i*G<#c@|ivMgTNooCeOS0W?_cB1*i zI%$h>RvTVvv?!x1)}tVWNSYS+5+p~zaDgB(;pq??kpY?M3~sT2Eme>cuJqSLDufeZ z^XIT%#u+77Pmlna8S>2#A+PL}2n&KD(l?`!BpbDVnt?JXldFkV2vI_jSVIv^itS|bgaM@8mS zk>01_#HQ;JWPmv<~P%Rc>EfnO~He)i_ zr|KhH|F~J9ZWmrzkE`*@K1Tr@{Bkwttfv)wbO#TD0)ru#oNVU36Ubm75rpuQ6J)Wn z!~Wl}b^B-V=Xe$*DPLkf#kt9LR|^Quwr0ZR>#f2v_|njHE~7c+(#+ea88%|Q0N>oErB`4-^-7+v{Z~l3`CmgbEjRh)a(^%D zd=Ba&A7Rk&#~k0UfqkM%0H@kSFwww;@p_|G)6M)H_D4LtSnnWuM&!)N8wb(H3OT-3s+Oi&2lW z4Z^vFX3&<Yayx6Q_a7j!zzVP=zrp##V>rp)8Jw+Oiv^h?J1<5T zTWLbec&f-MAqvkI|1F}x9OPL>Oi`W)ViUqHDo9AuLoj&yQx@C}tk7J3er%Z-;iO$f z1PRFzBkhhV=LA{MIk03wbie=H>u6^U5J3WStTll-mK1GEc^0>Jr!i-+3>qRzT)7!a zs5IBamYEWHCb1j^(hQ3)eC0-Z>FrNJU$G7R`)Obut1yPX2Js(fTZQjqF^aG#XNVMY z?Ixt_RgF>M`%8*(6$ByM`=GJCI85v0@h3bs!mzW|z8()#bi!4rCV~(a6Qs*tga+I! zH8}ZJ4R~x;pW11IhU^5D!!8272~RVaOWedHyb8>W^l3N~`2xtYa;YFhj+FHOfm4dN ze)|dRz>;jW`wF%>d;{M&dQyIclhLI3cbV)g3noWq2`MRm#^J-O&@=N9D<((p?vNa# zO+i`uUqoh`OG7hE_IhXVVj@axK&=5*Z5=>zTr8v}#DiZz3?(QajFKE-0B+lV&Gt^x zgMf7I>PQ8DOe!-ZTM(TM%NCstN);$6Xwti_uJ*0OuEc4VFcT zuj(l>9UU`$k#5?h2%>|mAS45dWN=W`y1BkB_CB)1;U=>EPLGSJhHOfG8GG-}=NwK# zRkuAE5jRIeuBH5_?alE)yq zcryg$n%*QWuwaNB4UGVaG0~8gm~eq0iD7z>5@`s*G6CbCW&|Nw25(2_3sGXpdUXO0 z;bQaG&r@JY!D5^P%R$k~p(c>+N-u&M%R$lQ9z;Sy6t4U_$m_-znoz)kWQ$QUz&O`n^JPQ$9Ym1GF%KKm z?QEjDOhL#L6eZ4CG#J$Dz)!<@#)HGI=76IA!c+u2$y7_80U`$>BPEAQLge_L62$!* z7x38X3OjbVz_x8p;IQp0itYAqDN*r}l*HI@N@l(Rd}RmZv?``3e+wro)0i0>{e*Qp zx*L9n?tqj886`d?nc^eyqXhd0QUWCbIBD~P{44-;8dwsUn1gvNFw6MwIOO>ThfThL zS=?m&bp>W$pv>@uB!@fbpQ%47Rq)S5kjjiJHi6vZPjM7pY)U`nLkmSHy4(Vh1e-jL zlWaOG8Rr>Fe4P%-F&)Bm;jcUK*A>30+|=+a6FByHP|EgHA|HxV99aXpy4$XeaIx_b)E4tVM=CPv0mRW${d0_tS{hQx7 zgx!0WYxEx<^h#O18VkeqO<%{~nf$2rp0qXVy))LW$6?S090F|&$T2dmGEFK5*BUUbp|6?wJbek?{$kspjYc&xAU&rv*Uv}*< zx^dXe47%OkV1~TjWp@SM3&;KMhA4^uFGCRL9oxW($ndQTW!rW;%9nO7l*oi=3R#s% zhzyU1n5ZO(kB@=4xM+w=N`a)b3`k0ehdBJal%!-zVxo+4P7MEW%AOD`Oc?pv7l&Ma zfKGnNstVC(>SFHZjMRuT#v1iIFytDBahq;6`E;zo_|>Tv(I({qYvVJWmaFFWn%|G_ z??ByKZbp-TxgL$Z#GsKEIcWF=R(sYxfcl?d;m22Cu0!3A-=sQdb?e+-v-?i(6R*Uf zhY_w=Rya<70bk!6ZCke;6M!pmDyDL}$>i8nqi6=V3hL7b#E5L$NhS(v*I&@&mu$X< z|0d-`lR!OPE7D-lX!ATeLHgQ_Dd)t{qT58_9M?t=wN{X)20b@HkF!<7$exYpm_1I; z9duLF=O-Bsx|(A#=#&#R+5TtH)pG9mcD^6xUi=uKqXBFb`v&759t%FwDDaa6K~Sg^ z!lW?}925a^{QsYuoQm_0SV~G}G9@h|mGZMML=J~Jz1NIZUX z2#LvYkdl_Ts30Bzd#EI2flP=b5IPdY(5=|QbY`xEnU)RDD-W6>j7EFK6?6lR=dBPr zvJsu$XMrmfiMxer%qe_7Inro6iYw&6c#CjwqE*z5?@tUj@VfEi14H%Peq4!0a9lot zucycw3bO4cNnXj8oa8uw(?^69^#N;&a-TJtZ84)Q0}h|a%vt47jXs$uh^64Fr(1<2 zzQ>hQK>rpZ2VuBTK*6v3UlnBPLewO@!gS($=ytNS!JtFid72ZB=bzvtQj?Qq2VBHB zl(I%69yX`35YbqSRpjh))ZoBE;eTer%z!oQY7Ks2F+e1dgoRQ;!b4-JB>(e*{Me!90ck_t*7m6br&I-HO0aozJ4bcK?+ zO=Lqgl@{fBsI=eHoX3?`(C-LwtvUC4WET_6sTMx%B$#P_Z0m59C5(AmB;XtZ9d{6@ zPB;oR6Wi7+$F^Fl#@sA56CUJD!u6+4?63?UcD7pAXJ-be_E{E+(`S&~b!_BxBmC$C zb-(mN{?A=u^XbGZ!J;-_AqnQ7u7%s4vyr@Qn0ese!gvS<1n1)6SWle2y9=9(G1%^Ica)2Po* zIM8n|+BxX@*uy>cHqdX6TaB1En1k;EbIS2XK#n&6+RnhTAU|@cYA%BEhJ4uDz7>aN zFEBW~A$!bPDG%_^Otq}Lph91&V(UVdg^yqEw2W&l$PW#n7DKz859(ou_wiL1XTll#|XI(TJVZ*?v2V(%v27cRC$-zdPh21TWK@S;yV}%BWx3 z!l3tFhhV7$$a3nCfFOtnA?L3w3-Y6u%BDif!Tq+-xpy0kRC75q&8C3m_y;;_inO8C zMJ2f!{0|AD!Kg=D^)+K{2F8VN-E?EWr)aR(&U^+_IJCp!OU&9nTXPt6uz~|ld^jER zCuZ311NLF3R{*{LS3n=WNd+N$4Umo7!h@x-EXa>s>gwt!HCj#{eHhRgD`v7o1Y@my zZ|z3QBtM|}#eXey`H=+C{v?&YqKgs^|1z_O{K~G4+SLKf_!+%4N^IJ6{#Q80qRtgmshaAL7 zd(_VEH`buzV$vyb7C&}qsA=u`{`ysb+P+|;^cUcu>pIAKi^Enr7;vy4>@xo~P#&-r zupkuw5b3fYKjqS1RZOX`X`ocs)rMQ-Y zX!9uAgGM?R=Eq#PN^M62?VcvBs`lil$*C4m(MTQl>y!KV+(Qj(SCBRC$BzOi)&dI! zyv%If&SA$Valt#pANELmK@t=)KNa0K0Hppw5EASQp;CW{kVq&&VZIcA6<;AAc$tXg@1mRGwo_7tpgcv zAu^C~i-b+e1C|`g%7Zvl~m z;2RnUKGFcXh=~Xy@gY)3sT|9K{EQ1a>&HIW_7R+{*1Kb}!?b?xhy_`ztsHMPKw~ZX zORnGxEAB|+e+KV{8;n$gt!q>REvr?N?L0M3hR<~;k2MRl7*j@D=9y?Vq|7!5R0MKP z2Sz!G!H=IEYcW(|)T13-%}Bcu8MnX^tf%w1&=G5daLaHOhfe)XYi5Sq^!E=pagzrd z48NUf)89PNvhLnpcECQ=B!amr9!!@RfaG&vk5t=mPM^g&1zN$XROACeUOEpOnD1q| z{LIUN)(;^&WivC>$Rm>6HnC4wNhHB?oF&UrueKml?ILJAHv=8%}8(n5X*`CVgsc?b(~cCy~^!IE-74WkG&nrM0GllAdnBbauW3M+zUlWu(!#NR2;r@u&Tg!>x;Ko1>X*6B0>A zhw2S;$Jz{PG4&&jhHZm&di(Hmju1whMU=i)3knXa2#06u#T}TY(H22DMu97SGA0zW z6SH-!M(wha!`4;^?C+FGzM(c6=K+@)WAaOx7dB{f)FyxmP{y|n4e|4WI@hvMP~PzO^j3v_|r}1P*Eu6 z9NzmPG*mT0V|5eMSJgm6HLj=)4f8bCQCch8p}l%9%$?=1&Zw?q%}u<-&Q86RIoT-U zpy@X_la)da8vh%EM*q%k+I<~+4gpJvQPiYEL46jKmQ_+}i%Tdqr4^LM;(3aziYe7) zrIg~r{CSG9DY~QOii#AJWkLSO%R)g;^aGqmz|M=kp6O}fkxiZLv|XO6kvc>5l}d6E zIdoSw$wOvaERXk>89hkkAd(O{2u-#1RFJaz`g4L*G}Tip?QdjXE^H;1l6 zRxrBH8bgvlWjWyMjrs_HhX<=IGTA{Y74jS8OZR%ocl($qFU z6PBj7x`v8D&K)eRs-*XY)fJa52uaYhD`d-pESF1{!(9)-oZ6BzQY(VVCi4fT8@Y9w z{pM(@OW6C$?j}OwD+db+-6sKh zaX3}pTmuy?wNQ&&2tsvZ3FTmO1Er~BFJ*soJ!Nl8Go`el6v~^h5S8UnSzZc#eE?lu z0CTgzCWC&+?pJJNe?9wPIKJ<`gN+V9&YsGCkU@@)Y$TCDOjI-^#Kl8Qm;|CjgXr0j zgt+iz%CaE8@N)3@1F-M#?*R?H!i?6Lz(k$FjhJvH{?wo8FjQed)I^e{2tux8HKJCM zPAtbtGQy8WUf^gD!|EF#r_okkmLLaO8Yuf(TPS6XWmJ;d>Pl#=uZ13*kEqow7E%Li z0Ly}+*UnRi?`r@wrTU;Q(E}6|1VoVV&@d`VVsscJM1?L3vRuAfDo8j|TLt?Lz6PgN zJJ@La1NLmgCYY#M`;n?cczS5xI@H&;N;S}`kA~U}(Qu21GTLN8SxgY!ln<>eD$;8< z(C)x-8a%``0!}8;U6aIc=r$2t-gP<#bD(`t7z?bi_mPSPPC@C zk_YI>ORU3@_d;xB62wF#LUc?pB|IvTvMk6iybw96YARvxfmf*{sQ+)Q@hU6MTr(G@ z>-9w=`}GeJNw6SFjQUCi(Qd-_otA`b-heqhSgrRFs>YHWeu9M%pP4?hn)O2jA;~-{ z|2=3@f5nV9o4{Z#@4?{)gB~o%+;pR0e7u%BIohyxa=OiEcDR8zH@nXqO}1LTe6oTI z6LLf5NYs5)j`--5WkHt9cS~h`2~^Y-LsdOaW*h2pr7QzB3s`94Q)Z&(F;MLn--1JZ zFusWZ010nNL_t)nxHFw(M-r}jsF9~4n?sLvn2@u@uXx-1G}b)vSBx^gh$6 z(}%3laFemJzmcmU=ZatPz@oM#(XMh-sg7<&NoyOZ7RZ z??o1oqph_AT3cJ8rKJVx+G?QvP%D*#aKwjUqap*&nYt~oKYlflBUyQXN7myjN9%bS zEC&V4K~Rk!{QvgOJT{K%j^n?bS%Os5Dy>jy6A%rB7@zB1J6<1k5|buvlN2IE#i1T) z|L8$ck3T9EMJ<)uzi9vIAMHO7z=a?{v<0Ohacu9sGdsI;?BUq)+Bs~ziP!7(?s|RC z?7Y7B;zf?ZSgs>1)c2J>@{DDztkrzx_ukAqn$URMb~N*^=OF#|?|32y;UBMb(*6h& zgyp7$#4w101i?@Y1St-FUpH;qxN)a95RqW$uXl1YZ`E22zZ1-0OGPeH@m;vW zAZ%2S)f1YsT7ywYI7^GkX0m00KPOcmMCl`zxoA7gYF`61-ooYNd#uqjN34BakMIcn zkTcQuc$9g}n(e-yM2BRq15Mfr`L&- zMfWvXu^_k;e1y--)z4-w?4B=Psv{TrhqgKUILcFE%gwEPya`t%dnp&KGl(Q}Q3q}k z)dz}7>+!7Eco({CgHo~@rV>AcLjN-`mO4N|S8=~3-Uhfjd|^`9^P5H0F=-~8Xk2j^ zq_R;tuz<^gm_!cDLN?gYkoMYeU+V!*4{d|jUVjx{eCY*v<+V4cBuo&N4JD#pz?CHm z=-uxEdi!yH$aRn(6CVbAQQIcu`k*=7h6-9;iOk!WiQ4Jvgt;(zN$5a1t?2{S+Qn&Q zWht-SMHZ$b;Nz)D$#xd=d(0DYMYa9;$T+c;kl&S8cO-M| zm&Gu!3?=gB{v!k_EyP-h92atdhR+c}^d+ao)s3s9SKR8am*vwQGK_a0TIOI_EAY#ST+?o7@`D1 zGQ}VEQv}IP$z2CP#%DcEXOH>t{!25QRD6=d{R7A=_MHXQ@i#2UJQ-d>UJhbG=o7E9AVij1Ey(gvd!5XO#}zFy z*t2tcTCRo@C+^}@_34`gA+WG)Dv}tYsD7DJJ{P23N;D=xL=He$QbCSH!B}j;ckBH; zdcVzr)ZH)>b3TYgcokQRMY31}J~l~3$&-~${)ZWi>j+}v3Zj$w_S11^Vmws4^Wx}d zp`h>P$aGq_TLhn1pagxVDJ(3T%6fvRnn(o+i87UB^vb=E8odwDwY^-i=T077J8Hof zG7+T)_Z?owa?BA?=KESnC829tk$%N35QIG9)y>NX$gz-?>b``l2_V$M#fG+l&lkP{ z6NKfH%vu}Sm&v`cgGb%9d?h|j_a1<3!v589wdT*Wvh5m5wv&15bOE>-twt}Z)yDGX zJ|-DCOc;+J0R(riqNjrz3aG|6T`nQ-ID$;h71q{en(IrefE z{7;l&Vfn^DpCeYok?oXmzD#|qI_K(jyK=I0u? zt5@y@CJ4)xW<5cATwN4zw})aZ-OC|V!O!?=A@8cho%;@$lG`81Cmg@PoXN**>FI=> zYy)kQr(g>M4U|%!lQNGb(l586<#{Ez;yA)n5!;VP2deM5K3osTXyNs2l|>a}AO|%j z2+P)HJweX*oTa$CT$G_ffaFC0H2Q7Jf?&74@GNjSWiRC7c1p3&vAqzlcjguQkFw#q z=kr?in;184k_nhVMy-BtLaF*Y8ES(2E;U2v?&gcpMlg%tu%M-f`OKvqFfsgP2>TTZ z6NF_;vz{O>S2v}n*F$mj`Y66`7iIi?0DbOu&ehvtHT37W)PN1f`s)CB9|9CO2+2#k z;L_FmA)Nvk9|KVPABDuw~0EG#{vZc8d zf)ED!pQ51Y!xm&b#-W~eE9%{AMd(}Hg1*mETD*;N;*Y>}dE2eU&<-m~w{f4}29~7u zCrDv=My{?c2n)-WWL-hrXDRL;55;?4pt!qlK=v~s+ck(<5PV?|2w@f6ei0Nw096!0 z62c%$3MirsqE`SdEI~vFf-4q+^GX2xsvCMkUC<}RLGeXE!k;zG0;&|Eg7jztl_a2fAQ<&R3|9>;pnOOW z78Vv378Vv378Vv378Vv378Vv378Vv37M737e*x>rZ3-!zvb6vJ002ovPDHLkV1iWP B;zx5eTCUBrKwoMUfCJShitL2!tl-3o`o6H}AXeo_pUt_n!Zr^Plss zeC4y>K-Wwc0009ox+epZLd{Fm#`+(RRuo}EheZ#J!=&Z`feXnd*o1J+%kvArq?@y1 z8pC40F(H&S`IX7@;@JC$-E$p^RwnhLQ{I`6_Gy{o@wpl6cBe zCS92snfF&3T^oG;V7?^s~>II9?%;so7!EkEzw4+s)p_b+kkF)YJNvuOv~bD^)vR3 z>N0OIP;h;4M&EuVyyBFjs!o-aTnlk{HiNaMA>-w1>Gofn$_;imQxcUIdhiL^#sdETKb|xjyQC{ zdqBlQE}6*qyE5+%8>;@Qn1pKM!u`RqKVH&Dd!O*CTiI$Or)PnQ#r*CF!YJ@B9^f;l^o$h%m!gxEd=z zjV-Wzyc(nDyg6 z+tyR8{xWQOt!&WO%}|5TXqVDD475c7Mk*)e&;=LWI41*^dOg2ZfZu{4!E+v5Vb#0B z=mi6~MoyqlO4%7TZ1UAPk_IQ`Z-7oS?Z0Ycb-mDtRDlA$QUK~r#Fji@)Y-iSl@=1! zs#{Z_Hh&&4I-77?alVJ>3o>xZlD-CJ8MJ1N-7thv`eAsgQw)OqR~vWa?rA+B zcD6}GzBbu^O%VOmnX^dx?oXW7Wk};)3=)b7x*uE*OqWgDn__@c0O}~V6>EJsZo_ce zx@i3rh~K~(xw3dN1OcLVp@6ObIcJYsH6U)|m_vGM6`)bUb26geP}0ok)0_&a7h9!3 z@faq|gvQFZ$PT9ajjmqEH4Z3|LtJ5j{3npIf^I<3MA7DBEBbV-+}GaP=T4tUS?hL? z5>MNRMLWW{d~}l$OUIEV-`F_c^D{;KC>0mSX~5uZ)8(rKG=S0q%ghiUr~H3aHa!h` zu7KmSoWuf0{lm7|PP`zRd~I1l!1KJM>IR52v3JBP$4#W&8)PZr_otU;9+?FRo);^I zAj1sZV>JW|@+S=^NW0-b#P?gV5WmTC_fhnR=l-cJ;8f%H+35|ulAqWE$?oIf$Dci$ zcq_Hd={(FRmc$p8g=DK$dlV7EE5@R4OVRt6f@8W~tu@{Zp~O-TOfMJsEHQWmCQCt? z;i}TDC=iQ)lq4Y9K-i=hlbkuSx57o6j0_pT|H3G&VH=7k!QBwG>1fm^q1RImCd2a| zi!7_UWR)}viKwZaHq!lMoq(P2j@48@IB(|uT(O&VtCzFJmqgeUu?Pb-aRV55VW90@ zn=2#C1m#)yF1Wq$AhHeEhUBjku!wiysIJVY8s$>XD73+J&7RL4(5q3-Epo@Wrh3Ft zj-~cySa3ocT(o;Klf4{c{LFHpl(!~(X9U~z1w@x1Z7!LQ*<*E2@@`ZYbs$x~+_=lt zQbk0uH5MHaj{CWWKG<*@#FtTG67sZexp9UdGjrg*0nAjZ_ZV_O@X__NB`oLot27VX z!-YY@2MF|RY&GZQib5v&eefz}_s-ErgYzm%(=4dH!C*Y+`V3@bNnd$8mqv1clOZB~ z0@4@WhP*!Aw2}*QU6IRTraJ837uTaC)wkzVpw7tq0)}3&`cT~x3H*oY$oc=Pa$+dw zX09Ol%o_?JDUyK|R5o&+Kt?bGc#g+8W5Mt-LtSCBH9?chmh`H3*9Q6eoMH3I{`8Xh zp5>`rF;i*Y9cex*Set&^aGr?3r6QLLm)H*e z3KDlH#F{gTeFrK3!4i=Kzc?4poWmkHTS1~PY})2aWT1`}(m?1#$8Cn7zt1JiK%ipDHSS$8&8 z@!80oW^1QMTR3HVi~jy>pM>g7iBa0Q0?7~3ZE7QwkAD^Ex4-iKWn?)Z-OY(M0p$m_ zyHNYmOSj%zK?mjnPE?&B{dM;Jw6K4mb2xEMa?@JU)2JGrV5X?cAK~!ioh85E0#$34 iVRu`!i173`N|L$T( Date: Mon, 13 Apr 2026 08:14:16 +0000 Subject: [PATCH 15/58] feat(retry): implement total timeout budget for retryable API calls --- src/churro_ocr/_internal/litellm.py | 16 ++++- src/churro_ocr/_internal/retry.py | 56 +++++++++++++---- tests/test_internal_helpers.py | 98 +++++++++++++++++++++++++++++ 3 files changed, 157 insertions(+), 13 deletions(-) diff --git a/src/churro_ocr/_internal/litellm.py b/src/churro_ocr/_internal/litellm.py index eaed37c..02b41d9 100644 --- a/src/churro_ocr/_internal/litellm.py +++ b/src/churro_ocr/_internal/litellm.py @@ -7,6 +7,7 @@ from contextlib import suppress from importlib import import_module from pathlib import Path +from time import monotonic from typing import Any, cast from PIL import Image @@ -174,7 +175,6 @@ async def complete_text( kwargs: dict[str, object] = { "model": model, "messages": messages, - "timeout": timeout_seconds, } if self._config.api_base: kwargs["api_base"] = self._config.api_base @@ -186,12 +186,24 @@ async def complete_text( kwargs["response_format"] = {"type": "json_object"} if self._config.completion_kwargs: kwargs.update(self._config.completion_kwargs) + deadline = monotonic() + float(timeout_seconds) + + async def _run_completion() -> Any: + attempt_kwargs = dict(kwargs) + remaining_timeout_seconds = max(0.0, deadline - monotonic()) + if remaining_timeout_seconds <= 0: + raise TimeoutError( + f"LiteLLM request exceeded the total timeout of {timeout_seconds} seconds." + ) + attempt_kwargs["timeout"] = remaining_timeout_seconds + return await acompletion(**attempt_kwargs) try: response = await retry_api_call( - lambda: acompletion(**kwargs), + _run_completion, operation_name="LiteLLM request", context=f"for model '{model}'", + max_total_seconds=float(timeout_seconds), ) except Exception as exc: # pragma: no cover - provider-specific failure path raise ProviderError(f"LiteLLM request failed for model '{model}': {exc}") from exc diff --git a/src/churro_ocr/_internal/retry.py b/src/churro_ocr/_internal/retry.py index d3a7680..188280d 100644 --- a/src/churro_ocr/_internal/retry.py +++ b/src/churro_ocr/_internal/retry.py @@ -5,6 +5,7 @@ import asyncio import logging from collections.abc import Awaitable, Callable, Mapping +from time import monotonic from typing import cast from tenacity import AsyncRetrying, RetryCallState, retry_if_exception, stop_after_attempt @@ -112,6 +113,16 @@ def compute_retry_delay_seconds( ) +def _remaining_retry_budget_seconds( + *, + started_at: float, + max_total_seconds: float | None, +) -> float | None: + if max_total_seconds is None: + return None + return max(0.0, max_total_seconds - (monotonic() - started_at)) + + def is_retryable_api_error(exc: BaseException) -> bool: """Return whether a provider exception should be retried.""" if isinstance(exc, TimeoutError): @@ -164,25 +175,48 @@ async def retry_api_call[T]( operation_name: str, context: str | None = None, max_attempts: int = DEFAULT_MAX_ATTEMPTS, + max_total_seconds: float | None = None, retry_filter: RetryPredicate = is_retryable_api_error, initial_backoff_seconds: float = DEFAULT_INITIAL_BACKOFF_SECONDS, max_backoff_seconds: float = DEFAULT_MAX_BACKOFF_SECONDS, ) -> T: """Execute an async provider request with shared retry behavior.""" + started_at = monotonic() + + def _retryable_within_budget(exc: BaseException) -> bool: + remaining_budget = _remaining_retry_budget_seconds( + started_at=started_at, + max_total_seconds=max_total_seconds, + ) + if remaining_budget is not None and remaining_budget <= 0: + return False + return retry_filter(exc) + + def _retry_wait_seconds(retry_state: RetryCallState) -> float: + if retry_state.outcome is None or not retry_state.outcome.failed: + return 0.0 + exc = retry_state.outcome.exception() + if exc is None: + return 0.0 + delay_seconds = compute_retry_delay_seconds( + exc, + attempt_number=retry_state.attempt_number, + initial_backoff_seconds=initial_backoff_seconds, + max_backoff_seconds=max_backoff_seconds, + ) + remaining_budget = _remaining_retry_budget_seconds( + started_at=started_at, + max_total_seconds=max_total_seconds, + ) + if remaining_budget is None: + return delay_seconds + return min(delay_seconds, remaining_budget) + retrying = AsyncRetrying( reraise=True, stop=stop_after_attempt(max_attempts), - retry=retry_if_exception(retry_filter), - wait=lambda retry_state: ( - 0.0 - if retry_state.outcome is None or not retry_state.outcome.failed - else compute_retry_delay_seconds( - retry_state.outcome.exception(), - attempt_number=retry_state.attempt_number, - initial_backoff_seconds=initial_backoff_seconds, - max_backoff_seconds=max_backoff_seconds, - ) - ), + retry=retry_if_exception(_retryable_within_budget), + wait=_retry_wait_seconds, before_sleep=_build_before_sleep_callback( operation_name=operation_name, context=context, diff --git a/tests/test_internal_helpers.py b/tests/test_internal_helpers.py index d74e097..d7c6a82 100644 --- a/tests/test_internal_helpers.py +++ b/tests/test_internal_helpers.py @@ -345,6 +345,104 @@ async def _flaky_acompletion(**_: object) -> object: assert sleep_calls == [3.0] +@pytest.mark.asyncio +async def test_retry_api_call_stops_after_total_time_budget(monkeypatch: pytest.MonkeyPatch) -> None: + calls = {"count": 0} + sleep_calls: list[float] = [] + now = {"value": 100.0} + + async def _fake_sleep(delay: float) -> None: + sleep_calls.append(delay) + now["value"] += delay + + async def _always_fail() -> object: + calls["count"] += 1 + raise ConnectionError("still failing") + + monkeypatch.setattr(retry_module, "retry_sleep", _fake_sleep) + monkeypatch.setattr(retry_module, "monotonic", lambda: now["value"]) + + with pytest.raises(ConnectionError, match="still failing"): + await retry_module.retry_api_call( + _always_fail, + operation_name="test operation", + max_attempts=6, + max_total_seconds=3.0, + ) + + assert calls == {"count": 3} + assert sleep_calls == [1.0, 2.0] + + +@pytest.mark.asyncio +async def test_transport_complete_text_uses_remaining_total_timeout_budget( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls = {"acompletion": 0} + captured_retry: dict[str, object] = {} + attempt_timeouts: list[float] = [] + now = {"value": 100.0} + + class FakeLiteLLMError(Exception): + def __init__(self, status_code: int) -> None: + self.status_code = status_code + self.headers = {} + self.raw_response = SimpleNamespace(headers=self.headers) + super().__init__(f"Status {status_code}") + + async def _flaky_acompletion(**kwargs: object) -> object: + calls["acompletion"] += 1 + attempt_timeouts.append(float(cast(float, kwargs["timeout"]))) + if calls["acompletion"] == 1: + now["value"] = 103.0 + raise FakeLiteLLMError(429) + return SimpleNamespace( + choices=[SimpleNamespace(message=SimpleNamespace(content="ok"))], + _hidden_params={}, + ) + + async def _fake_retry_api_call( + fn: Any, + *, + operation_name: str, + context: str | None = None, + max_attempts: int = retry_module.DEFAULT_MAX_ATTEMPTS, + max_total_seconds: float | None = None, + **_: object, + ) -> object: + captured_retry["operation_name"] = operation_name + captured_retry["context"] = context + captured_retry["max_attempts"] = max_attempts + captured_retry["max_total_seconds"] = max_total_seconds + try: + return await fn() + except FakeLiteLLMError: + return await fn() + + fake_module = _make_fake_litellm_module(acompletion=_flaky_acompletion) + monkeypatch.setitem(sys.modules, "litellm", fake_module) + monkeypatch.setattr(litellm_module, "_INITIALIZED", False) + monkeypatch.setattr(litellm_module, "retry_api_call", _fake_retry_api_call) + monkeypatch.setattr(litellm_module, "monotonic", lambda: now["value"]) + + transport = LiteLLMTransport() + result = await transport.complete_text( + model="example/model", + messages=[{"role": "user", "content": [{"type": "text", "text": "hello"}]}], + timeout_seconds=10, + ) + + assert result == "ok" + assert calls == {"acompletion": 2} + assert attempt_timeouts == [10.0, 7.0] + assert captured_retry == { + "operation_name": "LiteLLM request", + "context": "for model 'example/model'", + "max_attempts": retry_module.DEFAULT_MAX_ATTEMPTS, + "max_total_seconds": 10.0, + } + + @pytest.mark.asyncio async def test_transport_complete_text_rejects_empty_output(monkeypatch: pytest.MonkeyPatch) -> None: async def _empty_acompletion(**_: object) -> object: From d7cf581c1f2b61673d108274e9e164ea9af6320a Mon Sep 17 00:00:00 2001 From: Sina Date: Mon, 13 Apr 2026 08:21:09 +0000 Subject: [PATCH 16/58] docs(benchmark): add icons for Skywork R1V3 and R models --- benchmark_results.json | 4 ++-- docs/_static/img/azure-logo.png | Bin 93963 -> 12164 bytes docs/_static/img/internvl-logo.png | Bin 50595 -> 37537 bytes docs/_static/img/r4b-logo.png | Bin 0 -> 41870 bytes docs/_static/img/skywork-logo.png | Bin 0 -> 10728 bytes 5 files changed, 2 insertions(+), 2 deletions(-) create mode 100644 docs/_static/img/r4b-logo.png create mode 100644 docs/_static/img/skywork-logo.png diff --git a/benchmark_results.json b/benchmark_results.json index 63f3920..bd1743d 100644 --- a/benchmark_results.json +++ b/benchmark_results.json @@ -402,7 +402,7 @@ "modelName": "Skywork R1V3", "modelId": "Skywork/Skywork-R1V3-38B", "modelUrl": "https://huggingface.co/Skywork/Skywork-R1V3-38B", - "iconPath": null, + "iconPath": "_static/img/skywork-logo.png", "printed": 42.4283, "handwritten": 25.619, "total": 33.3771 @@ -429,7 +429,7 @@ "modelName": "R", "modelId": "YannQi/R-4B", "modelUrl": "https://huggingface.co/YannQi/R-4B", - "iconPath": null, + "iconPath": "_static/img/r4b-logo.png", "printed": 32.6874, "handwritten": 21.735, "total": 26.79 diff --git a/docs/_static/img/azure-logo.png b/docs/_static/img/azure-logo.png index 23f6803bbdfa6be1adc5a1af27c79b74eb8cfa23..7517b396734eaca648c6b707fb016d0eaf4ddf46 100644 GIT binary patch literal 12164 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
!Oj7-8|!t|jFkeTqzXHCoweovNk4 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 U53HF+tngc?Dr+jeQ!owqKhe}3?EnA( diff --git a/docs/_static/img/internvl-logo.png b/docs/_static/img/internvl-logo.png index e2579766af457c9afc9ce025cbbf27246fa84527..66dcbbd4d666d4a7a6c628b28ba66953cfe4cf7e 100644 GIT binary patch literal 37537 zcmcF~19K%zv~6t6#I~JGY?~)Ku`#i2+qQ9HTNB&1CpIT|-@R4$C%mre-qlrI)mqi7 z_v+n|%8F9R2zUr!U|`5H(&B*s?AZTgIEepBGG3nX{|vl?w3hRK_J0yMScoU~f0Z9# zGU6g?9yymiq29WyEw`Uv5;qS5%v+qXwyZJz7*?v%`#YP@tqSPLC5d5LrV%D-!=|`s zCNYsJY~!NQj^^z0;j@DHy*(B-t1?-f3OQ}DbsEpVSf%f$c>0sp?_Nvay@b^|}+ z{h#rHsp+a31KO!K;{Sij_st-1xw%QMtAHS6p?_ljeYzIk<>d4XD;1sP9+* z1Hu4kfXZ-1tBzaGW>Al?vYM)1uQBkhc+^J@ytRGz2MwEIR%0`LBlrI?&^Ca$ki&hZwh^HblQuAd zVd(7r8&fl(Ipz;rKc^WSn%5ZGhlC}5Zvbx1!Snuf)CUR7L>8L}4Oqc1NN=V14F2^7LUC&acb_Ykv8fQc>!Y%HQW0+_t{DTaMAVCYxVvFIaGPtzNziPh4 z3bwDed&p2A(FGY&5*SPat%9`ALgv>Ahk`zZUR_qXzyg)|ZFtpU3Mq8pZGqC@_0{I_UlHjr66w!{<@1AG@o4vP-QLv%kj<%mGu>M;e zdzUh9+ZguK^s2N!ghe5&*#%Q*74f2f;sQa>45GTw7x-h72v})w2k>#mwrueK?BvSG zck|B0Y)$8k8)A%)}#%OWFVw6PUD21YT&5rCiBBk09 zGuB1+oQY7PWLP92G5`t%R19^;Y6y00h;}>eL2t=>pFB7im8(jYA#gyf7)sT|GQ$ee zv1f$*RLPH;(<5g*#BxW|Cmw_7vH$JSzcdU*h@Qv4@m6yyvW{-xpJbAXMglpDWImLm^Z_ZC%Z>8QAES_(yUV(N5FT8Y@ zj`=S3%95BQjwQ|OSqGjrG0X5sxg46`6L^|m+2;oRA41>n-TN2dLh?1WX9bpLdygfW z!w%#xg5^n+zJ=0B1l$k4JANtGySA^@XWaZiz@N+VYMg;_&gHhI-tovtxkOw2+P~Y_ z)%#xDS*H&cWs%rrQrAd=P!)$K`w@;enJWv&wkk+yLkIQSz$@JVIW0|}rTD7%fUG;@ z{u{2keD)&U_Ta(LcY9@yxp}3aJV?cBP^;KnIZFEWJ;v%H_p@P86UeG#jDpEKRK%O2 zF4bs5GaI_fQPk*L(k?RQX>UGlFZ3@ytYH!rpc2@_6M%{NAdLu_p+=G~j&RJFd*=m# z<#Em9aUZ$xiKB$jng)Z56M0@WWXN5+;IM`VJP4#oeu_8>7J~gsq=MIS0A>P z|655GhUEjHzBf%_DD&S7rA#>|$Ghx8n@WfWSt(&Bd3S?z$ruhj8&$M|Xrel!YqBx* zc+;RRw)Q62gFDDG&vd^v^v}!9r`_pLXdqD6_(optR1Q7s=ooMqxVJp&Jm}~bX~lp< ztL`Gu6shzFS>sn-1F}u*yUAISu%u^a0*l_S3eo~H1dUD*eb0Zpq;rqNFt#A5eUbzY z_v<}G-wtejhRLJXYEL2nI)B2TLKYDc5gqyfgTkFW>ez>3mIGj!6g5>aR*k7zpR?pi z4Xv1RYHnz@iog?BQ9IF9=vCdx=;N&$4G7Oi6E=;c5ftg-!9DNfeB96cC-9{Ti?allt_@`iDnvwGpM0hI#fU`hEaM38>2OQ_nLDx=OK-r9nZbq~Z}<8rr5oD3F`5py?Dx7+0_+ z!`n^?+xM*8uf{OJc28g*&OwxJoRX$DlX4LoR~6fPjhRaPRkjQ1EA&G^|D*4U!S@OFSdf`%;u}r`q{(U%+LaC>@ z4vD4j^nQ9k)J85A0I3#0E00laK|hVv)vct4Q&sLgU|6b0mvE4r4~YndAOV*!dD!rR z5|4c$b&vcr7xFx(Xohfsr+9cqtpOdgL%0Dy#Y?)7HARQ@t!ZSuXVv5d`UfdRAiI7t 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 literal 50595 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
d8@cH%N(fKE-F?5 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 KSEWh`7W6+1Z4m Date: Mon, 13 Apr 2026 08:59:38 +0000 Subject: [PATCH 17/58] docs(benchmark): add GPT-5.4 Mini and MinerU 2.5 models to benchmark results --- benchmark_results.json | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/benchmark_results.json b/benchmark_results.json index bd1743d..5e33ae1 100644 --- a/benchmark_results.json +++ b/benchmark_results.json @@ -63,6 +63,16 @@ "handwritten": 55.2, "total": 65.9 }, + { + "modelName": "GPT-5.4 Mini", + "modelId": "gpt-5.4-mini-2026-03-17", + "modelUrl": "https://platform.openai.com/docs/models/gpt-5.4-mini", + "iconPath": "_static/img/openai-symbol.svg", + "iconInvertDark": true, + "printed": 72.3, + "handwritten": 46.5, + "total": 58.4 + }, { "modelName": "Gemini 2.5 Flash", "modelId": "gemini-2.5-flash", @@ -389,6 +399,15 @@ "handwritten": 28.3488, "total": 39.1984 }, + { + "modelName": "MinerU 2.5", + "modelId": "opendatalab/MinerU2.5-2509-1.2B", + "modelUrl": "https://huggingface.co/opendatalab/MinerU2.5-2509-1.2B", + "iconPath": null, + "printed": 56.0, + "handwritten": 20.4, + "total": 36.8 + }, { "modelName": "DeepSeek OCR 2", "modelId": "deepseek-ai/DeepSeek-OCR-2", From f22bbef22418eeab5de066643d5228165ed25be6 Mon Sep 17 00:00:00 2001 From: Sina Date: Mon, 13 Apr 2026 08:59:47 +0000 Subject: [PATCH 18/58] feat(benchmark): add reasoning effort option for litellm and openai-compatible backends --- docs/benchmarking.md | 1 + tests/test_tooling_benchmark.py | 76 +++++++++++++++++++++++++++++++ tooling/benchmarking/benchmark.py | 17 +++++++ 3 files changed, 94 insertions(+) diff --git a/docs/benchmarking.md b/docs/benchmarking.md index 701c7f2..2adb57b 100644 --- a/docs/benchmarking.md +++ b/docs/benchmarking.md @@ -27,6 +27,7 @@ The evaluation pipeline strips the default OCR wrapper tag, flattens supported X - `--language` and `--document-type`: filter the benchmark subset before slicing - `--output-dir PATH`: override the default results directory - `--max-concurrency N`: cap the number of in-flight OCR requests +- `--reasoning-effort VALUE`: forward LiteLLM/OpenAI `reasoning_effort` for `litellm` and `openai-compatible` backends ## Output Files diff --git a/tests/test_tooling_benchmark.py b/tests/test_tooling_benchmark.py index 0d6c7d3..5e0ea94 100644 --- a/tests/test_tooling_benchmark.py +++ b/tests/test_tooling_benchmark.py @@ -178,6 +178,23 @@ def test_parse_args_accepts_subset_filters() -> None: assert options.document_type == "print" +def test_parse_args_accepts_reasoning_effort() -> None: + options = benchmark.parse_args( + [ + "--backend", + "litellm", + "--dataset-split", + "dev", + "--model", + "gpt-5.4", + "--reasoning-effort", + "low", + ] + ) + + assert options.reasoning_effort == "low" + + def test_parse_args_rejects_unsupported_backend() -> None: with pytest.raises(SystemExit): benchmark.parse_args( @@ -215,6 +232,44 @@ def test_build_ocr_backend_enables_disk_cache_for_litellm( assert backend.transport.config.completion_kwargs == {"max_tokens": DEFAULT_OCR_MAX_TOKENS} +def test_validate_options_rejects_reasoning_effort_for_hf() -> None: + options = benchmark.BenchmarkOptions( + backend="hf", + dataset_split="dev", + model="example/model", + reasoning_effort="high", + ) + + assert benchmark._validate_options(options) == 1 + + +def test_build_ocr_backend_passes_reasoning_effort_for_litellm( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + cache_dir = tmp_path / "litellm-cache" + + monkeypatch.setattr(benchmark, "_default_litellm_cache_dir", lambda: cache_dir) + + backend = cast( + "LiteLLMVisionOCRBackend", + benchmark._build_ocr_backend( + benchmark.BenchmarkOptions( + backend="litellm", + dataset_split="dev", + model="gpt-5.4", + reasoning_effort="low", + ) + ), + ) + + assert backend.transport.config.cache_dir == cache_dir + assert backend.transport.config.completion_kwargs == { + "max_tokens": DEFAULT_OCR_MAX_TOKENS, + "reasoning_effort": "low", + } + + def test_build_ocr_backend_allows_openai_compatible_without_api_key() -> None: backend = cast( "LiteLLMVisionOCRBackend", @@ -233,6 +288,27 @@ def test_build_ocr_backend_allows_openai_compatible_without_api_key() -> None: assert backend.transport.config.api_key is None +def test_build_ocr_backend_passes_reasoning_effort_for_openai_compatible() -> None: + backend = cast( + "LiteLLMVisionOCRBackend", + benchmark._build_ocr_backend( + benchmark.BenchmarkOptions( + backend="openai-compatible", + dataset_split="dev", + model="gpt-5.4", + base_url="http://127.0.0.1:8000/v1", + reasoning_effort="medium", + ) + ), + ) + + assert backend.provider_name == "openai-compatible" + assert backend.transport.config.completion_kwargs == { + "max_tokens": DEFAULT_OCR_MAX_TOKENS, + "reasoning_effort": "medium", + } + + def test_build_ocr_backend_uses_dots_preset_for_hf() -> None: backend = cast( "HuggingFaceVisionOCRBackend", diff --git a/tooling/benchmarking/benchmark.py b/tooling/benchmarking/benchmark.py index 88ae9e5..f51e462 100644 --- a/tooling/benchmarking/benchmark.py +++ b/tooling/benchmarking/benchmark.py @@ -81,6 +81,7 @@ class BenchmarkOptions: api_key: str | None = None base_url: str | None = None api_version: str | None = None + reasoning_effort: str | None = None def dataset_subset(self) -> DatasetSubset: """Return the normalized subset filters for this benchmark run.""" @@ -114,6 +115,7 @@ def build_parser(*, add_help: bool = True) -> argparse.ArgumentParser: parser.add_argument("--api-key", default=None) parser.add_argument("--base-url", default=None) parser.add_argument("--api-version", default=None) + parser.add_argument("--reasoning-effort", default=None) return parser @@ -134,6 +136,7 @@ def parse_args(argv: list[str] | None = None) -> BenchmarkOptions: api_key=namespace.api_key, base_url=namespace.base_url, api_version=namespace.api_version, + reasoning_effort=namespace.reasoning_effort, ) @@ -147,6 +150,11 @@ def _validate_options(options: BenchmarkOptions) -> int: if options.output_dir is not None and options.output_dir.exists() and not options.output_dir.is_dir(): logger.error("Output path '%s' exists and is not a directory.", options.output_dir) return 1 + if options.reasoning_effort is not None and options.backend not in {"litellm", "openai-compatible"}: + logger.error( + "--reasoning-effort is only supported for backend=litellm and backend=openai-compatible." + ) + return 1 if options.backend == "litellm" and not options.model: logger.error("--model is required for backend=litellm.") return 1 @@ -200,6 +208,13 @@ def _default_litellm_cache_dir() -> Path: return Path(__file__).resolve().parents[2] / "workdir" / "cache" / "litellm" +def _transport_completion_kwargs(options: BenchmarkOptions) -> dict[str, object]: + completion_kwargs: dict[str, object] = {} + if options.reasoning_effort is not None: + completion_kwargs["reasoning_effort"] = options.reasoning_effort + return completion_kwargs + + def _create_progress_bar(*, total: int | None, desc: str, unit: str) -> tqdm[object]: """Return a tqdm progress bar tuned for steadier ETA updates.""" return tqdm( @@ -235,6 +250,7 @@ def _build_ocr_backend(options: BenchmarkOptions) -> OCRBackendLike: api_base=options.base_url, api_key=options.api_key, api_version=options.api_version, + completion_kwargs=_transport_completion_kwargs(options), cache_dir=_default_litellm_cache_dir(), ), ) @@ -250,6 +266,7 @@ def _build_ocr_backend(options: BenchmarkOptions) -> OCRBackendLike: api_base=options.base_url, api_key=options.api_key, api_version=options.api_version, + completion_kwargs=_transport_completion_kwargs(options), ), options=OpenAICompatibleOptions(), ) From 3c47e9879e06732621aeb4435f2bf9761e230cb1 Mon Sep 17 00:00:00 2001 From: Sina Date: Mon, 13 Apr 2026 10:12:25 +0000 Subject: [PATCH 19/58] feat(providers): add Infinity-Parser 7B support --- docs/guides/advanced-customization.md | 1 + docs/guides/providers.md | 4 +- src/churro_ocr/providers/specs.py | 54 ++++++++++++ src/churro_ocr/templates/__init__.py | 8 ++ src/churro_ocr/templates/presets.py | 20 +++++ tests/test_cli.py | 45 ++++++++++ tests/test_hf_ocr.py | 66 +++++++++++++++ tests/test_providers.py | 114 ++++++++++++++++++++++++++ tests/test_tooling_benchmark.py | 50 +++++++++++ 9 files changed, 361 insertions(+), 1 deletion(-) diff --git a/docs/guides/advanced-customization.md b/docs/guides/advanced-customization.md index bb2ae81..6a600cd 100644 --- a/docs/guides/advanced-customization.md +++ b/docs/guides/advanced-customization.md @@ -47,6 +47,7 @@ Use the [templates API](../api/templates.md) for exact signatures. | `DEEPSEEK_OCR_2_OCR_TEMPLATE` | Built-in template for `deepseek-ai/DeepSeek-OCR-2`. | | `DOTS_OCR_1_5_OCR_TEMPLATE` | Built-in template for `kristaller486/dots.ocr-1.5`. | | `DOTS_MOCR_OCR_TEMPLATE` | Built-in template for `rednote-hilab/dots.mocr`. | +| `INFINITY_PARSER_7B_OCR_TEMPLATE` | Built-in markdown-conversion template for `infly/Infinity-Parser-7B`. | | `MINERU2_5_2509_1_2B_OCR_TEMPLATE` | Built-in OCR template for `opendatalab/MinerU2.5-2509-1.2B`. | | `MINERU2_5_2509_1_2B_LAYOUT_TEMPLATE` | MinerU2.5 layout-detection prompt template used by the built-in two-step backends. | | `MINERU2_5_2509_1_2B_TABLE_TEMPLATE` | MinerU2.5 table-recognition prompt template used for block extraction. | diff --git a/docs/guides/providers.md b/docs/guides/providers.md index a55eedc..e99a7b5 100644 --- a/docs/guides/providers.md +++ b/docs/guides/providers.md @@ -59,7 +59,9 @@ backend = build_ocr_backend( ) ``` -Built-in model-specific profiles are resolved automatically for known models such as `stanford-oval/churro-3B`, `datalab-to/chandra-ocr-2`, `deepseek-ai/DeepSeek-OCR-2`, `kristaller486/dots.ocr-1.5`, `rednote-hilab/dots.mocr`, `opendatalab/MinerU2.5-2509-1.2B`, `PaddlePaddle/PaddleOCR-VL-1.5`, `LiquidAI/LFM2.5-VL-1.6B`, and the supported `olmOCR` checkpoints. +Built-in model-specific profiles are resolved automatically for known models such as `stanford-oval/churro-3B`, `datalab-to/chandra-ocr-2`, `deepseek-ai/DeepSeek-OCR-2`, `kristaller486/dots.ocr-1.5`, `rednote-hilab/dots.mocr`, `infly/Infinity-Parser-7B`, `opendatalab/MinerU2.5-2509-1.2B`, `PaddlePaddle/PaddleOCR-VL-1.5`, `LiquidAI/LFM2.5-VL-1.6B`, and the supported `olmOCR` checkpoints. + +For `infly/Infinity-Parser-7B`, the built-in `hf` and `openai-compatible` backends use the documented markdown-conversion prompt and treat the response as markdown or embedded HTML. The OCR result preserves the raw markdown in metadata, and repo-local benchmark evaluation normalizes that markdown or HTML back to plain text before metrics are computed. For `opendatalab/MinerU2.5-2509-1.2B`, the built-in `hf` and `openai-compatible` backends both run the model's two-step layout-plus-block pipeline and return markdown with embedded HTML tables when needed. Repo-local benchmark evaluation normalizes that markdown or HTML back to plain text before metrics are computed. diff --git a/src/churro_ocr/providers/specs.py b/src/churro_ocr/providers/specs.py index 024f1ce..e419e35 100644 --- a/src/churro_ocr/providers/specs.py +++ b/src/churro_ocr/providers/specs.py @@ -17,6 +17,7 @@ parse_chandra_response, parse_olmocr_response, strip_ocr_output_tag, + strip_rich_ocr_markup_to_plain_text, ) from churro_ocr.templates import ( CHANDRA_OCR_2_MODEL_ID, @@ -31,6 +32,10 @@ DOTS_MOCR_OCR_TEMPLATE, DOTS_OCR_1_5_MODEL_ID, DOTS_OCR_1_5_OCR_TEMPLATE, + INFINITY_PARSER_7B_MODEL_ID, + INFINITY_PARSER_7B_OCR_PROMPT, + INFINITY_PARSER_7B_OCR_TEMPLATE, + INFINITY_PARSER_7B_SYSTEM_PROMPT, LFM2_5_VL_1_6B_MODEL_ID, LFM2_5_VL_1_6B_OCR_TEMPLATE, MINERU2_5_2509_1_2B_MODEL_ID, @@ -58,8 +63,11 @@ DEFAULT_OCR_MAX_TOKENS = 20_000 CHANDRA_OCR_MAX_TOKENS = 12_384 DEEPSEEK_OCR_2_MAX_TOKENS = 8_192 +INFINITY_PARSER_7B_MAX_TOKENS = 8_192 OLMOCR_MAX_TOKENS = 8_000 PADDLEOCR_VL_MAX_TOKENS = 4_096 +INFINITY_PARSER_7B_MIN_PIXELS = 256 * 28 * 28 +INFINITY_PARSER_7B_MAX_PIXELS = 2304 * 28 * 28 CHANDRA_MAX_IMAGE_SIZE = (3_072, 2_048) CHANDRA_MIN_IMAGE_SIZE = (1_792, 28) CHANDRA_IMAGE_GRID_SIZE = 28 @@ -193,6 +201,21 @@ def lfm2_5_vl_text_postprocessor(text: str) -> str: return strip_ocr_output_tag(cleaned, output_tag=DEFAULT_OCR_OUTPUT_TAG) +def infinity_parser_7b_text_postprocessor(text: str) -> TextPostprocessorResult: + """Normalize Infinity-Parser markdown output to plain text and preserve raw markdown.""" + cleaned = _strip_leading_chat_scaffold( + text, + prompts=[ + INFINITY_PARSER_7B_OCR_PROMPT, + INFINITY_PARSER_7B_SYSTEM_PROMPT, + ], + ) + raw_markdown = cleaned.strip() + return strip_rich_ocr_markup_to_plain_text(raw_markdown), { + "raw_markdown": raw_markdown, + } + + def deepseek_ocr_2_text_postprocessor(text: str) -> str: """Strip DeepSeek OCR 2 prompt echoes, chat scaffold, and trailing stop tokens.""" cleaned = text.strip() @@ -518,6 +541,33 @@ def paddleocr_vl_1_5_profile() -> OCRModelProfile: ) +def infinity_parser_7b_profile() -> OCRModelProfile: + """Return the built-in ``infly/Infinity-Parser-7B`` OCR profile.""" + return OCRModelProfile( + profile_name=INFINITY_PARSER_7B_MODEL_ID, + template=INFINITY_PARSER_7B_OCR_TEMPLATE, + image_preprocessor=ensure_rgb, + text_postprocessor=infinity_parser_7b_text_postprocessor, + display_name="Infinity-Parser-7B", + transport=LiteLLMTransportConfig( + completion_kwargs={ + "max_tokens": INFINITY_PARSER_7B_MAX_TOKENS, + "temperature": 0.0, + "top_p": 0.95, + } + ), + huggingface=HuggingFaceOptions( + processor_kwargs={ + "min_pixels": INFINITY_PARSER_7B_MIN_PIXELS, + "max_pixels": INFINITY_PARSER_7B_MAX_PIXELS, + }, + generation_kwargs={ + "max_new_tokens": 4_096, + }, + ), + ) + + def mineru2_5_2509_1_2b_profile() -> OCRModelProfile: """Return the built-in ``opendatalab/MinerU2.5-2509-1.2B`` OCR profile.""" return OCRModelProfile( @@ -599,6 +649,7 @@ def _profile_registry() -> dict[str, OCRModelProfile]: deepseek_profile = deepseek_ocr_2_profile() dots_mocr = dots_mocr_profile() dots_profile = dots_ocr_1_5_profile() + infinity_parser_profile = infinity_parser_7b_profile() lfm2_5_vl_profile = lfm2_5_vl_1_6b_profile() mineru2_5_profile = mineru2_5_2509_1_2b_profile() olmocr_profile = olmocr_2_7b_1025_profile() @@ -611,6 +662,7 @@ def _profile_registry() -> dict[str, OCRModelProfile]: deepseek_profile.profile_name: deepseek_profile, dots_mocr.profile_name: dots_mocr, dots_profile.profile_name: dots_profile, + infinity_parser_profile.profile_name: infinity_parser_profile, lfm2_5_vl_profile.profile_name: lfm2_5_vl_profile, mineru2_5_profile.profile_name: mineru2_5_profile, olmocr_profile.profile_name: olmocr_profile, @@ -658,6 +710,8 @@ def resolve_ocr_profile( "default_ocr_profile", "default_ocr_text_postprocessor", "HuggingFaceOptions", + "infinity_parser_7b_profile", + "infinity_parser_7b_text_postprocessor", "identity_text_postprocessor", "lfm2_5_vl_text_postprocessor", "lfm2_5_vl_1_6b_profile", diff --git a/src/churro_ocr/templates/__init__.py b/src/churro_ocr/templates/__init__.py index 16f3fc7..257c3fc 100644 --- a/src/churro_ocr/templates/__init__.py +++ b/src/churro_ocr/templates/__init__.py @@ -23,6 +23,10 @@ DOTS_OCR_1_5_MODEL_ID, DOTS_OCR_1_5_OCR_PROMPT, DOTS_OCR_1_5_OCR_TEMPLATE, + INFINITY_PARSER_7B_MODEL_ID, + INFINITY_PARSER_7B_OCR_PROMPT, + INFINITY_PARSER_7B_OCR_TEMPLATE, + INFINITY_PARSER_7B_SYSTEM_PROMPT, LFM2_5_VL_1_6B_MODEL_ID, LFM2_5_VL_1_6B_OCR_TEMPLATE, MINERU2_5_2509_1_2B_FORMULA_PROMPT, @@ -61,6 +65,10 @@ "DOTS_OCR_1_5_MODEL_ID", "DOTS_OCR_1_5_OCR_PROMPT", "DOTS_OCR_1_5_OCR_TEMPLATE", + "INFINITY_PARSER_7B_MODEL_ID", + "INFINITY_PARSER_7B_OCR_PROMPT", + "INFINITY_PARSER_7B_OCR_TEMPLATE", + "INFINITY_PARSER_7B_SYSTEM_PROMPT", "MINERU2_5_2509_1_2B_FORMULA_PROMPT", "MINERU2_5_2509_1_2B_FORMULA_TEMPLATE", "MINERU2_5_2509_1_2B_IMAGE_ANALYSIS_PROMPT", diff --git a/src/churro_ocr/templates/presets.py b/src/churro_ocr/templates/presets.py index 4ecccee..970a4f5 100644 --- a/src/churro_ocr/templates/presets.py +++ b/src/churro_ocr/templates/presets.py @@ -15,6 +15,7 @@ DEEPSEEK_OCR_2_MODEL_ID = "deepseek-ai/DeepSeek-OCR-2" DOTS_OCR_1_5_MODEL_ID = "kristaller486/dots.ocr-1.5" DOTS_MOCR_MODEL_ID = "rednote-hilab/dots.mocr" +INFINITY_PARSER_7B_MODEL_ID = "infly/Infinity-Parser-7B" MINERU2_5_2509_1_2B_MODEL_ID = "opendatalab/MinerU2.5-2509-1.2B" PADDLEOCR_VL_1_5_MODEL_ID = "PaddlePaddle/PaddleOCR-VL-1.5" OLMOCR_2_7B_1025_MODEL_ID = "allenai/olmOCR-2-7B-1025" @@ -48,6 +49,21 @@ system_message=None, user_prompt=DOTS_MOCR_OCR_PROMPT, ) +INFINITY_PARSER_7B_SYSTEM_PROMPT = "You are a helpful assistant." +INFINITY_PARSER_7B_OCR_PROMPT = ( + "Convert this document page to Markdown.\n" + "- Transcribe all visible text accurately without guessing.\n" + "- Preserve the reading order and the document structure, including headings, paragraphs, and lists.\n" + "- Convert mathematical expressions to LaTeX, using \\(...\\) for inline math and " + "\\[...\\] for display math.\n" + "- Convert tables to HTML wrapped in ...
.\n" + "- Ignore figures and other purely graphical content instead of describing them.\n" + "- Return only the converted Markdown with no extra commentary." +) +INFINITY_PARSER_7B_OCR_TEMPLATE = HFChatTemplate( + system_message=INFINITY_PARSER_7B_SYSTEM_PROMPT, + user_prompt=INFINITY_PARSER_7B_OCR_PROMPT, +) MINERU2_5_2509_1_2B_SYSTEM_PROMPT = "You are a helpful assistant." MINERU2_5_2509_1_2B_LAYOUT_PROMPT = "\nLayout Detection:" MINERU2_5_2509_1_2B_TABLE_PROMPT = "\nTable Recognition:" @@ -105,6 +121,10 @@ "DOTS_OCR_1_5_MODEL_ID", "DOTS_OCR_1_5_OCR_PROMPT", "DOTS_OCR_1_5_OCR_TEMPLATE", + "INFINITY_PARSER_7B_MODEL_ID", + "INFINITY_PARSER_7B_OCR_PROMPT", + "INFINITY_PARSER_7B_OCR_TEMPLATE", + "INFINITY_PARSER_7B_SYSTEM_PROMPT", "MINERU2_5_2509_1_2B_FORMULA_PROMPT", "MINERU2_5_2509_1_2B_FORMULA_TEMPLATE", "MINERU2_5_2509_1_2B_IMAGE_ANALYSIS_PROMPT", diff --git a/tests/test_cli.py b/tests/test_cli.py index eb88e0c..343cc66 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -25,6 +25,8 @@ DEFAULT_OCR_TEMPLATE, DOTS_MOCR_OCR_TEMPLATE, DOTS_OCR_1_5_OCR_TEMPLATE, + INFINITY_PARSER_7B_MODEL_ID, + INFINITY_PARSER_7B_OCR_TEMPLATE, MINERU2_5_2509_1_2B_MODEL_ID, MINERU2_5_2509_1_2B_OCR_TEMPLATE, OLMOCR_2_7B_1025_MODEL_ID, @@ -363,6 +365,49 @@ def test_build_ocr_backend_aligns_templates_for_mineru2_5() -> None: assert openai_backend.transport.config.completion_kwargs == {} +def test_build_ocr_backend_aligns_templates_for_infinity_parser() -> None: + litellm_backend = cli_module._build_ocr_backend( + backend="litellm", + model=INFINITY_PARSER_7B_MODEL_ID, + endpoint=None, + api_key=None, + base_url=None, + api_version=None, + ) + hf_backend = cli_module._build_ocr_backend( + backend="hf", + model=INFINITY_PARSER_7B_MODEL_ID, + endpoint=None, + api_key=None, + base_url=None, + api_version=None, + ) + openai_backend = cli_module._build_ocr_backend( + backend="openai-compatible", + model=INFINITY_PARSER_7B_MODEL_ID, + endpoint=None, + api_key=None, + base_url="http://127.0.0.1:8000/v1", + api_version=None, + ) + + assert litellm_backend.template == INFINITY_PARSER_7B_OCR_TEMPLATE + assert litellm_backend.template == hf_backend.template == openai_backend.template + assert litellm_backend.model_name == "Infinity-Parser-7B" + assert hf_backend.model_name == "Infinity-Parser-7B" + assert openai_backend.model_name == "Infinity-Parser-7B" + assert litellm_backend.transport.config.completion_kwargs == { + "max_tokens": 8_192, + "temperature": 0.0, + "top_p": 0.95, + } + assert openai_backend.transport.config.completion_kwargs == { + "max_tokens": 8_192, + "temperature": 0.0, + "top_p": 0.95, + } + + def test_build_ocr_backend_rejects_mineru2_5_for_litellm() -> None: with pytest.raises(ConfigurationError, match="MinerU2.5 requires the built-in two-step pipeline"): cli_module._build_ocr_backend( diff --git a/tests/test_hf_ocr.py b/tests/test_hf_ocr.py index 9bd5731..d5691d1 100644 --- a/tests/test_hf_ocr.py +++ b/tests/test_hf_ocr.py @@ -39,6 +39,7 @@ from churro_ocr.providers.specs import ( DEFAULT_OCR_MAX_TOKENS, deepseek_ocr_2_text_postprocessor, + infinity_parser_7b_text_postprocessor, lfm2_5_vl_text_postprocessor, ) from churro_ocr.templates import ( @@ -54,6 +55,10 @@ DOTS_OCR_1_5_MODEL_ID, DOTS_OCR_1_5_OCR_PROMPT, DOTS_OCR_1_5_OCR_TEMPLATE, + INFINITY_PARSER_7B_MODEL_ID, + INFINITY_PARSER_7B_OCR_PROMPT, + INFINITY_PARSER_7B_OCR_TEMPLATE, + INFINITY_PARSER_7B_SYSTEM_PROMPT, LFM2_5_VL_1_6B_MODEL_ID, LFM2_5_VL_1_6B_OCR_TEMPLATE, MINERU2_5_2509_1_2B_FORMULA_PROMPT, @@ -124,6 +129,18 @@ def test_deepseek_ocr_2_template_builds_image_before_prompt() -> None: assert conversation[0]["content"][1]["text"] == DEEPSEEK_OCR_2_OCR_PROMPT +def test_infinity_parser_template_matches_documented_prompt_shape() -> None: + page = DocumentPage.from_image(Image.new("RGB", (20, 20), color="white")) + + conversation = INFINITY_PARSER_7B_OCR_TEMPLATE.build_conversation(page) + + assert conversation[0]["role"] == "system" + assert conversation[0]["content"][0]["text"] == INFINITY_PARSER_7B_SYSTEM_PROMPT + assert conversation[1]["role"] == "user" + assert conversation[1]["content"][0]["type"] == "image" + assert conversation[1]["content"][1]["text"] == INFINITY_PARSER_7B_OCR_PROMPT + + def test_mineru2_5_template_matches_upstream_prompt_shape() -> None: page = DocumentPage.from_image(Image.new("RGB", (20, 20), color="white")) @@ -260,6 +277,27 @@ def test_lfm25_text_postprocessor_strips_role_only_prefix() -> None: assert lfm2_5_vl_text_postprocessor("assistant:\nplain text") == "plain text" +def test_infinity_parser_text_postprocessor_strips_prompt_echo_and_preserves_raw_markdown() -> None: + processed = infinity_parser_7b_text_postprocessor( + f"{INFINITY_PARSER_7B_OCR_PROMPT}\n" + "assistant:\n" + "# Heading\n\n" + "
YearValue
190042
\n\n" + "Paragraph with [note](https://example.test).\n" + ) + assert isinstance(processed, tuple) + text, metadata = processed + + assert text == "Heading\n\nYear | Value\n1900 | 42\n\nParagraph with note." + assert metadata == { + "raw_markdown": ( + "# Heading\n\n" + "
YearValue
190042
\n\n" + "Paragraph with [note](https://example.test)." + ), + } + + def test_deepseek_ocr_2_text_postprocessor_strips_prompt_echo_and_stop_token() -> None: assert ( deepseek_ocr_2_text_postprocessor( @@ -333,6 +371,34 @@ def test_build_ocr_backend_uses_olmocr_profile_defaults_for_hf() -> None: assert backend.image_preprocessor(Image.new("RGB", (5_000, 3_000), color="white")).size == (1_288, 772) +def test_build_ocr_backend_uses_infinity_parser_profile_defaults_for_hf() -> None: + backend = cast( + "HuggingFaceVisionOCRBackend", + build_ocr_backend( + OCRBackendSpec( + provider="hf", + model=INFINITY_PARSER_7B_MODEL_ID, + ) + ), + ) + + assert type(backend) is HuggingFaceVisionOCRBackend + assert backend.template == INFINITY_PARSER_7B_OCR_TEMPLATE + assert backend.model_name == "Infinity-Parser-7B" + assert backend.generation_kwargs == { + "max_new_tokens": 4_096, + } + assert backend.processor_kwargs == { + "min_pixels": 200_704, + "max_pixels": 1_806_336, + } + assert backend.trust_remote_code is False + assert backend.model_kwargs == {} + preprocessed_image = backend.image_preprocessor(Image.new("RGBA", (32, 16), color=(255, 255, 255, 255))) + assert preprocessed_image.size == (32, 16) + assert preprocessed_image.mode == "RGB" + + def test_build_ocr_backend_uses_lfm25_profile_defaults_for_hf() -> None: backend = cast( "LFM25VLOCRBackend", diff --git a/tests/test_providers.py b/tests/test_providers.py index eadc8a6..35fee85 100644 --- a/tests/test_providers.py +++ b/tests/test_providers.py @@ -54,6 +54,10 @@ DEEPSEEK_OCR_2_OCR_TEMPLATE, DEFAULT_OCR_TEMPLATE, DOTS_MOCR_OCR_TEMPLATE, + INFINITY_PARSER_7B_MODEL_ID, + INFINITY_PARSER_7B_OCR_PROMPT, + INFINITY_PARSER_7B_OCR_TEMPLATE, + INFINITY_PARSER_7B_SYSTEM_PROMPT, MINERU2_5_2509_1_2B_MODEL_ID, MINERU2_5_2509_1_2B_OCR_PROMPT, MINERU2_5_2509_1_2B_OCR_TEMPLATE, @@ -782,6 +786,28 @@ def test_build_ocr_backend_resolves_olmocr_fp8_profile_defaults_for_openai_compa } +def test_build_ocr_backend_uses_infinity_parser_profile_defaults_for_openai_compatible() -> None: + backend = cast( + "OpenAICompatibleOCRBackend", + build_ocr_backend( + OCRBackendSpec( + provider="openai-compatible", + model=INFINITY_PARSER_7B_MODEL_ID, + transport=LiteLLMTransportConfig(api_base="http://127.0.0.1:8000/v1"), + ) + ), + ) + + assert type(backend) is OpenAICompatibleOCRBackend + assert backend.template == INFINITY_PARSER_7B_OCR_TEMPLATE + assert backend.model_name == "Infinity-Parser-7B" + assert backend.transport.config.completion_kwargs == { + "max_tokens": 8_192, + "temperature": 0.0, + "top_p": 0.95, + } + + def test_build_ocr_backend_uses_paddleocr_vl_profile_defaults_for_openai_compatible() -> None: backend = cast( "OpenAICompatibleOCRBackend", @@ -1031,6 +1057,94 @@ async def _fake_complete_text( assert prompt_image.mode == "RGB" +@pytest.mark.asyncio +async def test_openai_compatible_backend_uses_infinity_parser_prompt_and_markdown_postprocessing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured: dict[str, object] = {} + + def _fake_prepare_messages_from_conversation( + self: LiteLLMTransport, + conversation: list[dict[str, object]], + ) -> list[dict[str, object]]: + captured["conversation"] = conversation + captured["completion_kwargs"] = dict(self.config.completion_kwargs) + return [{"role": "user", "content": [{"type": "text", "text": "prompt"}]}] + + async def _fake_complete_text( + self: LiteLLMTransport, + *, + model: str, + messages: list[dict[str, object]], + timeout_seconds: int = 600, + output_json: bool = False, + allow_empty: bool = False, + ) -> str: + captured["model"] = model + captured["messages"] = messages + captured["timeout_seconds"] = timeout_seconds + captured["output_json"] = output_json + captured["allow_empty"] = allow_empty + captured["completion_kwargs"] = dict(self.config.completion_kwargs) + return ( + f"{INFINITY_PARSER_7B_OCR_PROMPT}\n" + "assistant:\n" + "# Ledger\n\n" + "" + "
YearValue
190042
\n\n" + "Paragraph with [note](https://example.test).\n" + ) + + monkeypatch.setattr( + LiteLLMTransport, + "prepare_messages_from_conversation", + _fake_prepare_messages_from_conversation, + ) + monkeypatch.setattr(LiteLLMTransport, "complete_text", _fake_complete_text) + + backend = cast( + "OpenAICompatibleOCRBackend", + build_ocr_backend( + OCRBackendSpec( + provider="openai-compatible", + model=INFINITY_PARSER_7B_MODEL_ID, + transport=LiteLLMTransportConfig(api_base="http://127.0.0.1:8000/v1"), + ) + ), + ) + result = await backend.ocr( + DocumentPage.from_image(Image.new("RGBA", (32, 16), color=(255, 255, 255, 255))) + ) + + assert result.text == "Ledger\n\nYear | Value\n1900 | 42\n\nParagraph with note." + assert result.metadata == { + "raw_markdown": ( + "# Ledger\n\n" + "
YearValue
190042
\n\n" + "Paragraph with [note](https://example.test)." + ), + } + assert captured["model"] == f"openai/{INFINITY_PARSER_7B_MODEL_ID}" + assert captured["messages"] == [{"role": "user", "content": [{"type": "text", "text": "prompt"}]}] + assert captured["timeout_seconds"] == 600 + assert captured["output_json"] is False + assert captured["allow_empty"] is True + assert captured["completion_kwargs"] == { + "max_tokens": 8_192, + "temperature": 0.0, + "top_p": 0.95, + } + conversation = cast("list[dict[str, object]]", captured["conversation"]) + assert conversation[0]["role"] == "system" + assert conversation[0]["content"] == [{"type": "text", "text": INFINITY_PARSER_7B_SYSTEM_PROMPT}] + user_content = cast("list[dict[str, object]]", conversation[1]["content"]) + assert user_content[0]["type"] == "image" + assert user_content[1] == {"type": "text", "text": INFINITY_PARSER_7B_OCR_PROMPT} + prompt_image = cast("Image.Image", user_content[0]["image"]) + assert prompt_image.size == (32, 16) + assert prompt_image.mode == "RGB" + + @pytest.mark.asyncio async def test_openai_compatible_backend_uses_chandra_prompt_and_plain_text_postprocessing( monkeypatch: pytest.MonkeyPatch, diff --git a/tests/test_tooling_benchmark.py b/tests/test_tooling_benchmark.py index 5e0ea94..b13638f 100644 --- a/tests/test_tooling_benchmark.py +++ b/tests/test_tooling_benchmark.py @@ -17,6 +17,7 @@ DEEPSEEK_OCR_2_OCR_TEMPLATE, DOTS_MOCR_OCR_TEMPLATE, DOTS_OCR_1_5_OCR_TEMPLATE, + INFINITY_PARSER_7B_OCR_TEMPLATE, MINERU2_5_2509_1_2B_OCR_TEMPLATE, PADDLEOCR_VL_1_5_OCR_TEMPLATE, ) @@ -488,6 +489,55 @@ def test_build_ocr_backend_uses_paddleocr_vl_preset_for_openai_compatible() -> N } +def test_build_ocr_backend_uses_infinity_parser_preset_for_hf() -> None: + backend = cast( + "HuggingFaceVisionOCRBackend", + benchmark._build_ocr_backend( + benchmark.BenchmarkOptions( + backend="hf", + dataset_split="dev", + model="infly/Infinity-Parser-7B", + ) + ), + ) + + assert type(backend) is HuggingFaceVisionOCRBackend + assert backend.template == INFINITY_PARSER_7B_OCR_TEMPLATE + assert backend.model_name == "Infinity-Parser-7B" + assert backend.processor_kwargs == { + "min_pixels": 200_704, + "max_pixels": 1_806_336, + } + assert backend.trust_remote_code is False + assert backend.model_kwargs == {"device_map": "auto", "torch_dtype": "auto"} + assert backend.generation_kwargs == { + "max_new_tokens": 4_096, + } + + +def test_build_ocr_backend_uses_infinity_parser_preset_for_openai_compatible() -> None: + backend = cast( + "LiteLLMVisionOCRBackend", + benchmark._build_ocr_backend( + benchmark.BenchmarkOptions( + backend="openai-compatible", + dataset_split="dev", + model="infly/Infinity-Parser-7B", + base_url="http://127.0.0.1:8000/v1", + ) + ), + ) + + assert backend.provider_name == "openai-compatible" + assert backend.model_name == "Infinity-Parser-7B" + assert backend.template == INFINITY_PARSER_7B_OCR_TEMPLATE + assert backend.transport.config.completion_kwargs == { + "max_tokens": 8_192, + "temperature": 0.0, + "top_p": 0.95, + } + + def test_build_ocr_backend_uses_mineru2_5_preset_for_hf() -> None: backend = cast( "HuggingFaceVisionOCRBackend", From 27cc7c59f92cd05f61f981e11ebcd59ce6622ad9 Mon Sep 17 00:00:00 2001 From: Sina Date: Mon, 13 Apr 2026 10:12:57 +0000 Subject: [PATCH 20/58] fix(benchmark): handle timed-out OCR pages gracefully --- src/churro_ocr/_internal/litellm.py | 10 ++- tests/test_internal_helpers.py | 33 ++++++++ tests/test_tooling_benchmark.py | 127 ++++++++++++++++++++++++++++ tooling/benchmarking/benchmark.py | 93 ++++++++++++++------ 4 files changed, 236 insertions(+), 27 deletions(-) diff --git a/src/churro_ocr/_internal/litellm.py b/src/churro_ocr/_internal/litellm.py index 02b41d9..733d995 100644 --- a/src/churro_ocr/_internal/litellm.py +++ b/src/churro_ocr/_internal/litellm.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio import logging from collections.abc import Sequence from contextlib import suppress @@ -161,7 +162,7 @@ async def complete_text( *, model: str, messages: list[dict[str, Any]], - timeout_seconds: int = 600, + timeout_seconds: float = 600, output_json: bool = False, allow_empty: bool = False, ) -> str: @@ -196,7 +197,10 @@ async def _run_completion() -> Any: f"LiteLLM request exceeded the total timeout of {timeout_seconds} seconds." ) attempt_kwargs["timeout"] = remaining_timeout_seconds - return await acompletion(**attempt_kwargs) + return await asyncio.wait_for( + acompletion(**attempt_kwargs), + timeout=remaining_timeout_seconds, + ) try: response = await retry_api_call( @@ -351,7 +355,7 @@ async def complete_text( api_base: str | None = None, api_key: str | None = None, api_version: str | None = None, - timeout_seconds: int = 600, + timeout_seconds: float = 600, output_json: bool = False, allow_empty: bool = False, completion_kwargs: dict[str, object] | None = None, diff --git a/tests/test_internal_helpers.py b/tests/test_internal_helpers.py index d7c6a82..447d298 100644 --- a/tests/test_internal_helpers.py +++ b/tests/test_internal_helpers.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import re import sys from base64 import b64encode @@ -443,6 +444,38 @@ async def _fake_retry_api_call( } +@pytest.mark.asyncio +async def test_transport_complete_text_enforces_wall_clock_timeout_budget( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls = {"acompletion": 0} + + async def _hanging_acompletion(**kwargs: object) -> object: + calls["acompletion"] += 1 + await asyncio.sleep(float(cast(float, kwargs["timeout"])) * 10) + return SimpleNamespace( + choices=[SimpleNamespace(message=SimpleNamespace(content="late"))], + _hidden_params={}, + ) + + fake_module = _make_fake_litellm_module(acompletion=_hanging_acompletion) + monkeypatch.setitem(sys.modules, "litellm", fake_module) + monkeypatch.setattr(litellm_module, "_INITIALIZED", False) + + transport = LiteLLMTransport() + with pytest.raises( + ProviderError, + match="LiteLLM request failed for model 'example/model':", + ): + await transport.complete_text( + model="example/model", + messages=[{"role": "user", "content": [{"type": "text", "text": "hello"}]}], + timeout_seconds=0.01, + ) + + assert calls == {"acompletion": 1} + + @pytest.mark.asyncio async def test_transport_complete_text_rejects_empty_output(monkeypatch: pytest.MonkeyPatch) -> None: async def _empty_acompletion(**_: object) -> object: diff --git a/tests/test_tooling_benchmark.py b/tests/test_tooling_benchmark.py index b13638f..06c6ef1 100644 --- a/tests/test_tooling_benchmark.py +++ b/tests/test_tooling_benchmark.py @@ -1109,3 +1109,130 @@ async def ocr(self, page): # noqa: ANN001 {"text": "page-2", "metadata": {"page_width": 2}}, ] assert logged_messages == ["First benchmark OCR output for backend=azure model=:\npage-3"] + + +@pytest.mark.asyncio +async def test_predict_texts_continues_after_non_batch_page_failure(monkeypatch) -> None: + dataset: list[BenchmarkDatasetExample] = [ + _benchmark_example("0", size=(3, 3), transcription="alpha"), + _benchmark_example("1", size=(1, 1), transcription="beta"), + _benchmark_example("2", size=(2, 2), transcription="gamma"), + ] + logged_messages: list[str] = [] + + class FakeLogger: + def info(self, _message: str, *args: object) -> None: # noqa: ANN001 + return None + + def exception(self, message: str, *args: object) -> None: + logged_messages.append(message % args if args else message) + + class FakeOCRBackend: + async def ocr(self, page): # noqa: ANN001 + if page.width == 1: + raise RuntimeError("timed out") + return OCRResult( + text=f"page-{page.width}", + provider_name="fake", + model_name="fake-model", + metadata={"page_width": page.width}, + ) + + monkeypatch.setattr(benchmark, "logger", FakeLogger()) + monkeypatch.setattr(benchmark, "_build_ocr_backend", lambda _: FakeOCRBackend()) + + options = benchmark.BenchmarkOptions( + backend="azure", + dataset_split="dev", + endpoint="https://example.invalid", + api_key="secret", + max_concurrency=2, + ) + + evaluation_examples, predictions = await benchmark._predict_texts( + dataset, + options, + total_pages=3, + ) + + assert evaluation_examples == [benchmark._build_evaluation_example(example) for example in dataset] + assert predictions == [ + {"text": "page-3", "metadata": {"page_width": 3}}, + { + "text": "", + "metadata": {"benchmark_error": {"type": "RuntimeError", "message": "timed out"}}, + }, + {"text": "page-2", "metadata": {"page_width": 2}}, + ] + assert logged_messages == [ + "Benchmark OCR failed for example_id=1 dataset_id=dataset-1 backend=azure model=; " + "treating prediction as empty." + ] + + +@pytest.mark.asyncio +async def test_predict_texts_continues_after_batch_failure(monkeypatch) -> None: + dataset: list[BenchmarkDatasetExample] = [ + _benchmark_example("0", size=(3, 3), transcription="alpha"), + _benchmark_example("1", size=(1, 1), transcription="beta"), + _benchmark_example("2", size=(2, 2), transcription="gamma"), + ] + logged_messages: list[str] = [] + call_count = {"ocr_batch": 0} + + class FakeLogger: + def info(self, _message: str, *args: object) -> None: # noqa: ANN001 + return None + + def exception(self, message: str, *args: object) -> None: + logged_messages.append(message % args if args else message) + + class FakeBatchBackend: + async def ocr_batch(self, pages): # noqa: ANN001 + call_count["ocr_batch"] += 1 + if call_count["ocr_batch"] == 1: + raise RuntimeError("batch timed out") + return [ + OCRResult( + text=f"page-{page.width}", + provider_name="fake", + model_name="fake-model", + metadata={"page_width": page.width}, + ) + for page in pages + ] + + monkeypatch.setattr(benchmark, "logger", FakeLogger()) + monkeypatch.setattr(benchmark, "_build_ocr_backend", lambda _: FakeBatchBackend()) + + options = benchmark.BenchmarkOptions( + backend="hf", + dataset_split="dev", + model="kristaller486/dots.ocr-1.5", + max_concurrency=2, + ) + + evaluation_examples, predictions = await benchmark._predict_texts( + dataset, + options, + total_pages=3, + ) + + assert evaluation_examples == [benchmark._build_evaluation_example(example) for example in dataset] + assert predictions == [ + { + "text": "", + "metadata": {"benchmark_error": {"type": "RuntimeError", "message": "batch timed out"}}, + }, + { + "text": "", + "metadata": {"benchmark_error": {"type": "RuntimeError", "message": "batch timed out"}}, + }, + {"text": "page-2", "metadata": {"page_width": 2}}, + ] + assert logged_messages == [ + "Benchmark OCR failed for example_id=0 dataset_id=dataset-0 backend=hf " + "model=kristaller486/dots.ocr-1.5; treating prediction as empty.", + "Benchmark OCR failed for example_id=1 dataset_id=dataset-1 backend=hf " + "model=kristaller486/dots.ocr-1.5; treating prediction as empty.", + ] diff --git a/tooling/benchmarking/benchmark.py b/tooling/benchmarking/benchmark.py index f51e462..5e426fa 100644 --- a/tooling/benchmarking/benchmark.py +++ b/tooling/benchmarking/benchmark.py @@ -315,6 +315,41 @@ def _log_first_benchmark_output(*, options: BenchmarkOptions, text: str) -> None ) +def _failure_metadata(exc: BaseException) -> dict[str, Any]: + message = str(exc).strip() + metadata: dict[str, Any] = { + "benchmark_error": { + "type": type(exc).__name__, + } + } + if message: + metadata["benchmark_error"]["message"] = message + return metadata + + +def _empty_prediction_for_failure(exc: BaseException) -> BenchmarkPrediction: + return { + "text": "", + "metadata": _failure_metadata(exc), + } + + +def _log_prediction_failure( + *, + options: BenchmarkOptions, + example: BenchmarkDatasetExample, + exc: BaseException, +) -> None: + logger.exception( + "Benchmark OCR failed for example_id=%s dataset_id=%s backend=%s model=%s; " + "treating prediction as empty.", + example["example_id"], + example["dataset_id"], + options.backend, + options.model or "", + ) + + async def _predict_texts( dataset: Iterable[BenchmarkDatasetExample], options: BenchmarkOptions, @@ -350,35 +385,45 @@ async def _predict_texts( break progress.set_postfix(submitted=submitted_pages, in_flight=len(pages), refresh=False) - batch_results = await ocr_backend.ocr_batch(pages) - assert len(batch_results) == len(pages), ( - f"HF OCR batch returned {len(batch_results)} results for {len(pages)} pages." - ) - if not has_logged_first_output and batch_results: - _log_first_benchmark_output( - options=options, - text=batch_results[0].text or "", + try: + batch_results = await ocr_backend.ocr_batch(pages) + assert len(batch_results) == len(pages), ( + f"HF OCR batch returned {len(batch_results)} results for {len(pages)} pages." ) - has_logged_first_output = True - predictions.extend( - { - "text": result.text or "", - "metadata": dict(result.metadata), - } - for result in batch_results - ) - progress.update(len(batch_results)) + if not has_logged_first_output and batch_results: + _log_first_benchmark_output( + options=options, + text=batch_results[0].text or "", + ) + has_logged_first_output = True + predictions.extend( + { + "text": result.text or "", + "metadata": dict(result.metadata), + } + for result in batch_results + ) + except Exception as exc: + for example in batch_examples: + _log_prediction_failure(options=options, example=example, exc=exc) + predictions.extend(_empty_prediction_for_failure(exc) for _ in pages) + progress.update(len(pages)) progress.set_postfix(submitted=submitted_pages, in_flight=0, refresh=False) return evaluation_examples, predictions - async def _predict(index: int, image: Image.Image) -> tuple[int, BenchmarkPrediction]: + async def _predict(index: int, example: BenchmarkDatasetExample) -> tuple[int, BenchmarkPrediction]: + image = example["image"] page = DocumentPage(page_index=index, source_index=0, image=image) - if callable(ocr_backend) and not isinstance(ocr_backend, OCRBackend): - result = await ocr_backend(page) - else: - assert isinstance(ocr_backend, OCRBackend) - result = await ocr_backend.ocr(page) + try: + if callable(ocr_backend) and not isinstance(ocr_backend, OCRBackend): + result = await ocr_backend(page) + else: + assert isinstance(ocr_backend, OCRBackend) + result = await ocr_backend.ocr(page) + except Exception as exc: + _log_prediction_failure(options=options, example=example, exc=exc) + return index, _empty_prediction_for_failure(exc) return index, { "text": result.text or "", "metadata": dict(result.metadata), @@ -424,7 +469,7 @@ def _progress_heartbeat(progress: tqdm[object], stop_event: threading.Event) -> assert isinstance(image, Image.Image) evaluation_examples.append(_build_evaluation_example(example)) predictions.append({"text": "", "metadata": {}}) - pending_tasks.add(asyncio.create_task(_predict(next_index, image))) + pending_tasks.add(asyncio.create_task(_predict(next_index, example))) next_index += 1 _update_progress_status(progress) From 768270b1d56c46fdc403e38c4d754bb485879d47 Mon Sep 17 00:00:00 2001 From: Sina Date: Mon, 13 Apr 2026 10:13:04 +0000 Subject: [PATCH 21/58] chore(leaderboard): add GPT-5.4 Nano entry --- benchmark_results.json | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/benchmark_results.json b/benchmark_results.json index 5e33ae1..a21e1b9 100644 --- a/benchmark_results.json +++ b/benchmark_results.json @@ -54,7 +54,7 @@ "total": 67.1 }, { - "modelName": "GPT-5.4", + "modelName": "GPT-5.4 (reasoning_effort=none)", "modelId": "gpt-5.4-2026-03-05", "modelUrl": "https://platform.openai.com/docs/models/gpt-5.4", "iconPath": "_static/img/openai-symbol.svg", @@ -64,7 +64,7 @@ "total": 65.9 }, { - "modelName": "GPT-5.4 Mini", + "modelName": "GPT-5.4 Mini (reasoning_effort=none)", "modelId": "gpt-5.4-mini-2026-03-17", "modelUrl": "https://platform.openai.com/docs/models/gpt-5.4-mini", "iconPath": "_static/img/openai-symbol.svg", @@ -73,6 +73,16 @@ "handwritten": 46.5, "total": 58.4 }, + { + "modelName": "GPT-5.4 Nano (reasoning_effort=none)", + "modelId": "gpt-5.4-nano-2026-03-17", + "modelUrl": "https://platform.openai.com/docs/models/gpt-5.4-nano", + "iconPath": "_static/img/openai-symbol.svg", + "iconInvertDark": true, + "printed": 55.7, + "handwritten": 29.7, + "total": 41.7 + }, { "modelName": "Gemini 2.5 Flash", "modelId": "gemini-2.5-flash", From f172755f7d615dc897bf75621aeb2c0b11cc7de0 Mon Sep 17 00:00:00 2001 From: Sina Date: Mon, 13 Apr 2026 15:48:47 +0000 Subject: [PATCH 22/58] docs(benchmark): update GPT-5.4 model entries with reasoning=medium --- benchmark_results.json | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/benchmark_results.json b/benchmark_results.json index a21e1b9..0e61692 100644 --- a/benchmark_results.json +++ b/benchmark_results.json @@ -54,34 +54,34 @@ "total": 67.1 }, { - "modelName": "GPT-5.4 (reasoning_effort=none)", + "modelName": "GPT-5.4", "modelId": "gpt-5.4-2026-03-05", "modelUrl": "https://platform.openai.com/docs/models/gpt-5.4", "iconPath": "_static/img/openai-symbol.svg", "iconInvertDark": true, - "printed": 78.4, - "handwritten": 55.2, - "total": 65.9 + "printed": 75.8, + "handwritten": 52.8, + "total": 63.4 }, { - "modelName": "GPT-5.4 Mini (reasoning_effort=none)", + "modelName": "GPT-5.4 Mini", "modelId": "gpt-5.4-mini-2026-03-17", "modelUrl": "https://platform.openai.com/docs/models/gpt-5.4-mini", "iconPath": "_static/img/openai-symbol.svg", "iconInvertDark": true, - "printed": 72.3, - "handwritten": 46.5, - "total": 58.4 + "printed": 70.2, + "handwritten": 39.8, + "total": 53.8 }, { - "modelName": "GPT-5.4 Nano (reasoning_effort=none)", + "modelName": "GPT-5.4 Nano", "modelId": "gpt-5.4-nano-2026-03-17", "modelUrl": "https://platform.openai.com/docs/models/gpt-5.4-nano", "iconPath": "_static/img/openai-symbol.svg", "iconInvertDark": true, - "printed": 55.7, - "handwritten": 29.7, - "total": 41.7 + "printed": 56.8, + "handwritten": 27.9, + "total": 41.2 }, { "modelName": "Gemini 2.5 Flash", From 9a296a5c3de99e841efdf78a6175d7fd4dc5b897 Mon Sep 17 00:00:00 2001 From: Sina Date: Mon, 13 Apr 2026 17:34:36 +0000 Subject: [PATCH 23/58] feat(benchmark): add Infinity Parser 7B model with logo --- benchmark_results.json | 9 +++++++++ docs/_static/img/infly-logo.png | Bin 0 -> 31461 bytes 2 files changed, 9 insertions(+) create mode 100644 docs/_static/img/infly-logo.png diff --git a/benchmark_results.json b/benchmark_results.json index 0e61692..a67f345 100644 --- a/benchmark_results.json +++ b/benchmark_results.json @@ -203,6 +203,15 @@ "handwritten": 43.1781, "total": 55.4205 }, + { + "modelName": "Infinity Parser", + "modelId": "infly/Infinity-Parser-7B", + "modelUrl": "https://huggingface.co/infly/Infinity-Parser-7B", + "iconPath": "_static/img/infly-logo.png", + "printed": 76.3, + "handwritten": 50.9, + "total": 62.6 + }, { "modelName": "Qwen 3.5 (9B)", "modelId": "Qwen/Qwen3.5-9B", diff --git a/docs/_static/img/infly-logo.png b/docs/_static/img/infly-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..8dac29857b7d087319b8f760d328f9e8380b4f67 GIT binary patch literal 31461 zcmce7g;Sf~^K~hq1SmAPLx2Fq-HJ81mE!KjU5Z-?!6~GnxD_eIt!QzIOK~d{FJ9dB z4WHk9|AjX*$umi2Zsxh!J$Lt6lA_*#o+!#dwR|%6GBNeEW~SR_h7evJF76%^do4YMJm+!1$imY_ zXmR8h18t+oTCdaoPTii$W=(Ei`Y~={PzEaaO3P*FP>R{7(Vb_#EiYT zk|5v6teqC|KR`E&VocJr`2YTL8%$aYL;do&B0Eg)m^nHoAs%d27tf##W%h0Ua2@Nqbkw3T>8Qogm`O>Y3O@PZfS{UoQ6>!#kEC9u zu!tfeBZWypNr@nz)A%K7QT%nQzc|(3`2a;2cCc3CYaGAxJ(ew1cUM2J1Fp(0BqdP{ zI7!dFyFqk>q~X;FzHoy`C)J2Aw|5+2L!;ko1tajKj8Es}2f-AQSv)*EAjL-$&xoB! z`zr!1E=Aee?>66i=1B#z77N`xoyION`_jng(EBA10; zbi5$3tGt;JVt zId>8f-9OYl2wlt9=ta6jwbDVLxvW=0%_6@Ue~6C?QBenMtf|?w)dd?lLRgUaFeK;~ ze-3Nalc_lttah;O6KRozLj{ zivay3vN7gt>+=_zlU2_lMpb4}F2`i!caS+ogOP{<<0gQ!z`$6Y3LrVu1;zyH zgUh&Y0p;9X5$nS8>o@)@ZNS69wXy{VdsGM;O+dccVDpY^0E+Ae0pvxgu!V)Mu#5%E z4&+zAkiq6}0(1J`ChPQ_%<4<29g}NSz*#jT?Hl^3k>~mbaVqW#RLD>_1-uYN45%!K zAG>qK-PI`9GQ==HHX2M2v5vsX*wyqHW5^eo{uBRagE(?|d#RkxYz3y*k-H@f)p}nF z7V`@Mr$JUw`RcL}kBv-=vsbm~)J0@m;tmZ=$1`VTBu*?!e0wcNSxf|9WGXYEC#%&} z;Qk7$R5f7NGGKp)Va^2OMuw&WVNAqu9QsB~hg2I|oF6{Brss#fG)8!wQ79BBSB&LI zRF9op|BA>;d!CvKQILC<&&-dV;Q{KA&11S^)S4r;?+DWW zyTjDTKzY)?WL=({A;I;R93&npiJ$mfSO|r=ta77Mv_PC#KR6Jw7;f=NWW54uZg4Oh z@l#wz>Mi;Z1JFw9fT~zNSFD|qnGm!bMe$UMvt@a%?=7vr>urBID1aUGJ3V+b4t0w{vC0OrwPA9AP~ewT25 zIu8A^yQ?A*bW{Zd5fP4EM>Ien@;LZIf0vPZfU0$G*{^0<>#^^layz_G0|ScuZ{L0{ z*Jih)Q%XeKtVxV@wT7|ICwg+{Rlf3(%WAc7X32dgp>#f1m~ArptKe^a6}iasvhP+k zgUB&KO^fdear#mqacz(|D{A*yBh^9bvJGIWcrx@J>{S{o1iW4k0*r|1T#%wftl^h# zDo6yy`rZs6zj1~8Tp}PQ92Al-IN+L10+QhdC52(IE1V4+_9~h63+&Fn%TsF`aB2I9 z&0+z}z1&#Y~>I2mi-ET>p}c(FsZ>4RR5jFs^Y zuthNT3-}&aXqklFLjeyNafpo&M#i_nWfEfy2f5pxz-8?Uy^iD~qj5ikVdYc`^Wv>` zS~#Cp^dc!U*|3nhMo%zGqRph;ioid}rvkH`0Zku=z4a@KmfpC7I}||TkC~1N3JB`P z0nuO;d{p*x6N=1Q0q`%RQHev!ATMWxOrH|Q3XFRKoAWO|u7uI3MGy)edKt-}{X+h)J6G?leIpnD zYj>auphNXBetWPxG0fqMk`2^>=!P*ZFhe?Dk>b!6RjGytu1Z^Ts;*v+A7gDa%Fgf0 zA$^EYAcC%_4FZML2jEQz*0X2Sv{rR{g~m<{{yuH?dr91-1+p#$L;C)*_8s7(Zc+tb zW{9E3yoK;FEjp}Nftm*F8>Evx&9^m9G$F%z3+Oe7bZdkq^nGkQ@?lNUBn`}sjZ8@a zDkrgr;18;H6lU;o+nBcWxVcd49ei?0ul(xUyT$QFL1AKMauASSUH37R3HH85kIDqv z0I35ccS+P6YvD)yeMZH1CYN%VHqDumz-or4%oykPkwlXf%fnda1h_)QhGS&w>b-*A z>>lw)T;ui6SGb388{j*DALc;p>!l}XCr7eTuUFNd@_twUx1#yt6P6o@iqJPNSUhQA zD+QUn=zyAl zM_}ZGA_jASohkqnJj=OPmJp%BV=IcqBvGJ#c1SP}3)CzD z21|X*{iba-f!kU73LceSqH1_xE1hoq>TZ2_IJUi?^0|usJblWK@YbYHL?wo@#8oVI z_^??L#UU-we6yDz-vt4bvy9%F*ir}C1?`oIa#Ja{Cx7N03YNm->~{geGO;y?@(n1lqdFhz3+qX!$*pb1r{hKbPlJxi}R$ zQCGY5&2fxk{qh|wl2z(4-;v-at*$>p->-lp>ecytZP;Sx)Jb{F-)hcZQV%OAB-9Bo*c@9^ z3`7;B7k;H@svt!j-fqx>WWn!tCAE|Gc8Wx0w)H9zUN=%4zc+;3$$FWZjLn)d*7X+` z-68^2{U{kmdGvdM8R?d0=SUs7FKZTP>6G%Fpc4`b*SIk(BV2B)Y~YVyDC{Zy)?plP z0)1u_3c*W1U{M5IW0-}+(}tRGZk84>f=hE}!ZnTRG%D?>4Q{N z`>|hw)xk&FXHLGfCFZC0Q?tXk+k_YPfBSC?$@=L$6jcEDDOj**@Q}QP(|m>36F3CK)ZoatSOWGh zLITW!VoKb;zsWi#iwFUP-)6B&nTJ8}UO84cjz#V&E<2D=>}DYbn+wOp@4OOC_H&!x zz;tOF9QW%poeD#?4}=F#%*u952ir8_e`~*B@SY)h8_-Pr?I7`MReHMRS5%=O&25EK zt6^v`d-_p}9GLe~b4IhNHZK3A!wOQZWNPTS_WDdt<`QWY$2e~~Oqms!rUkmqgqbnJ zjEZ2DzW{F7pM-~eBEcW}r^-laeWhx|#NrmNyPel4Egev`$G(2aYxgUNu!%qO!z1I-oqTEfbIdN`}?> z3V$7m?N0uPUFVb>Np=`397*k^u;qh7a48>@9tH{xbS3OZ)9)|5M{ON74HtKv-pZ#> zAYwxFle|1IJ1c>lO8V0m^Ljspk)abM%-GLPWw+*W=z=<=^vH$G-foWeaFgwQf_(6sEeu7csk^An4@Z+&W< zV$S#?-<=C=VF{XhvtfJHxz4Wt@?J`dg;;&NNrF6Td;7He*hEEKp z&DMC>B?kSJ2U zkLFvhe$C~|m&+dN9j~TF!gCEE=f>L|nBtgA=8C^aY&|(cPD1a2SLDy4w`$iumzyx? z)fX$c{+vh!sJY@lNJHnCtXO5LMEgBj?@u;9*Dyj5SZie!(D`Wj8hxE>j= z4=~3M0g)53Zlqj6h93l=)8%tOD=laP6@{bD$3Lbo@-C*GQ_qQh9>v$}DB%59eKkja zi2~m$k+@U=P~~MmF~EEv5Z?nzmi$u&b|Gtk$Eerzq`p1k9SDyUidHx>ym>5H#CWtFRyO_5{r_s?|iTlHhXK!oT+sp>%YOI zf_Bkq|KkFRd!bC~4=wEi4l=xVF(7p7L`95H>9E^NF>64Git0fl!7 z06*lkzl>H#*q8{jUzU7xG_GnF&X4~c;cED*xL53VU&xAoah?HdH-wwkNWx9`%*RH`or1?F0aK`c~m zr>u>WKw?peLY&X{7`vx~iG!tbl>e>v3$7Nm(&LdE}ye~5B{?Q#H}>*-yg!WZx5>7q(VVc#)p4fT6- zSO*_6jjuAd%E=Z$;eJr~yMEz@1|3u^VNpy=YNcmb!(OG`H7%j>EeO1E1m zM{=lgC!DGcyVH=}S_4%mauY&ji0M#&3y=>VKn#Ubd9kH^B$ib{2?tN{5`jQ%y-i~G&9>GNf zm+6IGO8wMWlRY7Q{k{+{SL(NUzq?kiO|IX0E{SWb3r0FoW};IXzn^L}l}x^9!9aRc z==t{bx5#80>n&5dtjggj7sg|o_4)`xFHM}93j%H{S#cFMXLyn1C}qKAEE6LJ9nYuY zej&*x0e94;to`B0)xT;wmE_Yz&LOKv0T?~SkT8aoEQ~TU^yUKloO+f+xaNkZcTaD$2~@dkdwgZ9+gT#4KRvgfa<3V$*<+gHX~7ct zKK@&Hh{-rYrq@EwN?+_1$2d>!yQzxm$f~tU%(cpWeEW8e&o0HQqA#`&q)xYJApHST zT(!naemC0(@4aOjcBk`-BN5z@*fhz@9+|10~na-E+cZF9on$JW60@d?2h{FIY?(3Uhu|@6&V5Li1*y;6U=!tOFS`|sd zMf@{FGDRKPy6vck2oY3|83yjB^=Z$`#vAc%0xwXfn)6D5`tm9nx3RQhFtHsrX(eHUspn$5fgEKYhZOgn zYINC^q>A}ECCF6zT19%vj9i9wc_LxXVh z%aZxOPo&mMrx}^Qr454brLd0~N&Qo^z#%9g2E1Vf?&ib3Xh2gmphFm_F3WVZFS&a# zz=s)X?fb(7?zm$l)!DffNREqt@{Uf-5pC#29Es6I3J$8JMM6e1mUqHI1QEd}X-gR^ zVr@mkk~Cv1b+TS2v#cVFHG{umJY$QoBCgfZ@s#HQUj5Ik$J%>W{6WGK)s&XI3rxP& z>UlEAzBQp5x`=h*H!@A7rT6JJcIa9{xvWix`PUVkeeE%lg-CquJk#lpLz`N|Mk;Aj zN2Hvj{$P)U@rWTiKR!UT2;9a9OnWTAQc}T4dLRL_VqCZ2)jx05;Zn>70nIaQ%3_kz zGWBokxvyB#ixQIUrc$q7ovjj-y<(~`cmsQa#T&8?LL$KY34Mhq#IMmq^>43=z{w6( zDj2A4gJ2=!S4f3TqIHlA+~+AtG6Os0I+(fdHWC8N%m(7*bPjRJcxf64xs-G@NZkBba+%ye|z-t4R=#;_6$2IhHn^j%&zHhd_ zgUMe=(9Z_DzHnS>#MoObC|1^fCs;uxPicOcfNRKfm%j3IgntK%>dLXq?3BFe^s|i0#<(}eOYNmXc5Xd#Wnxao zq5OgG*xG)Yyrq>>{_8J z#RU>DA>8>-xH+O`#ve_xB<+^2c6Q6T6|bZNC*>pA{e!1Fpa-LQeGBF;o#VD?oTo?lpCftN2pfcv;| zfCD20#rG!XnaMJB$|RIcsw!mS+#hz+tklV-R~h8K2Iy=Q7}MRgPv?==9hV=8 z3Fr(p{a;V2E|w2g&P4=MHdPQw$M{>H;V7ld)F~Q1EI!4elI?McZDOKnvFjw%`FRtC# zef@)>dl>=O9{BD4*&)$60|F&98RHK3vanmr?2b{MT6Hgl^0syMB}qMKiXz6gZEf?G zxI9uJC?wXj|9asgz%4cOaAY2@q&dS7%*w9;QlNgZ`Gc9BNk_qbp5vfU%yt6|-9N?Z zc@apDs8}O)zomf|0!-PU07!%kHvu5(g7&v>(B38b6t}me{SS_$Nj7C{x(e{kb=zy* z$VRaD1N_Osm#Nve24-7h0~nA9vAwWosQ~lPY*7~?xUn3nNsURx>QrIvV}z*)YT@6V zete_gG@U$Y^6{%6j#M4LeZY)P(M^RzXsd(42OMN_vkDNInDjskTPG%Jk6yK)Q*buP z0Ra}T00q5SLnFpZuPBNu9rufiyPZQ41Kz}J5ArR-Onn|%+IdGY#{AfNB-Fvm7u3#0)L=9 zUoblVo4G|Ip>Tt8aGIj?+gmk0FB^vSQUlz^66K=YQBYeoNT*_eu+%7#t>HHjsWo1^k~ko#jHMWq4Q z=g)ti7@MjO8^oG5<=_2k+`jziAy{h=UXmEQOdG8dz@5eskxj^zvqsV+?Yk7{L0di+B!? z_qWACf~yg0FM#w(uTA}-uE$Bw){zLRW1^rbYu%HziSxQY!+*)Dij)#`4W{Y(29U&+ z#Sq=fT*ziHQ(qm2Va^C2@Xw2!DfEZ(&<-wbOo@OR4EfeY2pbI5x}n%mYkDB6BS4WB zw)Ciz5k*SuoeS+Mf?6T1P77X6M0eD)V&l23E^EDM<`Jss$!qo)`ZX`ft$XN(RAtx* z0M?9(`kW%GbM&wHpea}GsJ?Dg=5fum$4&^649*aoZ=!DgW5|EM_``uKGW4fP$DTeO zO#izhfY0VF{zA~aKPxT8KuhRh_Px2!(7PZ)0;InWA@&epMLG3K1LmL$eQu{MsQaF(PW;IcPf6JB+j+@RQ*9p2q7LStncS(-Km2OrT-?rR(dTAXidaO%6~snP z?z$TkFIfqRJ`rUg#n4^P+1pe3%mN7n?&7i{=(u#RBrgpzo!cpEXD_B$Q!ULQxRm$q zf4;)RbbkW#z(b`lG7R&ziy55H*quy!WrVSdrV;R<-x6JJ^Z9ED_0uYqb9rID5w^f! z+X{PayCNTs{r$k~<@^1X9EXwC(Gp#*pP~Hz7a#V;n>;go{Y0SEyc%E@9-2;zU(g-* zOg}#h%AsGKJkxf+H&Ro}3W1%k40a`Tpegzo+rl%+0DSE(7B!(60hw^SI!_&%ARrIV z%G0$qg7QYNm)CSMaP@Bpg=sN;P)-U|O>*`nzAaIgrv$chKDashaHoU3tqhvx_Bvh* zI>5Z|RORnVE7itP*r-^h-OVLK*ZZJ_ICg&`GyI{RQ|e$Q_-M`jd2mbCS#V1@u5Sr` zBNYG@3y(1oX%`b#z(LXw=Z>^}Fk+Rb69Ei32-amP6 zE3rviKsxgNLv}6M|G0qV-svWcyf4xqtFcr!>F3Fz?{4##-6L@YtfjA~P$i4z9~8DO zjCHX^%D6AX#fN{4-K1Gyxds#)^F0J=;Vy)7pFWY&KT^`u*7BLEFkOrefGP%k`ch~n zu)axx-u(Odp%UT3-MZayI|DBF*08}hI+vMxTv0zXEZp$*;`)v)-sM&GaB^|CfA>P4?<@=u%%roQIr_w4rkG=!E$>0ia?W~T5fbUu{>dKoeb>vr&Kt)PCbf&(BFNR{i^^h(sdLJm4m)|nZ?MBu={`4Hz)|W&bvVxI zXSg~iixjBkk!MYDE|ii5wX)L?KLSyKSmd`nX*$%>G9wsA&&?&AL;PH0a;|!_Srr`f zKbZRdHS)B6H4EIB{f)gxZgU<(4-uEa`H1i@Jg`ngWw!P1GR`V-OI}s z*n7u*EkJ6b-LfQx9go`N%S#sw^7=t=e@bZLaf5t`+hs@(!N}X9hoD^!GBx#_9i_6F zK>qaG#lqJd+`)KIsQRb&S%bUXuQoY@yIeo1BjO!^TgL}6($}rV!S1s*etUO1nPNL1 zO%Z&zAMFDU&sb4(zP4o-+gSl|*GnMfm7_Sp-ipl*t~Pv~%nBsKeO)EjQP@jqC6#s^ zNz%JttonW4kgG+*22WWOqbefbAAfZyfaJFe6Y%LwbhR->mqbYDox05Vr2F~y*P!f z+Wc%F;{gO8bmPFN!fO2D_R-~8uNIXma_jSE4EJ}EK_k#w53BXKMUDn^v=oL?lRhss zGl}pUA+}~2VTH0d7`mCCyZd4A{BBqZqcWINgj`O}cv3~V;C$f441V~(xR7ugVb@s9 zpvH)8+?SG(Z?#IyHpWRp=5wvz$GdC`3kLfog`}qbvrU&pvO5sWa=m*xZ`9I0YcZ@h z$COg{Ll)S1!RtDN!BZ|y(e@q6ZCxM2(gbi^Cmg;W8>v}a?={4Gv1D>z?_0vw?x2}R zAt8!)c!i~xWC&!_DGSNa7J=OocR!0(!e9(z1NbtEz{Z=kVP&G;KYPmNb4JwRXb0d} ziJ9E}yT4qn2M#uV7Y~K|O;Flt^M{M#uV@Ea<`4C3ZlmdmvF&v7xMGKX)DdHcaT#&? zG93*}C80K3ZIZ_G9fk6Pe|aK`XG7l#45H&r%ltBqSgwy?{hC#@oc;?>r>Rcyt`Y)H-LHS+8xZ3w$MiM>nv6M<3^FWNI@2->cAf~|HfZQfK%PZSWUw_N%?;% zj?zWAl&(gIk_qWFrg4Tbi{&92;oF6^sbCI3fqPr_v zOANYpGQ1h_b1ZC|^7kNIScqfgROlF(QqwcSo@1PYHRmQi!`8)##)$?rcF)148W~|u zP3P_BfBbVE=7IIJ7HJZunPE*!cl&V?2fD%hm*1Hn&{GIXXNv{kyQe$~6eu-kyoyu# z@k;;Jw;W4e(qv9-PqBA)<^bh{4MXm&5ZYdbUliO ztKUp0qQq(x6aM{D@ud+mt~)m1YlS*!A~T}AAiao_Sf&>v=fW~8A{)<0JdHmngg~Z? z8OvgI6&0e4%HtSV%9gEw_n5)$MvKBZKVtWdPoK(jC=ISRniiT%b7XINA8XE`6?tgT6%+s~zC z*_^^cp4xUi(g`s>H*~rB8NUstb ztf5CK;NTS>A<90|oDT!kZu(;Gc4Z;Vf~g^05NwOJRe&xviYk>#@Ay-qu|^rTOc6T` zi4-Q)7W=+3@Ti&5lO=~D6cMjh=k=qmy{VFv0-$LTc6g@p zg1U6#t$1m#Tf9T|cKyvw8*bn_(QD_JAA>Y-Uo4@}R=bb@$-4<=qAUe!3;k~g}^K_WS(7@A- z#GO9<*-AgYQjWzZ4ud+<{R6vgdE-VPGT+IUz*!pu45HL83TuugTTl$iPP8-%%Tbc!OfiVzGJ_D z0#6g4q8IsH-f*|J;9ouEIWR+)gJx{eZue!oQ4h~909)1*K5S8iC8Fppm#pu4yUU~> zF2chYe0&%ROMh#hyOk}2mV$0jez9mL4gc!Ef_(w0gOm#Cc`E~J(@|FZ)kfj~%KfQ2 zy}Sz{U=P)D%x#S@@|pfGgiYEh057QEy3yb3)@QIK!p@azp?JTIFlUAYPK?2ZF~>jZ z9z?vRq5dVzpauumb>!1p{pnr)tyvpades4`LD2&!=qyshKq4fE53kicVlI7 zDR4Te6rU(L5m@e>q#@dBxvim~-1p(MI~7EA#|PnZ(H>E7deSts@l(||@|+x*1%OIK zM5cG4ELkX8XpdAMl7|v9kz`!AhYZSBR;Kq)Xbymiq+~GxA&xK)JwBfsLHnmL13FeZ zXeC5Y&h<}%F6**O&-OsnV;=k#olqR7JL4V zUR>9pVFnoVKhiab_4XwY0Hqn>0~)EK!A}X`rcaaP+GhJ0S!j=B{;sRPoq1g$U@KrR z*|{UY_Ql7u1D6!SeqIXd5|qX{*XI~{&lK5^`;|(+SPStBz@vlT)6K2axoLF&nUw%l zx4ES*5lC|7V-={}EDlUG-T%fxQe?9z=x4A-Mhl}g&Xw(a;09DY8NxY!&;~t#Z(l&0 z8Rjh?FbGuNI1Asn?uGGGK4_EqnP}XJ=NJUOE!II7${G0BrJ$-5(Z$G82??a@`quS& z6kBUV*{*&dFis+*iCTTmiheeK<;e}~ycVt_(gkG_Sue4x`W!lKyD^F#dx2}KM*2Ls z2r9JkQIP6 z9{OX}4uUJNoCCKrrjBc`5cQuna&xuSG>o2EmFQwmCw_j6MkX#M=xR~7fdYpP)huUG zxZ+rwZNb+%*dKRm)<|2F3*;~lHiTmFWk4vaU;#1QbYDORwA5FpR=@l#>UpXFCk}cW~T5Vs_oZn82#eCWF#QkVpVC0%&6LGak z5qSKJz_MNQukdLjgiYDpXh{DXHyl)OIKvl}*+cQBhv6YJB<9t^64$ zqd83IZw0Yw0_vdjTZBJe+4OxUe&RRT9)&&I8YsE9;0vM=zXb06X~Der-GH>SaoJu6 z>7xB<6Qi8FOSgxto0#ejinkSaN|@@n>BGSp_p~#vv+eQrYKJCY%?_KM3LbJ?^YN_X zazxay#X={FGwImo;bN=jL`km+AU1FyYU1 zU+X*z%)P1k5R>dh*gr-W$ye!0|35CE#2mL3)FqOyx>4fG9DcXgc*~P9y4EY#xU?a%Rzl&y46uqD9UV> zLLd3M@2CXT-O5m$>Y(EV>KcllHXgeH#47OwG%x?!rU?z$?l+8~`t2j!9V#XK0uHC< zREY1A5h#dPEE)cvR}JMBdsNyO9$9kD*M!h6av*2OKfcYY+7IzK_T@2Px1%E%yrCOr zR^jIcuJlI$R9nkrZxNhQv|A<8aYrEY8E%H4trbJ_sjJVfm0vmX|h;)+dM89N0qVpCLWG)Moy~b8~Cc zZsPPRhx`X^$;EDPwa(H_uaY$@Fam&&e15s`Aqt$S4WfEqGuDpJu_ku->P|E=!lVod zq`J|NzS<()VZse6z}lZZO(2#;SPZ8LNa2~tI=?^G@BlOWim;F9J)cWeHnWv#^fL%~ zKH*`oU)S&Qpo%XeNDR-@0`WKQ4Gy7B6a=2gXKZ@NiaUrSi_URK?3(-HxOl9gQTvuv z!7_Ha80C~f+ZUj?4u0;@lKy=NLp^H(>@Q1KtM8~Y^8M`7-9Y-$eZpTq0-3k4Wyz7r zbxJ^oGC)5o(_0p0k1;~3Ez#0>-Rn0nl<0_`DNd?zIef7DdKL%i_2tCsr!&Tcsx@W| z^iYirK%!Hg>q`Hm3dL_xgz<}9W&qt{^Nm&dYLO5%SI${yh*FA{EE1EZ4 z^T_u<8vb-ZQaXUfEz*#?4Ieu?0ph8wtf?6jglvojhT<3&Oe6>RZd1!J%!lr>af_re z>gzwh7Kg}Ks%sN3-0_@0Pyg{$Ja2CEe>3QRktk$#KwJE^RdQ-TTveCuNHhC{)aAf! zgZ;zAGh(r_uukODzJX!*Z7Dre$E3L3M}(8f{F7Nf4A22W%tDfvKf`&_}u{1aGN@E_Zx;9vjfp{)Mze z*l~IARPn*xY9i8x&00lb{HbxADZHF{fZ0p*NLjnhBXM@^(ynMep1G>GUI4b(YcGSb zeUn&7Pfcvu&Rk0+xV2`m{`~%n0r%;hz1;GLJWndYO9fo_aGIjz@Rm6gbEkaQuAA<- za^ls{LLk6sC2Dr|+nD;rZ~giLE!Vt+%ujfO<6GXTS-69|?q=_22Pu;OF0~Iy-Gxqg z7#jZbI9+X|_Zs}I{^*}U&v%CQ*Os-$uyOf! zr~EJ8DDEa$jN|N;?=%0b%TyLvCEFt#jIuR$v_-QNuAGM5%bkyfp ze#J}O>DB;kwD)&fQ^EJ1_z*cz8#fdrV=?>IrEWFZx&?idXSNLsE48ARKE+=lPj~}U zhc;E@nl1Lg7aD(&IjzJWQOWtOSg-!%?AnCbVDDV>ZvJo&lftJMH~b;CxTg8}GLaP# z7IxBKAPF)fBcPS_R+H;2#)lh7kcXs)H+U6wE-NS#$@Jjggk z8DGzXJw9e&3`MYD;vTZ;4QR8Y)#_6DUQ!aoi46qpA9wce=SesmQM#6sG~N5?&|IR| zsx9gACOo1I4CJ^>db+_J3p;(NNy6{JmYT}b7(KXCg3R^_4KkvHtFKNw4}O1!L>IqJ z?_W{i26s9gh1XUVI8r#(h1z_x9baKVKvCj*d=SjKlcXOS=NNWP)v??%+}P6$Pio+ni>Li2rO`=vsA ziK-2%5TsJXb3OXpgPc-X0n=NWQD^l$>!U0@#`LHmMqQ?UPdE zXECQA-XA#Zt+=JhW;sq-5?NwFu_tgID#1lzgLGV6Z$S;u>4I?W3(8%>w@^-4? zm8GUoR@x1%22mAjbmH+YRMdhQO4M);(sWfVg4cw0giLzd~m0gv&aNtx(X=?*_ z3Mp`p97*@-GZp=@=?ATcw*=gJZU&;CDs&$9n)U}s{&09b&m3r9TYta1iL-f~QQEo4 z%xXn3N{+izI^U1I7gVgV<6BPAl+k*r6i>Kmrr_Ix&06?8Nw>4EMHJBF{g3q4ui^dX z>Fy%Vwb$-kRn=x|kTHhN7hQaA55w<%g_d+1>L7UO*t}YE<_;BJC-tLJk_4$1>iGPsEPrm@Uhr6Hl zf{`!&LFC&mSSt^j{{A*`$*d0>c%`ak_ zxnkW?+SmgFDvD*j{`l{lgsi^ELL?cEa_tzgL1}!x>MQWBPrdpBkH`0K^Kc^#T$RqI z-NnNd{g$Uwty2N~FKvxbK5bO!poVE|Kd*(1P#7r0%}Gn}^r6`dn%~5=z$E?M-FfNw z_%N{f+;e<<>10}?ipNU0+@U7f#^D=}8U|`C2{i$7II&_S3z8I#&_tw#*qa8^F8kbI z;@sXnMG<&kz@{NCY7O9*FBO(|yZO)gN*ciNSkUK`1(4P*pK@(~0lq?aF6ddTQyI6X=ESk0Xzi+20#?&5`B4OhI-cu|Hex2t_3{%5)hV`i zg04fpIjzvK)$X=;tM73E+LB<<;!P|`6HUd`#bsUeZw^j{H<&#knG**%XlsK@SH}q} z2a|kK`zOvK=K;F`eQm9d0H1Bl67l)-&c%N>W=s2_kGoZ^0q}N|y}Yr8lVhBEEY!#d zaG3fl6`>3XKvrV7rv|j}FF^`6y{h<1{K&Ee8KXK)Hn@pvl1`Dgz;F~Rr9KH~^#zs7 z!)s_^MZ$8ihX`~b!%*Oain0-`Oa#45GVGTmaEsH;bE!Wzs3<3OkU87B(i9=>nzg_x zY+2QY|BiK^zVZ1d-0=Fl0%dVUEm$>-f9Tv_M&JN{^3iQ#Wki6K-as0afu-;NdkZSj`1;lqUrdb=GK4SMz`-fT z(+FfeZGrXJl;^`zP$L0~vGJ~XJ7Wqkl(92_;2FnDUChH5o&;TkT(3c^d~aQ+uugFB z0hn;CfKcgnN&e@1LbZiW9G7Ix0rN~K%G~1T=PHsc?(#&7HY$5l-IhyCtsmKaMO%5+o|DO$> zEk>6TPo1V-Pr&MdW%$&IS4D3LDRhDrjv9M7+knOTH7|nu5!{|JqOwO`KWI08p3^zjv7zjFXSE5FiomoMmj5&Vg~XwcbFINpWH_Gu4Vn# zNY1K%*$v&>F4?>!MN@K#x(Z;QQL5DgLa-~&F`<#?D?<49Lr<+glt(lb;0ynoTGPYB zJmiMWET*IQ=BfgLrCZxI2}I;KG4xV{(3&X!X4@Ta*R2})6VuC|Gb(NIx!NLUR$`Su zTQIs&COAyC8y4jsC*Ad}uzD_Phb7&UJIH&DWViQJ`2Kaw`;Qn<0vRS$<5<+T_Gj(q zO^~T*D8TN(zxq?XKCeqX>M&21rn%MUOMbWgW#m*#+e#p36&5+Y!^QN-urRhoz55D8 zmIHHz9_Ii%g&`5`|5-2ov)$kR`jLgR+Wh4o76EGP$1Ml#kND`pZybUhS|%XyoaB(V zdoXb$DO)RJOApZYdi%>m0E*I`e-6`%1w)fOpngD(Y3Q(feZ+8S7ez3-uU0wlScA5t zOz>6iv9z@gC)a4~;79R-)G4R7qRBE3OW3?yXV@K;!Mfx=AG2+=LqiwK6P~qc#)nF@ z`n2|@Gau~wmX23F#GeJwble0LS{S#Hnq|Ku1>Ciju5V#x$=%L;^Xw14F?r@wKW=}K z6)+R-o6TxpgIea_iI4eX$G#jdb_{5*T$qL&-)gUtyi6|bR zM$h=I3HTSvirC5McJ99`O;&4Fzp-3*cesTo0XNA6U$LFQ!HjG=Q5@9|es$PpXP7&f z-k+sMrX&|%`Mq5;=j>0BuN@a4fPVrzE*sV=z_w_=)D<20w*>&2Oqv^KgtYtl6I`LE znLv6E#r?b7st+Hs5(`)Neg{7B+FM{=pZ)G}V5*8(sDbn;8e@BmWUBwU+CoEl+aSQH<$Ed1Ir%d%UnOz8G{+OId^(XJ5)+TTjz1j6*yu^otK)9Jqk=_awz?#o+yr z((DLH9ZWT1EL_E8feNhh^`+YzL4i$C_sFLx*SZ^x@Cr>g3JNEsX5K7M+>Sl>gV;Rlha) zhI^3>Mvn%mfs}N24Wvsz=`N)uB?U%{(%p?ncXu~}5`yID8VEA_%3atzU&5qkey-M# z$XEProPMX+?(uA1UhsRZ0jdg6SGrqny?c6|@aow5H@-7h)%xKvy3~m?HW#dXiWCsc zdHagAk)oF!Y(~$+2ymURu;)^8Zno_#gLtp)$B>oFfu#Zw(sOI7CV^!`%bGbx7t>~J zJt`#zMO~L|pgNmf+q9dZ<-7cxj2F`ly+M=GALvE9C_j9CrVPGj`{x5RUzr)Wa={h< zRYLU;hL0Jrc=58D`&<5_FrME{u3cZ%n%o>B#QKZy+KHL>d>KjXnuForq305%w~{Ns~v$dU~a!c+2Q_atruND1+R&-C)jb$ zqV;@l*H#iTH%>BRz8z2M7MtQ~Dsjbf)U(#zecz7TiR%n;jXMbsB<95qYn9gN z@&}NJAN~m`^-7?OImz}pFR0$-57%O+h4|4*3yqi4FSNsEM`oy3WW>DVS{t5iV9Y^0 zH{?S4_3iq4DODit*-K8BO1Lz~NO>cS-sK0D622*Pai&mRph38fo=!lSARvGEgf1y{ zcPKT`X{+L+R z$K+pVlvdBXJTSB~R2F=g!v1I1Ut(9)Lb`dECfN#c8;tKTD^o9thQSS1kYk8~xy_QI z#8|3{GY4mjEBMN<`R%)9VG9e{I4#EE&78|i z1(ca5)D^>`!1s;~MOwesvcK&7c6e4b_coXIjJ^+9{mQcvxK!hbQ?cBkJksm4Cm-70 zoMeROR|TZ?Zd%F##3PTOZZ(hE;2~aYS+{j`7kk(B_DhlVY_a>i>dcxemY>QWx;|_& zXDD|)7d3rY{$A~}?bdUn_tO|3xh>(i+Moz?mTL-##8uyI<1WIIOhf+W4 zK*=y5i!so0o`eBTnW^Z{aDJt^BpZv-Zzc%H0b{@RMwyrXee!3ju;RQ4!Lsu(q78a z($*f5TF>v@9!HVm{FK~9v-i!e3XB6^9+`UM@J5Ca_Lj%%?2lY_qE5X_?w?60I=iQ( zML~7McxVYsn$ArcAI|WKf^oMs^YFqJh~DQ1piPUR9yinWB9*fCs>ad3@}}rJq*Fl z6qgnSW)zyC0uRFiLHrnq-Ir@@Q{ReJQdK^=3DZyI&A!a=<-#HdsH32Dl#zDAQE3;3 z@)){92Slg*Wp@8W1Qay=qA#vShgGo{Q3T&>-c7`}<%mZ}a+&($&pXxo z=GAEdwWB|cy9NE?oa(xd#i~l8X;(Erj3KS@In-UObE0zm-GC?LviS5vz=LQ{qNY76 zTuAQzwDUi998R=0x9R<~%%yXHo380?#1&q=c${5AC6<}zmoR#a#r0-(fC4m$n4~yA z_g(E;yI^Tsr(cO?YO`kln&B%v={J2llbJxHuT1c}pTJ<3ZlH)4s^@PmFRiHZb2(=o zI6Hhr+ieZub6XYXW2+xF460`Sksq-?wXliaFfbpQB}V&auSg)NORq z+u%cGgFq>PLu6BN} z43ueX!nc1Lmx|WXEK@j!)T)z0@>l%|vB85LYNwr8uTm8lyPd>@bKub4EaJCP`Ni}pUwma3`8Uucn=KON?CS>#r}Hthb^*AIrf(mDejWh zHfRiLqdQ&2O zr!?e&8<$OV_qpl65t~=78wA}t(#%&B7)ZbSh^-eR4B`ukkl@|w@Rx6d($LE*7$c+M z5an0+=?W+GBb;^Xc!#pk7PGL%x$c6SsGzZDGaq>Ac$z_?d^mbgWsP`9X^l*rNfJBj z#55WFZ7N6!zopmnW938y<5~M_QlX+LH;ID$d%js@nHyPmtb>+iQ#I;~}*=TmIR6PA}#*r;{LrEZ^ zn>S%-KD8QI=r|12b!#dPg}o;!+LB&%y$Luyra~ZuH4i@Co|{0FWNk|z&pAyHM-Os{ ziLQ_*4p$ViT!L@Dq5d(}=cs|2FRGNJ**5-A_eEac5aw%rwfm}cmYa>oJ3@7Qj%GvN zM3KBah~BHL)MI98lQ^9uwkMfbJr-zY#0!|s@8x;~*>Txia`ud@s`2fQ)`LlHc?;9( zBFtLP$e~01*^2kqICsY-Ui51Lrcd%;h_BAjhb>iSPCsrTYUP_es;v zn%xm=>0JHDP%;^1W_8v5b)Cdz$}xJdTSX81r;T_h2^5edDFM)W3;uiGEP6YgZtS~p zuMu;UR)-K%L+)XBT;5Qp-Tzhma5L;)4v9ToLFc&*2w11aoJ}-n|$+06FA{pz`X$`Jot7Y|@aWf=qT7&Kq zZ#pEBcz*sn@rx;;p7@trF$HN=r4m~59xC%kKYwTMi^ReiO9Tvk6^8-6yj`jy%ECV9}T=Vs|H3Y z|LEVa%|#Q6Au{KW+-n#2Xrnq7(>JmY%5D!}IHbPmIalyT+P|6UIS3c*N^V59+NTHl zwBzi*xP<3K{!W-=W25Zr=d$AVEP+}R@m6NT7iWFvM3lHCBNuQPxXX$~4RJR@Vt?|7 zR+(%JQ+BFz36{Vs3DOoMvS@y6;vZfQS!jZxc+EJPQfZ8i1(uzRqVSeKKT44lK+X!2#O9mU=zOph00mIBajmsBAt>mWw$}8?5g*Vr7}|NDuHi{<*NR zpT6L44gDAl61@Qzv4ngDSagEibfUswN_7x&t+?CV^ss;kv*432&8s28Z>L)4q-lfT z4aQI}x00ZF&LXDLkdHsv`r~v;pD-Z_v1HwlCj4hcK;Eq{@4A?x0SVNyafXxzNpAdd zMKj^}w8jgz18a#S?;Fq7FmlSy{VoO?T`t>SKSEMG?@!9#-Ar{ZP$gm-+kd4tUKqm^ z%vcnzHcf>Kp51+CtCtz2rlo|}yJnO-D-qF;B{NGQtbug3m2V`L4z^#UG!v=blSD_* zIKnp~&U`zR!EO`GAgM)o!Ld>V4S#d!QRTx)p5`P^V>lJ-$5K*q zc-uET#x-*;<7z;h%v&;>>fu z)}VbIwQx8&Tlll_bKJr}A}W)^OE@;KJy|m~SYe^2&hU_Nb;pY-e5pHPMKe^Q5Zssa zJ4NYCHBqZ!;^dg^kY~117hlU>Ac8z!chfA^RJU2|HQ%0)TkoBUgmz9`Lfxl(8gLKw zNnXmLKZ@zq<31xr6u5vH1NKntrp* zm&`gRn7U0lz?X|K9J+hh`zYIg5g9)3EbTapUM%$AZNC*@wU0y2a2-9p z^^to$<4rKV#z3)etaWSDG{EzD^k@fUm7vA4D4wL~^-?QR-{`C1Rn#AGCE056o|KCf`hy@AiV{kQ7IUtM z644eG^3ElEdNqG5v+Wu54O>Y~C9DO>i1glYLlL)sfH-8W25sh5>*X=ms+Pd-IKS1V z-TVGHv|8Bf3PWD#)Yph=x_nSqw%bZfG@?}ZWXVx_&mSO9l}i@Akfs}+$Md5_97+70 z6X54-L2e7a+H0e*c&y2$ziah*`ZvUf>*2mtV=nNavj0?(za4sdFwnPy*h{fsYjRLSJ*pY%5o?oOE}}g(O{-%AZ!WcVGN(U+GJG`WMw=t`%mJ6^~fq`Hy_RxtT#% zFH|C;W2Ve2!A{VbrGFuMTt!^;*uHzof6kPa^_^6N>5oFT3YZy(0f92{0ef=>dL;nY zj-mIq&@7Zn{c>)J3mG7=kVrkyP8aC^0aO6eYH}^l^OzIsEvXZPA$+U+;M5U*n#s4< zZ!*|Isu%Jp1l6jJ(`Sjlwp>eIov_^13my$gtrdQO!13s%=u9zKf2#ZDVd_uuUXRJK zWtu$KSVqnePfFOkKc}jl?4-?(T(q*5`zyruPbtk5x#*r6Bk{@_5$-2GYa12gNafjM z94$fSok!NCFCGwJ6pHaVIWHQUf9r%gW%#*{A&kWIf9tJC;$_gjECZkAQ z5cJcojV^>tjZ)J1s~C!BX8kI$1|P{A*8b?L*kB3)YjTkq6upfhQi==mo=V2~}S-HxK>u`$hJc_jyI1iFzpFE|ems5_T{8sNb$gwK z1|0kEr7)9MDv~?QUQvi*_elU+%LcTu`Xs6({VofrJ*x5rcH+WfB^?_v=)OH_%=C&O z%tiyuZ^(ZCW*(_A=Ksu97kFF?JHbKatfl?)e=kyt4|FI8o{xrbCBR3ovOK2)2k7qp zwtMFSUA|A|h|@*J?sNf9r|!%|Fz?3|_J^FDtx*e6=aY}Y*5tE%ORrl>b!J0qDS4tR za0SJXJ<*L*d56uoB&#=Rq?vzmE)Sc#Y-y`IJBNSHFmMXyY;onFr;aInkfd5Q5&R@? zzA>aLwXSkMG^0B2BH`9TG<00UnQ><)9=o#2{AFdC6Tc&B<#!cXsGl`hOd#TWSuGBF#c&gK zZgI%_Huv5|8XKX`?Xl~SyzbncW=peUXmA_ z{t5{}qv#R-3dZ8kX-kQ4P;Hyg^Jkg9q09oaeQfecEIW-V+#K=9diP4o$F4k@2Q#(Q zhv7eJJcsu_uvYiC(E-7y-YXh$Sp^{Yt)J3UF0D z*J{65DR6$clel%C4loaqW^(V3cBk?K(2Jg6Lz}9W>5&!6A5wKc@rYiJ!n9JkdKTs@ zd692SL)LFH$O8^5sa<_8XeEUU$)O!6*^R*V;;7DzlQ5Ccn6nPppuGnm};yL#D1Tb{z5n3q@2a5OpAj(q#| zH!J^rHi~9vCR~mBXA0z={h>!qS0!XC>xtM|vn@yX%yo)n*+<(`46Kns_<{^ctp=zD zkFTjy5j@7gpW=ns|BiG}Z3Dzkmln4QGyZBKp+XY#uR69o`q6y|ifQ$DzX$2KeYk~P z?RGJ@Y`Ka<&#`s#^F&NQeCL}!L3Nkm4=AH<;Tv^TboEM02QOD`WsypDiYBw%|r z=v#?N!%qUIYvNbVyAOJf-oy)hvXp6ug#&KUvwj3yPS~nz+gYh=TSjk2F?4m6LY|3v zceDAquV05_<)^AYsnSK)x2J#RBD>j(m&F69s18GDP9(yvGN|Jfsr?654Y4fbuAYe! zy5_~xOB{iO4i!5=8)!Dzp_j&*>weCAjr`}9R0LucQk4EJyli)&z`VlcCqxU-=Uqby zLj0bU5YfUXn>ua~A=DaT(p2Em*-~6w!UnTbI^>0C7)0Jy{z$g>oYa{pg$3I(l{+}8 zNCd>)6lBrsdS4^U4hc()uFYbzKh8-xT~Bm(^q8P2EIfg0M}Oz__LyC`fD83D6nO>n z^=S}O48Hb0fbv97=>Jl9Z`d7U-p^%$;X2Fehe?Dn96R?->i2|bz4_mcs{v8QfukiH zz=r^DY@(Jxa3g+ z#NK}^&w}DzO61j!Ure&A&!JEK?Ck#vw-NMBN~myq$zrgHuaHu`1p6X0|jaf<__FgVf%hD53bS0_`gJBkC0>!&GRJ3A$g)9O8{;3zKUIiOIk zC12Y=Y&z<%^`P8D{_$$xjnd^sLY}oF3l})&p{eYp(*6ewJuKn@chhF!6D$BR#UJm7a=d8^5U_8KaKbL~sL%K5l{Ei?q+YDk z4^M`Ye5^>v6SeRJ(z=*_UJ*uq1)dlMt|E@)OQ^aE@$2S@wNgkW6IJ;WQ2x1nEZE|O z0Gd>kRn$^--5ORYXwB2^h0tPG#5#V=tfc-aJVtmKuAbSOoN!E5_~raXI}=qD(f&fPxn&QEi4 z7x7#8oBv0oGqFG7T7 zMh#p7mLpi=pI3gGw@3$c)3ZD;(}Ql#ZFa{&TvaVM^0TQG%(mL{*^hbr8w;&h4!p$l zOc_M2p=l~$PmFza@EAmG6haF#*PNAAl8qUxLSHi-L1RJ##mwKEdp++Eb|;%izdVu7 zfs7N)0kF=Lg%iQgK?2LT_x8`$RWiE1O%VR)dNQ#hQP|4KB)xOf}ms{^1XSNO-X+LsNoZ0ZKeYQYTy044QE z@(;f2bv(uJE(Qc&*Q4I@?XCAQ2E70 zS)#$-y|^Dyd^-HcfXp1{LjLDV7eAUT=R6^i#f~ z%e{%~kJM|&JMDZV1J|!*qu1MZ6S%+aX7P4dH3NPKSkV9{pbI$)A$6DX9xr!V=@%Oc z8M%@|7%dnhx>ILWMj1P;!=x04Z`s=K#=dAPH6|BA^e{}EsQ3Mvo+&&N#|hWs@g3(rY;XIA0-as-+%0=t+vcry-NF!hb2rB}i? zK#d`?ElFC}T9_OW3Umz`f5lhPp0k}f?AwY-eo@wX9*bH^3pf9(q^Ux(6b6V=jt(ZJ zWDljr(c_3nOJmwx!zoEQ2(Q|aVjLdX+}UT1_)>>Cw(+dF?hPmC3LHwv23_0*hhAEv z+9KOqDdOV}sbjg}LdFr)LM07O&=~!^LL;Bh$)tZkD1i2G^`4?V_RX;LLP z^oO^j_a7i|v=t^Fy}2?{z+oammBQ(xhx_aMIt^p??3MIL&xgQUx!&O-8jFZyYFC9k zYNc3;01(5#t0|t>l7mE@N^mVtu$jjOaosryL5m7POMr zh17|Bo;ppgblyg@v4-RHdjq^6DIe@y zXHVU#DZ;B=Li5WrO1~`4*k`nS%huFi+L1+CF;#w!iJ>sEd^@nmz)n*+Ikelzv)$s9 zAQwa#8VBsvE=PfeEs8(%UP*7};A=*I%Q0SMqQ6ToHgS?z%9%OBmX84261NkS`RX7TK=-;zE@vqL!OIXxF>M)l2~{%3m${AYdz9 zJ?2Y+hG#c)Tk9XjIF$^&>~mop#vK#|D;k%UTm)kPCXo3|5s)KmeO(!p`Pcw0hl z6gmgC+H%?Wx@1&CG<-LAKl<=kBR7W2TP=>}ozcG}bbDtUGFNn?ReQSKf93mAzBTr1 zSs}UUVuT(C1>B>`H8qf4REY?cU~N!sYiK!NWGQOkLNAjQrM>a;V38To{Vg2^X~rqG zQ`2B430*Y8GwXgGvz>C;s5mxh68zI-<;t*pyc_K{A>)};URws?nCv%GTlA~XW0)p_ z#A@N}IF0W-pMO8iFu?xmu-z-@rPr-`=^}03@WguOFj!UWy?*z_RcajrO@h_jlO`7d z3a;Gn_0FcanE=kWgWsnk)U-!x$rJ!NHLjK*Tl7%OEGMGSEN9C|H=D7t7CtovpGw;2 zDAmCxnhat?!O&_Gw|F|BPh(hPjlyl9i_qC4Vt}yHz-Ec%3Hb{0bZ|&rDKC zBJU+tTk}PWtXErA38vmoL6x4hzdlg(nXEVIf=o(uQX?et^Nzi`Dp^e_v3Qx?2Pvx4 zKB=NVjb*3rDIOwp``2tI! z!;Op%6PUDgpRIbeb*O;PesvcZ1SPb9qiAt)zt;S?>Rv%S?IQT-^gH}xlUFmf(_ilU z`*`XySIWF@4{U$V%kVr&UBsGQ%s}Q33ZJ^LLTW}Vy?-nX1^sw_-wOGJ9GDkG>HC9` z%px8V5^Kmf5C%hl#I!ziUn(G_#+T^DGZW($!jBv7o?Hu!o&BiMb6gYJf*0(kN}$IV6(~>MP~B%B{GG{1Yh~uXe%03 zBULG1UBvadyV2{A$-PKGnmQ3#hQ3)g@U9Grcx||Fohlrr{rE?w+4_lICjPI9xNbi5 zPxID+`={J5?0}5SL!a=<_SJ|Mf7(AevU@7s_=V1`qTCA^OvVvlSX5Ieq`_LOgGgr% zW>DCpOw3EgoefcpEy(EFG%ceDFQ%~N`el)m^R=}AzY$@fSko%y+dtWhFJ)`SK8(vi z(OsGtyR_c*YcT8A)3$2)s*y5_MV=A=cW)u8?_(#Bd>U`&qo zxLjN&A1HqeBA^$o`NdO3li5{@cj{t(Wbh~ALVVG0Vqo?lySw*j$t7SvFt=UGnQ*|7 zFaa>j6;iy58*5x12G)sw*eDn5CU{r#L*n&9pB8w>%wYE=v_swNNEQ6{U3SR#8{|5+ zs?NILpK;Jjp@ze*@863t*=;}nwGB#9s4dlXK1*F|2)US18fM22V|kfu`KUnvM6Xw z{OF#7TQo{R=YN;01FxwN&IDo%>T&U$O4E@cpC`OW)Kx>We!NDc4_}58v`DQx0s&@U z|Kfam&w|GFZsiA* zvOm;~V<5pE{1?(7SwidlKA`toW1NZpQ+c2d><(txoBCyCkr68E_8B5buPjl7SZbWx zHHshzUd!JpvnXAripa6wjb|}55fI~&fyz#0_zuKbQ^N8$zo5 z^~b>|hPnCgf}#lIg3HEW_d-FD}ePI}%3l z=(~rApGJaQPK7Dcaoma0#Y}P++372w1+2J2Q!zNPo^Em z4-e~HMb~ObA2=9xZB_=^S4Qm=>UMvNCbe`x>{ds57pu<_M%S3BOw>_kvWcN63f`dy z>5b1sL)3cZz#oJ}H>VgF6-AE6FVp`KIb1TR;==uJh}w}y%~7boC_qn*2^A)?l%Zqn z$`gOp?ad@FtR|?t+CA4F%T7?jOwuJ~*o8;>4`ZmkeODf<3yP)L?)&gJ2he{M)(}!a zYv?p{+5n(mKz63_c7-fiQrgCGj_^*yQ#?i2^%R zBi46Y^D!eR!&qhMvGZBGDL&z-04~~UYF;61o^#P_)-W(B$5FJaNu0!dnU=9|Ti43fKq$6>!xi;i3u$ z(h0E93F?E#d(P`v`hfT()hj4b5Fg{nE#vTB?1U70imSKvS$7^v@6nj4V)1+DvgTty z8FVT9OO2fsWhEN;@X1r73g5-<1x%rjmK6G0m@~l)4MLlfF8vP$b5Cw#ZUO1b7rv{N zA!Ph;k{#?ME8-Im5?k_~u{)w56Xt%&ZE6@kC0(?Lo#3px^qzMJ+^;L@)`W+bsSseX z6!sp-)(>}^xc^lcMIi0WkS6gqx@6H^m`r%Fg4p9LL0`lXFK?*gu@LV|(fN$OG-M-_ zVsv48FR3wkEjxkZ95|mWEylK1&&v0sQj)(IDHk4AS|(-hyd8}VSSpebXDP8>2F%qt z`rDTzYbv?8tB)U-FtsTbru_U(cf)_zjB8lvcg@lb*c>!#yPk~hCchgWb^-)P96A6( zzI_49Z;^N4wdSL<+)Z|P9B>0Q$TCIp(f&iwvfz7i;BYsd zWQe1sf_NZDWuv=go7GKHg%{H1sc`6h2iC{iSr-9l$6#CCmsf3N6^e0_hpHCi;g_;R z4ARn=pFY*-je4R8A_(gQS07dNOX|6l5MlF=7PRoq)5%`i5Z0-2(v^MIk@4W82@lo@ z?x2+;>XrySiXPVtMyw$#{}xc zCqrAvp)IKL03eLOs%$ndvmy0IgQgkAowXtlTPtUqdHp1EzCWCF7q_k}0#^`QsSh|G zk#mnY-PhObi1OS!@;i7cciZ)#DlG6CHJXWvrpv~muH&LtGW|%X65RPxgEt9<;KKUe z$R_k{KCKPkawcJv7B;$oGJ^DDtu0I^gxSf%B_p1rQXH5+=;(TKb%dX=>CADDPtzmz#}S zUau#ojPaMTLyjQE7bI=bd8MN%ww!54*+Omd$%llwU51T}Sda5uX157Jn}( zT)X^jhCiH8oX%}gq4e~VIumMmqJ-hKQ*A4WUYDhu3zU~)Qfh@KIFik20>)MTPmZDxj7l-%tTW;)!?JYAXxPV9a4eItDD90P` zM2~|~f%m@$3{#!gTZgVg*q23AHWIV|TB-#fFUBPRD5zM{ZN`D%UnW#QvGe~SFypWq zDb(($uYiAXOla+0)i2o860P~dt>N>iqi+tJ=gy)VjmwHz9;$>7gUo=rIM@hT3*e--*ed7Rz)#>Gfnb9$XYBKH;0H0jPpXj4uM~VW zZyB^c{lU&-q#Nf<5@X>`6k?h=6rCfGa(W8agzipegQ6Hv$|W{v+5sVt-dh50nExv7 zEI-#~I@Fi79yC-{I^5}P^809~{$F12duMPmaOZ62mLJNb-+xLNRG=I3K#c!AIu-Qs zIy!ZuIJZ65oKX59Ec5a1G8z@D$hYL7a3yvQSj7sFaM}Jz1RkrR)W_@@$Dyu)r)l!I z{alSt#?Clq)letdRE5JU6SuG19K*ttFOmWCkZ_O_B=-*keGPk$dV&m~%`DwMQ5C)q z*3L{5i{|`$O>U4BuBh-q9C9pC0cgXX;CaIN0IlxK5B!-yy!)7W*&YM;S0lwGa)b*N zE7cv!&%BrRutk?669mp?a2h$_{&+%&Nm!4&famH2`gBV^h7>X>>XKtDA=A@vm@7`R z=hMn5vt@NM;h3_4%BY}6d-8gJ^HQx(Y2lvZQ?}-Cmj<`(+S9#Dh2Em%V@FG6D!Sm} zg%Lp^q1wFuYvi+`(jEQ6T%$QWO_R;G;c0ghTYo&W1hycc-Z0R5{71{GB#r ztxN&b>FE1}Nx3KyhSE>G&!^T#4%!lh7XAb5@*%kYREHwieYao~ddDOCPYHHmO_B_8 zR$hVqLy5;wl&=M9r==R(k@4R9MH?#WT_BRqKBgq@LK&D}jSmz_O;Z<)FR}Yw%7y zG%X7%0f={{lV=&L+_0H+{Nc+Ve6NI)V$5kNCef!*$tTL zc86yWK|xYJettn{1JW`+hY)A(xjQDt}1E{W*dung~KT{sLCV7@wBKp`<)Q z@-AOKmoU@h$8#kw%UMnsqs_LQy))tGl|f*SiI{nU)P**`^3ycQ2T6E=lP1yzlTLe; znansJLNvC)0DX6xZ~8BRqDCtg(bq7LsKQB};B#H_&4HudAt#q@;wG^o!o!TFFTan{ z$ZG4*OhtM<+pc#nxjBzR%{UHW@;IC38+W7`5JUgQ+AhaN^UJo$8n1iZ#fGkzd8uu9 zkyFElSoACV#2!s&VTkehN$A@nCoR49>ydLIDP%*`ZO-ZYOY!6PH#zmnA9s(r#8QH% zxU|S#zd(6mmSeS#n(Mz~F54Z8;rw>?kUtVq)mrA5B5XHYtjXq*y3v^$PHz10d^sYe zQTopTUe(pwNzgDmPyOWfP4A$wqlHS*OKzff`Gb1TP}rgO;*w)acPEtGbr8dXJd+;j zIL?YH?=y*CBIGnEii3B5`RD_8d$(z*f%!{aTt!s3gr)2663tP=eL<#|RMf98OPhHi zP3)V*1mf~#q+P}JzKETeG_7Jz9XvX^3(BrmV)G5cfRP*xxo$rGmlbeuRy9_^;Mxlh zJ{v03BB|=%PpDMK&HIPmz$S~Cp4K$>N(#*Pr0Y*VB1@@DPB*~5B|lk^uT@{q>>ma~ zu|qVMHGo;~nqozK`oj<#rG_XGrV~tyMGRO-u(nRvUPCT=^p#-QVU?zFoQSTq{`$I% z%Hh<{J`<}XrLl*gHY>vmzkW!kxNVS^u0LV%y2jt2VzEI&Oh`dyxsJ&2-C6R$E?H44 zFs~Rr?S;Tw7;V7DeB*!1h37Q>HgJ^e!)7Pn5C5biKqo(DVw z3Xhf?eUh%}bHejfMD~1cK4ebLOE^sC$V(FYsoV#nV0hZ^q02cTQCvplb!L3I8Wsl8 z0zHl(8&xZavpkGBk}Pmdzx#ohmGd`S(PCsVN?r1*RlbO{k_Zm3CMF@V?HhaJ(_m^= zYNcWFSg;lDdYkD_EB1j9!?+cL9y11tjMqV2j*|ml8R3`<^aUu2t^|JWmzj{dKO5>M zHzTlvx7BQAOA)gT#=D5|ETPtnD%UUy%7ME%pfwE4hT!g%$xmrEsB(c+k>iYvD{ zEW?}NZ$PI%>WPMF)Z>y%nwNpUZ5N76vQ`Ikp5!dH`IgHN+H8dr^@gKNN@IPsxJiul z-hvgQ2I%E=x=>v&G;8Y}sGIH6f#QM4N-h4`*}Qe>x0HnN7YZHx6Bf`U+xYTJKu4*;MbJ+g?hG+@g literal 0 HcmV?d00001 From 15e1c1888ec7fc6cc89d1def55ac09c287e08e22 Mon Sep 17 00:00:00 2001 From: Sina Date: Mon, 13 Apr 2026 17:39:44 +0000 Subject: [PATCH 24/58] fix(litellm): stabilize retries and add async cleanup --- src/churro_ocr/_internal/litellm.py | 59 ++++++++++++++++++++++++++++- tests/test_internal_helpers.py | 36 +++++++++++++++++- 2 files changed, 91 insertions(+), 4 deletions(-) diff --git a/src/churro_ocr/_internal/litellm.py b/src/churro_ocr/_internal/litellm.py index 733d995..e2f57d2 100644 --- a/src/churro_ocr/_internal/litellm.py +++ b/src/churro_ocr/_internal/litellm.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import inspect import logging from collections.abc import Sequence from contextlib import suppress @@ -97,6 +98,59 @@ def configure_disk_cache(*, disk_cache_dir: str | Path) -> None: _DISK_CACHE_DIR = cache_dir +async def _close_async_resource(resource: object, *, seen: set[int]) -> None: + if resource is None: + return + resource_id = id(resource) + if resource_id in seen: + return + seen.add(resource_id) + + for method_name in ("close", "aclose"): + close_method = getattr(resource, method_name, None) + if not callable(close_method): + continue + with suppress(Exception): + result = close_method() + if inspect.isawaitable(result): + await result + return + + for attribute_name in ("client", "_client", "session"): + nested_resource = getattr(resource, attribute_name, None) + if nested_resource is not None: + await _close_async_resource(nested_resource, seen=seen) + + +async def close_litellm_async_clients() -> None: + """Best-effort cleanup for cached LiteLLM async clients and sessions.""" + try: + import litellm + except ImportError: + return + + litellm_any = cast(Any, litellm) + resources: list[object] = [] + + cache = getattr(litellm_any, "in_memory_llm_clients_cache", None) + cache_dict = getattr(cache, "cache_dict", None) + if isinstance(cache_dict, dict): + resources.extend(cache_dict.values()) + cache_dict.clear() + + for attribute_name in ("aclient_session", "client_session"): + session = getattr(litellm_any, attribute_name, None) + if session is None: + continue + resources.append(session) + with suppress(Exception): + setattr(litellm_any, attribute_name, None) + + seen: set[int] = set() + for resource in resources: + await _close_async_resource(resource, seen=seen) + + class LiteLLMTransport: """Shared LiteLLM transport for OCR and LLM page detection.""" @@ -187,7 +241,9 @@ async def complete_text( kwargs["response_format"] = {"type": "json_object"} if self._config.completion_kwargs: kwargs.update(self._config.completion_kwargs) - deadline = monotonic() + float(timeout_seconds) + request_timeout_seconds = float(timeout_seconds) + kwargs["timeout"] = request_timeout_seconds + deadline = monotonic() + request_timeout_seconds async def _run_completion() -> Any: attempt_kwargs = dict(kwargs) @@ -196,7 +252,6 @@ async def _run_completion() -> Any: raise TimeoutError( f"LiteLLM request exceeded the total timeout of {timeout_seconds} seconds." ) - attempt_kwargs["timeout"] = remaining_timeout_seconds return await asyncio.wait_for( acompletion(**attempt_kwargs), timeout=remaining_timeout_seconds, diff --git a/tests/test_internal_helpers.py b/tests/test_internal_helpers.py index 447d298..8109225 100644 --- a/tests/test_internal_helpers.py +++ b/tests/test_internal_helpers.py @@ -376,7 +376,7 @@ async def _always_fail() -> object: @pytest.mark.asyncio -async def test_transport_complete_text_uses_remaining_total_timeout_budget( +async def test_transport_complete_text_uses_stable_provider_timeout_with_total_timeout_budget( monkeypatch: pytest.MonkeyPatch, ) -> None: calls = {"acompletion": 0} @@ -435,7 +435,7 @@ async def _fake_retry_api_call( assert result == "ok" assert calls == {"acompletion": 2} - assert attempt_timeouts == [10.0, 7.0] + assert attempt_timeouts == [10.0, 10.0] assert captured_retry == { "operation_name": "LiteLLM request", "context": "for model 'example/model'", @@ -444,6 +444,38 @@ async def _fake_retry_api_call( } +@pytest.mark.asyncio +async def test_close_litellm_async_clients_closes_cached_async_clients( + monkeypatch: pytest.MonkeyPatch, +) -> None: + closed: list[str] = [] + + class FakeAsyncOpenAI: + async def close(self) -> None: + closed.append("client") + + class FakeAsyncHTTPClient: + async def aclose(self) -> None: + closed.append("http") + + fake_module = _make_fake_litellm_module(acompletion=lambda **_: None) + fake_litellm_module = cast(Any, fake_module) + fake_litellm_module.in_memory_llm_clients_cache = SimpleNamespace( + cache_dict={ + "openai": FakeAsyncOpenAI(), + "wrapper": SimpleNamespace(client=FakeAsyncHTTPClient()), + } + ) + fake_litellm_module.aclient_session = FakeAsyncHTTPClient() + monkeypatch.setitem(sys.modules, "litellm", fake_module) + + await litellm_module.close_litellm_async_clients() + + assert closed == ["client", "http", "http"] + assert fake_litellm_module.in_memory_llm_clients_cache.cache_dict == {} + assert fake_litellm_module.aclient_session is None + + @pytest.mark.asyncio async def test_transport_complete_text_enforces_wall_clock_timeout_budget( monkeypatch: pytest.MonkeyPatch, From e5b8971b604b5e1199d75c2d6229111c69965c47 Mon Sep 17 00:00:00 2001 From: Sina Date: Mon, 13 Apr 2026 17:40:42 +0000 Subject: [PATCH 25/58] fix(benchmark): close cached LiteLLM clients --- tests/test_tooling_benchmark.py | 15 ++++++++++ tooling/benchmarking/benchmark.py | 46 ++++++++++++++++++------------- 2 files changed, 42 insertions(+), 19 deletions(-) diff --git a/tests/test_tooling_benchmark.py b/tests/test_tooling_benchmark.py index 06c6ef1..c44ddc4 100644 --- a/tests/test_tooling_benchmark.py +++ b/tests/test_tooling_benchmark.py @@ -677,10 +677,23 @@ async def fake_predict(ds, options, *, total_pages): # noqa: ANN001 ] monkeypatch.setattr(benchmark, "_predict_texts", fake_predict) + cleanup_calls: list[str] = [] + call_order: list[str] = [] + + async def fake_cleanup() -> None: + cleanup_calls.append("closed") + call_order.append("cleanup") + + monkeypatch.setattr( + benchmark, + "close_litellm_async_clients", + fake_cleanup, + ) captured: dict[str, object] = {} def fake_compute_metrics(ds, predictions, output_prefix, elapsed_time): # noqa: ANN001 + call_order.append("compute_metrics") captured["dataset"] = ds captured["predictions"] = predictions captured["output_prefix"] = output_prefix @@ -709,6 +722,8 @@ def fake_compute_metrics(ds, predictions, output_prefix, elapsed_time): # noqa: assert captured["predictions"] == [{"text": "prediction", "metadata": {"raw_html": "

prediction

"}}] assert captured["output_prefix"] == str(tmp_path / "outputs") assert captured["elapsed_time"] == pytest.approx(3.5) + assert call_order == ["cleanup", "compute_metrics"] + assert cleanup_calls == ["closed"] def test_create_output_prefix_includes_subset_filters( diff --git a/tooling/benchmarking/benchmark.py b/tooling/benchmarking/benchmark.py index 5e426fa..0b96ab7 100644 --- a/tooling/benchmarking/benchmark.py +++ b/tooling/benchmarking/benchmark.py @@ -21,6 +21,7 @@ sys.path.remove(_REPO_SRC_PATH_STR) sys.path.insert(0, _REPO_SRC_PATH_STR) +from churro_ocr._internal.litellm import close_litellm_async_clients from churro_ocr._internal.logging import logger from churro_ocr.errors import ConfigurationError from churro_ocr.ocr import BatchOCRBackend, OCRBackend, OCRBackendLike @@ -507,26 +508,33 @@ async def run(options: BenchmarkOptions) -> int: if validation_status != 0: return validation_status - dataset_stream = _load_dataset(CHURRO_DATASET_ID, split=options.dataset_split) - dataset = _selected_dataset_examples(dataset_stream, options) - total_pages = getattr(dataset, "num_rows", None) - if not isinstance(total_pages, int): - total_pages = None - - output_prefix = create_output_prefix(options) - start_time = time() - evaluation_examples, predictions = await _predict_texts( - dataset, - options, - total_pages=total_pages, - ) - elapsed_time = time() - start_time + clients_closed = False + try: + dataset_stream = _load_dataset(CHURRO_DATASET_ID, split=options.dataset_split) + dataset = _selected_dataset_examples(dataset_stream, options) + total_pages = getattr(dataset, "num_rows", None) + if not isinstance(total_pages, int): + total_pages = None + + output_prefix = create_output_prefix(options) + start_time = time() + evaluation_examples, predictions = await _predict_texts( + dataset, + options, + total_pages=total_pages, + ) + elapsed_time = time() - start_time + await close_litellm_async_clients() + clients_closed = True - assert len(evaluation_examples) == len(predictions), ( - f"Mismatch in dataset size ({len(evaluation_examples)}) and predictions ({len(predictions)})." - ) - compute_metrics(evaluation_examples, predictions, output_prefix, elapsed_time) - return 0 + assert len(evaluation_examples) == len(predictions), ( + f"Mismatch in dataset size ({len(evaluation_examples)}) and predictions ({len(predictions)})." + ) + compute_metrics(evaluation_examples, predictions, output_prefix, elapsed_time) + return 0 + finally: + if not clients_closed: + await close_litellm_async_clients() def main(argv: list[str] | None = None) -> int: From b8bd19e8b86936604c1625b4e8575b212700361a Mon Sep 17 00:00:00 2001 From: Sina Date: Mon, 13 Apr 2026 17:41:26 +0000 Subject: [PATCH 26/58] fix(evaluation): avoid non-fork multiprocessing pools --- tests/test_tooling_support.py | 66 +++++++++++++++++++++++++++++ tooling/evaluation/evaluate_page.py | 18 ++++++++ 2 files changed, 84 insertions(+) diff --git a/tests/test_tooling_support.py b/tests/test_tooling_support.py index e8c87b8..b0a2fd9 100644 --- a/tests/test_tooling_support.py +++ b/tests/test_tooling_support.py @@ -310,6 +310,7 @@ def imap(self, func, iterable): # noqa: ANN001 return map(func, iterable) monkeypatch.setattr(evaluate_page_module, "initialize_metrics", fake_initialize_metrics) + monkeypatch.setattr(evaluate_page_module, "_should_use_multiprocessing_pool", lambda: True) monkeypatch.setattr(evaluate_page_module.multiprocessing, "cpu_count", lambda: 2) monkeypatch.setattr(evaluate_page_module.multiprocessing, "Pool", _FakePool) monkeypatch.setattr( @@ -361,6 +362,71 @@ def imap(self, func, iterable): # noqa: ANN001 assert [row["example_id"] for row in rows] == ["row-1", "row-2"] +def test_batch_evaluate_uses_in_process_path_when_multiprocessing_is_disabled( + monkeypatch: pytest.MonkeyPatch, +) -> None: + init_calls = 0 + + def fake_initialize_metrics() -> None: + nonlocal init_calls + init_calls += 1 + + def _unexpected_pool(*args: object, **kwargs: object) -> object: + del args, kwargs + raise AssertionError("multiprocessing pool should not be used") + + monkeypatch.setattr(evaluate_page_module, "initialize_metrics", fake_initialize_metrics) + monkeypatch.setattr(evaluate_page_module, "_should_use_multiprocessing_pool", lambda: False) + monkeypatch.setattr(evaluate_page_module.multiprocessing, "Pool", _unexpected_pool) + monkeypatch.setattr(evaluate_page_module, "tqdm", lambda iterable, **_kwargs: iterable) + monkeypatch.setattr( + evaluate_page_module, + "evaluate_page", + lambda inputs: cast( + "PageEvaluationResult", + { + "example_id": inputs[0]["example_id"], + "normalized_levenshtein_similarity": 1.0, + "is_empty": 0.0, + }, + ), + ) + + aggregate, rows = evaluate_page_module.batch_evaluate( + dataset=[ + cast( + "BenchmarkDatasetExample", + { + "image": "image", + "cleaned_transcription": "", + "dataset_id": "dataset-1", + "document_type": "print", + "example_id": "row-1", + "main_language": "English", + "main_script": "Latin", + }, + ), + cast( + "BenchmarkDatasetExample", + { + "image": "image", + "cleaned_transcription": "", + "dataset_id": "dataset-2", + "document_type": "print", + "example_id": "row-2", + "main_language": "English", + "main_script": "Latin", + }, + ), + ], + predicted_texts=["predicted-1", "predicted-2"], + ) + + assert init_calls == 1 + assert aggregate == {"normalized_levenshtein_similarity": 1.0, "is_empty": 0.0} + assert [row["example_id"] for row in rows] == ["row-1", "row-2"] + + def test_calculate_metrics_from_text_and_internal_error_fallback(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(evaluate_page_module, "initialize_metrics", lambda: None) monkeypatch.setattr( diff --git a/tooling/evaluation/evaluate_page.py b/tooling/evaluation/evaluate_page.py index 7c9038d..3819474 100644 --- a/tooling/evaluation/evaluate_page.py +++ b/tooling/evaluation/evaluate_page.py @@ -207,6 +207,14 @@ def aggregate_results( return averaged, results +def _should_use_multiprocessing_pool() -> bool: + """Use the process pool only when the runtime is already using fork.""" + try: + return multiprocessing.get_start_method() == "fork" + except RuntimeError: + return False + + def batch_evaluate( dataset: list[EvaluationExample], predicted_texts: list[str], @@ -217,6 +225,16 @@ def batch_evaluate( results = [evaluate_page(pair) for pair in zip(dataset, predicted_texts, strict=False)] return aggregate_results(results) + if not _should_use_multiprocessing_pool(): + results = list( + tqdm( + map(evaluate_page, zip(dataset, predicted_texts, strict=False)), + total=len(dataset), + mininterval=0.5, + ) + ) + return aggregate_results(results) + processes = min(8, max(1, multiprocessing.cpu_count())) with multiprocessing.Pool(processes=processes, initializer=initialize_metrics) as pool: results = list( From 471e939ec7348bc3b5af2fd01074ebed67cc033e Mon Sep 17 00:00:00 2001 From: Sina Date: Mon, 13 Apr 2026 17:42:38 +0000 Subject: [PATCH 27/58] fix(providers): harden OCR defaults and Infinity parsing --- src/churro_ocr/providers/hf.py | 5 ++++- src/churro_ocr/providers/specs.py | 17 ++++++++++++-- tests/test_hf_ocr.py | 37 +++++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 3 deletions(-) diff --git a/src/churro_ocr/providers/hf.py b/src/churro_ocr/providers/hf.py index c10e55d..f54a342 100644 --- a/src/churro_ocr/providers/hf.py +++ b/src/churro_ocr/providers/hf.py @@ -305,7 +305,10 @@ def _default_dots_ocr_1_5_model_kwargs() -> dict[str, object]: if not torch.cuda.is_available(): return model_kwargs - free_bytes, _ = torch.cuda.mem_get_info() + try: + free_bytes, _ = torch.cuda.mem_get_info() + except RuntimeError: + return model_kwargs free_gib = max(1, int(free_bytes / (1024**3)) - 1) if free_gib < 8: return {"dtype": "float32"} diff --git a/src/churro_ocr/providers/specs.py b/src/churro_ocr/providers/specs.py index e419e35..61c6ea2 100644 --- a/src/churro_ocr/providers/specs.py +++ b/src/churro_ocr/providers/specs.py @@ -60,7 +60,7 @@ TextPostprocessorResult = str | tuple[str, dict[str, Any]] TextPostprocessor = Callable[[str], TextPostprocessorResult] VisionInputBuilder = Callable[[OCRConversation], object] -DEFAULT_OCR_MAX_TOKENS = 20_000 +DEFAULT_OCR_MAX_TOKENS = 25_000 CHANDRA_OCR_MAX_TOKENS = 12_384 DEEPSEEK_OCR_2_MAX_TOKENS = 8_192 INFINITY_PARSER_7B_MAX_TOKENS = 8_192 @@ -142,6 +142,10 @@ def default_ocr_text_postprocessor(text: str) -> str: "<|system|>", "<|system|>:", } +_OUTER_FENCED_CODE_BLOCK_RE = re.compile( + r"^(?P`{3,}|~{3,})(?P[^\n]*)\n(?P.*)\n(?P=fence)$", + flags=re.DOTALL, +) def _strip_leading_chat_scaffold(text: str, *, prompts: Sequence[str]) -> str: @@ -178,6 +182,15 @@ def _strip_leading_chat_scaffold(text: str, *, prompts: Sequence[str]) -> str: return cleaned.strip() +def _strip_outer_fenced_code_block(text: str) -> str: + """Unwrap a single outer fenced code block while preserving its inner content.""" + cleaned = text.strip() + match = _OUTER_FENCED_CODE_BLOCK_RE.fullmatch(cleaned) + if match is None: + return cleaned + return match.group("body").strip() + + def olmocr_image_preprocessor(image: Image.Image) -> Image.Image: """Resize an image to olmOCR's expected 1288px longest side and normalize to RGB.""" return ensure_rgb( @@ -210,7 +223,7 @@ def infinity_parser_7b_text_postprocessor(text: str) -> TextPostprocessorResult: INFINITY_PARSER_7B_SYSTEM_PROMPT, ], ) - raw_markdown = cleaned.strip() + raw_markdown = _strip_outer_fenced_code_block(cleaned) return strip_rich_ocr_markup_to_plain_text(raw_markdown), { "raw_markdown": raw_markdown, } diff --git a/tests/test_hf_ocr.py b/tests/test_hf_ocr.py index d5691d1..0ed032c 100644 --- a/tests/test_hf_ocr.py +++ b/tests/test_hf_ocr.py @@ -298,6 +298,24 @@ def test_infinity_parser_text_postprocessor_strips_prompt_echo_and_preserves_raw } +def test_infinity_parser_text_postprocessor_strips_outer_markdown_fence() -> None: + processed = infinity_parser_7b_text_postprocessor( + f"{INFINITY_PARSER_7B_OCR_PROMPT}\n" + "assistant:\n" + "```markdown\n" + "169\n\n" + "které wětšj gsau nynj žigjejch;\n" + "```" + ) + assert isinstance(processed, tuple) + text, metadata = processed + + assert text == "169\n\nkteré wětšj gsau nynj žigjejch;" + assert metadata == { + "raw_markdown": "169\n\nkteré wětšj gsau nynj žigjejch;", + } + + def test_deepseek_ocr_2_text_postprocessor_strips_prompt_echo_and_stop_token() -> None: assert ( deepseek_ocr_2_text_postprocessor( @@ -2344,6 +2362,25 @@ def mem_get_info() -> tuple[int, int]: assert hf_module._default_dots_ocr_1_5_model_kwargs() == expected +def test_default_dots_ocr_1_5_model_kwargs_falls_back_when_mem_probe_fails( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class _FakeCuda: + @staticmethod + def is_available() -> bool: + return True + + @staticmethod + def mem_get_info() -> tuple[int, int]: + raise RuntimeError("cudaMemGetInfo failed") + + torch_module = ModuleType("torch") + cast(Any, torch_module).cuda = _FakeCuda + monkeypatch.setitem(sys.modules, "torch", torch_module) + + assert hf_module._default_dots_ocr_1_5_model_kwargs() == {"dtype": "auto"} + + @pytest.mark.asyncio async def test_huggingface_vision_ocr_backend_batches_pages_with_custom_vision_inputs( monkeypatch: pytest.MonkeyPatch, From b3f7262f4d033bdc8212472e5ada32ab66caf154 Mon Sep 17 00:00:00 2001 From: Sina Date: Mon, 13 Apr 2026 18:04:58 +0000 Subject: [PATCH 28/58] refactor(types): centralize shared OCR structural aliases --- src/churro_ocr/document.py | 24 +++++++++------- src/churro_ocr/ocr.py | 20 +++++++------ src/churro_ocr/page_detection.py | 34 ++++++++++++---------- src/churro_ocr/templates/base.py | 7 ++--- src/churro_ocr/templates/hf.py | 9 ++++-- src/churro_ocr/types.py | 48 ++++++++++++++++++++++++++++++++ 6 files changed, 102 insertions(+), 40 deletions(-) create mode 100644 src/churro_ocr/types.py diff --git a/src/churro_ocr/document.py b/src/churro_ocr/document.py index fca50c4..887bb0b 100644 --- a/src/churro_ocr/document.py +++ b/src/churro_ocr/document.py @@ -4,8 +4,7 @@ import asyncio from dataclasses import dataclass, field -from pathlib import Path -from typing import Any +from typing import TYPE_CHECKING from churro_ocr._internal.runtime import run_sync from churro_ocr.errors import ConfigurationError @@ -17,6 +16,11 @@ PageDetectionRequest, ) +if TYPE_CHECKING: + from pathlib import Path + + from churro_ocr.types import MetadataDict + @dataclass(slots=True) class DocumentOCRResult: @@ -29,7 +33,7 @@ class DocumentOCRResult: pages: list[DocumentPage] source_type: str - metadata: dict[str, Any] = field(default_factory=dict) + metadata: MetadataDict = field(default_factory=dict) def texts(self) -> list[str]: """Return OCR text for each page in order. @@ -89,7 +93,7 @@ async def process_image( self, request: PageDetectionRequest, *, - ocr_metadata: dict[str, Any] | None = None, + ocr_metadata: MetadataDict | None = None, ) -> DocumentOCRResult: """Detect pages and OCR a single input image. @@ -110,7 +114,7 @@ def process_image_sync( self, request: PageDetectionRequest, *, - ocr_metadata: dict[str, Any] | None = None, + ocr_metadata: MetadataDict | None = None, ) -> DocumentOCRResult: """Synchronously detect pages and OCR a single input image. @@ -127,7 +131,7 @@ async def process_pdf( *, dpi: int = 300, trim_margin: int = 30, - ocr_metadata: dict[str, Any] | None = None, + ocr_metadata: MetadataDict | None = None, ) -> DocumentOCRResult: """Rasterize, detect pages, and OCR a PDF. @@ -156,7 +160,7 @@ def process_pdf_sync( *, dpi: int = 300, trim_margin: int = 30, - ocr_metadata: dict[str, Any] | None = None, + ocr_metadata: MetadataDict | None = None, ) -> DocumentOCRResult: """Synchronously rasterize, detect pages, and OCR a PDF. @@ -180,8 +184,8 @@ async def _ocr_detection_result( self, detected_pages: list[DocumentPage], source_type: str, - metadata: dict[str, Any], - ocr_metadata: dict[str, Any] | None, + metadata: MetadataDict, + ocr_metadata: MetadataDict | None, ) -> DocumentOCRResult: semaphore = asyncio.Semaphore(self.max_concurrency) @@ -200,7 +204,7 @@ async def _ocr_page( self, page: DocumentPage, *, - ocr_metadata: dict[str, Any] | None, + ocr_metadata: MetadataDict | None, ) -> DocumentPage: page_metadata = dict(page.metadata) page_metadata.update(ocr_metadata or {}) diff --git a/src/churro_ocr/ocr.py b/src/churro_ocr/ocr.py index 2f6f5f7..b1946f8 100644 --- a/src/churro_ocr/ocr.py +++ b/src/churro_ocr/ocr.py @@ -4,16 +4,20 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass, field, replace -from pathlib import Path -from typing import Any, Protocol, runtime_checkable - -from PIL import Image +from typing import TYPE_CHECKING, Protocol, runtime_checkable from churro_ocr._internal.image import prepare_ocr_image from churro_ocr._internal.runtime import run_sync from churro_ocr.errors import ConfigurationError from churro_ocr.page_detection import DocumentPage +if TYPE_CHECKING: + from pathlib import Path + + from PIL import Image + + from churro_ocr.types import MetadataDict + @dataclass(slots=True) class OCRResult: @@ -28,7 +32,7 @@ class OCRResult: text: str provider_name: str model_name: str - metadata: dict[str, Any] = field(default_factory=dict) + metadata: MetadataDict = field(default_factory=dict) @runtime_checkable @@ -113,7 +117,7 @@ async def aocr_image( image_path: str | Path | None = None, page_index: int = 0, source_index: int = 0, - metadata: dict[str, Any] | None = None, + metadata: MetadataDict | None = None, ) -> DocumentPage: """Create a single page from an image input and OCR it. @@ -142,7 +146,7 @@ def ocr_image( image_path: str | Path | None = None, page_index: int = 0, source_index: int = 0, - metadata: dict[str, Any] | None = None, + metadata: MetadataDict | None = None, ) -> DocumentPage: """Create a single page from an image input and OCR it synchronously. @@ -172,7 +176,7 @@ def _page_from_image_input( image_path: str | Path | None, page_index: int, source_index: int, - metadata: dict[str, Any] | None, + metadata: MetadataDict | None, ) -> DocumentPage: if (image is None) == (image_path is None): raise ConfigurationError("OCR image helpers require exactly one of `image` or `image_path`.") diff --git a/src/churro_ocr/page_detection.py b/src/churro_ocr/page_detection.py index d0c6d86..59ec4ee 100644 --- a/src/churro_ocr/page_detection.py +++ b/src/churro_ocr/page_detection.py @@ -4,8 +4,7 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass, field, replace -from pathlib import Path -from typing import Any, Protocol, runtime_checkable +from typing import TYPE_CHECKING, Protocol, runtime_checkable from PIL import Image, ImageDraw @@ -14,6 +13,11 @@ from churro_ocr._internal.runtime import run_sync from churro_ocr.errors import ConfigurationError +if TYPE_CHECKING: + from pathlib import Path + + from churro_ocr.types import BoundingBox, MetadataDict, Polygon + @dataclass(slots=True) class PageCandidate: @@ -26,10 +30,10 @@ class PageCandidate: :param metadata: Detector-side metadata attached to the candidate. """ - bbox: tuple[float, float, float, float] | None = None + bbox: BoundingBox | None = None image: Image.Image | None = None - polygon: tuple[tuple[float, float], ...] = () - metadata: dict[str, Any] = field(default_factory=dict) + polygon: Polygon = () + metadata: MetadataDict = field(default_factory=dict) @dataclass(slots=True) @@ -51,13 +55,13 @@ class DocumentPage: page_index: int image: Image.Image source_index: int - bbox: tuple[float, float, float, float] | None = None - polygon: tuple[tuple[float, float], ...] = () - metadata: dict[str, Any] = field(default_factory=dict) + bbox: BoundingBox | None = None + polygon: Polygon = () + metadata: MetadataDict = field(default_factory=dict) text: str | None = None provider_name: str | None = None model_name: str | None = None - ocr_metadata: dict[str, Any] = field(default_factory=dict) + ocr_metadata: MetadataDict = field(default_factory=dict) @property def width(self) -> int: @@ -76,7 +80,7 @@ def from_image( *, page_index: int = 0, source_index: int = 0, - metadata: dict[str, Any] | None = None, + metadata: MetadataDict | None = None, ) -> DocumentPage: """Create a document page from an in-memory image. @@ -100,7 +104,7 @@ def from_image_path( *, page_index: int = 0, source_index: int = 0, - metadata: dict[str, Any] | None = None, + metadata: MetadataDict | None = None, ) -> DocumentPage: """Create a document page from an image path. @@ -123,7 +127,7 @@ def with_ocr( text: str, provider_name: str, model_name: str, - ocr_metadata: dict[str, Any] | None = None, + ocr_metadata: MetadataDict | None = None, ) -> DocumentPage: """Return a copy of the page with OCR output attached. @@ -183,7 +187,7 @@ class PageDetectionResult: pages: list[DocumentPage] source_type: str - metadata: dict[str, Any] = field(default_factory=dict) + metadata: MetadataDict = field(default_factory=dict) @runtime_checkable @@ -361,7 +365,7 @@ def detect_pdf_sync( def _crop_bbox( source_image: Image.Image, - bbox: tuple[float, float, float, float], + bbox: BoundingBox, *, trim_margin: int, ) -> Image.Image: @@ -375,7 +379,7 @@ def _crop_bbox( def _crop_polygon( source_image: Image.Image, - polygon: tuple[tuple[float, float], ...], + polygon: Polygon, *, trim_margin: int, ) -> Image.Image: diff --git a/src/churro_ocr/templates/base.py b/src/churro_ocr/templates/base.py index d9d2b18..747a574 100644 --- a/src/churro_ocr/templates/base.py +++ b/src/churro_ocr/templates/base.py @@ -3,11 +3,10 @@ from __future__ import annotations from collections.abc import Callable -from typing import Any, Protocol, runtime_checkable +from typing import Protocol, runtime_checkable from churro_ocr.page_detection import DocumentPage - -OCRConversation = list[dict[str, Any]] +from churro_ocr.types import OCRConversation @runtime_checkable @@ -48,9 +47,9 @@ def build_ocr_conversation(template: OCRPromptTemplateLike, page: DocumentPage) __all__ = [ - "build_ocr_conversation", "OCRConversation", "OCRPromptTemplate", "OCRPromptTemplateCallable", "OCRPromptTemplateLike", + "build_ocr_conversation", ] diff --git a/src/churro_ocr/templates/hf.py b/src/churro_ocr/templates/hf.py index 9032873..0c695d1 100644 --- a/src/churro_ocr/templates/hf.py +++ b/src/churro_ocr/templates/hf.py @@ -3,9 +3,12 @@ from __future__ import annotations from dataclasses import dataclass +from typing import TYPE_CHECKING -from churro_ocr.page_detection import DocumentPage -from churro_ocr.templates.base import OCRConversation +if TYPE_CHECKING: + from churro_ocr.page_detection import DocumentPage + from churro_ocr.templates.base import OCRConversation + from churro_ocr.types import OCRBuiltInConversationContentItem @dataclass(slots=True, frozen=True) @@ -38,7 +41,7 @@ def build_conversation(self, page: DocumentPage) -> OCRConversation: } ) - user_content: list[dict[str, object]] = [] + user_content: list[OCRBuiltInConversationContentItem] = [] if self.user_prompt and self.user_prompt_first: user_content.append({"type": "text", "text": self.user_prompt}) if self.include_image: diff --git a/src/churro_ocr/types.py b/src/churro_ocr/types.py new file mode 100644 index 0000000..455b86b --- /dev/null +++ b/src/churro_ocr/types.py @@ -0,0 +1,48 @@ +"""Shared structural type aliases for CHURRO public interfaces.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Literal, TypedDict + +if TYPE_CHECKING: + from PIL import Image + +# Provider metadata is forwarded through public results with backend-specific +# nested payloads, so keep this alias intentionally loose and centralize it. +type MetadataDict = dict[str, Any] +type BoundingBox = tuple[float, float, float, float] +type Polygon = tuple[tuple[float, float], ...] +type OCRRole = Literal["assistant", "system", "user"] +type OCRConversationContentItem = dict[str, Any] +type OCRConversationMessage = dict[str, Any] + + +class OCRImageContentItem(TypedDict): + """Built-in image content item used by chat-template OCR prompts.""" + + type: Literal["image"] + image: Image.Image + + +class OCRTextContentItem(TypedDict): + """Built-in text content item used by chat-template OCR prompts.""" + + type: Literal["text"] + text: str + + +type OCRBuiltInConversationContentItem = OCRImageContentItem | OCRTextContentItem +type OCRConversation = list[OCRConversationMessage] + +__all__ = [ + "BoundingBox", + "MetadataDict", + "OCRBuiltInConversationContentItem", + "OCRConversation", + "OCRConversationContentItem", + "OCRConversationMessage", + "OCRImageContentItem", + "OCRRole", + "OCRTextContentItem", + "Polygon", +] From bb87492053285abd94d8e249c31961b661c9694e Mon Sep 17 00:00:00 2001 From: Sina Date: Mon, 13 Apr 2026 18:05:47 +0000 Subject: [PATCH 29/58] refactor(metadata): reuse shared OCR metadata aliases --- src/churro_ocr/prompts/ocr.py | 13 ++++++++----- src/churro_ocr/providers/_shared.py | 13 ++++++++----- src/churro_ocr/providers/specs.py | 8 ++++---- tooling/evaluation/types.py | 11 +++++++---- 4 files changed, 27 insertions(+), 18 deletions(-) diff --git a/src/churro_ocr/prompts/ocr.py b/src/churro_ocr/prompts/ocr.py index 6729ff2..68f3084 100644 --- a/src/churro_ocr/prompts/ocr.py +++ b/src/churro_ocr/prompts/ocr.py @@ -4,7 +4,10 @@ import html import re -from typing import Any +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from churro_ocr.types import MetadataDict DEFAULT_OCR_OUTPUT_TAG = "output" @@ -119,7 +122,7 @@ def strip_ocr_output_tag(text: str, *, output_tag: str = DEFAULT_OCR_OUTPUT_TAG) return stray_tag_pattern.sub("", text).strip() -def _extract_yaml_front_matter(text: str) -> tuple[dict[str, object], str]: +def _extract_yaml_front_matter(text: str) -> tuple[MetadataDict, str]: """Return YAML front matter fields and the remaining markdown body.""" stripped = text.strip() if not stripped.startswith("---\n"): @@ -131,7 +134,7 @@ def _extract_yaml_front_matter(text: str) -> tuple[dict[str, object], str]: front_matter_block = stripped[4:end_index] body = stripped[end_index + 4 :].strip() - front_matter: dict[str, object] = {} + front_matter: MetadataDict = {} for line in front_matter_block.splitlines(): if ":" not in line: continue @@ -207,7 +210,7 @@ def strip_rich_ocr_markup_to_plain_text(text: str) -> str: return "\n".join(normalized_lines).strip() -def parse_olmocr_response(text: str) -> tuple[str, dict[str, Any]]: +def parse_olmocr_response(text: str) -> tuple[str, MetadataDict]: """Extract plain text and metadata from an olmOCR YAML-front-matter response.""" front_matter, markdown_body = _extract_yaml_front_matter(text) return strip_rich_ocr_markup_to_plain_text(markdown_body), { @@ -216,7 +219,7 @@ def parse_olmocr_response(text: str) -> tuple[str, dict[str, Any]]: } -def parse_chandra_response(text: str) -> tuple[str, dict[str, Any]]: +def parse_chandra_response(text: str) -> tuple[str, MetadataDict]: """Extract plain text and metadata from a Chandra HTML-layout response.""" raw_html = text.strip() return strip_rich_ocr_markup_to_plain_text(raw_html), { diff --git a/src/churro_ocr/providers/_shared.py b/src/churro_ocr/providers/_shared.py index 42f7f01..9b7b167 100644 --- a/src/churro_ocr/providers/_shared.py +++ b/src/churro_ocr/providers/_shared.py @@ -3,18 +3,21 @@ from __future__ import annotations from dataclasses import replace -from typing import Any +from typing import TYPE_CHECKING from churro_ocr.errors import ConfigurationError from churro_ocr.ocr import OCRResult -from churro_ocr.page_detection import DocumentPage -from churro_ocr.providers.specs import ImagePreprocessor, TextPostprocessor from churro_ocr.templates import ( OCRConversation, OCRPromptTemplateLike, build_ocr_conversation, ) +if TYPE_CHECKING: + from churro_ocr.page_detection import DocumentPage + from churro_ocr.providers.specs import ImagePreprocessor, TextPostprocessor + from churro_ocr.types import MetadataDict + def preprocess_backend_page( page: DocumentPage, @@ -74,11 +77,11 @@ def build_ocr_result( provider_name: str, model_name: str, text_postprocessor: TextPostprocessor, - metadata: dict[str, Any] | None = None, + metadata: MetadataDict | None = None, ) -> OCRResult: """Build a normalized OCR result after postprocessing.""" processed = text_postprocessor(text) - postprocessor_metadata: dict[str, Any] = {} + postprocessor_metadata: MetadataDict = {} if isinstance(processed, tuple): processed_text, postprocessor_metadata = processed else: diff --git a/src/churro_ocr/providers/specs.py b/src/churro_ocr/providers/specs.py index 61c6ea2..b60d439 100644 --- a/src/churro_ocr/providers/specs.py +++ b/src/churro_ocr/providers/specs.py @@ -5,8 +5,7 @@ import re from collections.abc import Callable, Sequence from dataclasses import dataclass, field -from pathlib import Path -from typing import TYPE_CHECKING, Any, Literal, cast +from typing import TYPE_CHECKING, Literal, cast from PIL import Image @@ -49,15 +48,16 @@ OCRConversation, OCRPromptTemplateLike, ) +from churro_ocr.types import MetadataDict if TYPE_CHECKING: - pass + from pathlib import Path OCRProvider = Literal["litellm", "openai-compatible", "azure", "mistral", "hf"] MistralOCRModel = Literal["mistral-ocr-2505", "mistral-ocr-2512"] ImagePreprocessor = Callable[[Image.Image], Image.Image] -TextPostprocessorResult = str | tuple[str, dict[str, Any]] +TextPostprocessorResult = str | tuple[str, MetadataDict] TextPostprocessor = Callable[[str], TextPostprocessorResult] VisionInputBuilder = Callable[[OCRConversation], object] DEFAULT_OCR_MAX_TOKENS = 25_000 diff --git a/tooling/evaluation/types.py b/tooling/evaluation/types.py index ede76f1..5fb5ea2 100644 --- a/tooling/evaluation/types.py +++ b/tooling/evaluation/types.py @@ -2,9 +2,12 @@ from __future__ import annotations -from typing import Any, TypedDict +from typing import TYPE_CHECKING, TypedDict -from PIL import Image +if TYPE_CHECKING: + from PIL import Image + +from churro_ocr.types import MetadataDict EVALUATION_EXAMPLE_FIELDS = ( "cleaned_transcription", @@ -65,13 +68,13 @@ class BenchmarkPrediction(TypedDict): """OCR output retained during benchmarking before metrics are computed.""" text: str - metadata: dict[str, Any] + metadata: MetadataDict class BenchmarkOutputRow(PageEvaluationResult): """Serialized benchmark output row written to ``outputs.json``.""" - metadata: dict[str, Any] + metadata: MetadataDict def to_evaluation_example(example: BenchmarkDatasetExample) -> EvaluationExample: From 658716979b575c7a8e8aea80720e4df8dc3036ab Mon Sep 17 00:00:00 2001 From: Sina Date: Mon, 13 Apr 2026 18:06:07 +0000 Subject: [PATCH 30/58] test(types): centralize shared fixture helper protocols --- tests/_types.py | 49 +++++++++++++++++++++++++++++++++++++++++++++++ tests/conftest.py | 14 +++++++++----- 2 files changed, 58 insertions(+), 5 deletions(-) create mode 100644 tests/_types.py diff --git a/tests/_types.py b/tests/_types.py new file mode 100644 index 0000000..ea1ee80 --- /dev/null +++ b/tests/_types.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Protocol + +if TYPE_CHECKING: + from pathlib import Path + +type RGBColor = tuple[int, int, int] +type RGBAColor = tuple[int, int, int, int] +type ImageColor = str | RGBColor | RGBAColor + + +class ImportFailurePatcher(Protocol): + def __call__( + self, + *, + failing_name: str, + exception_type: type[ImportError] = ImportError, + ) -> None: ... + + +class WriteImageFile(Protocol): + def __call__( + self, + *, + size: tuple[int, int] = (10, 10), + filename: str = "sample.png", + mode: str = "RGB", + color: ImageColor = "white", + ) -> Path: ... + + +class HasKey(Protocol): + key: str + + +class ReadableBody(Protocol): + def read(self) -> bytes: ... + + +__all__ = [ + "HasKey", + "ImageColor", + "ImportFailurePatcher", + "RGBAColor", + "RGBColor", + "ReadableBody", + "WriteImageFile", +] diff --git a/tests/conftest.py b/tests/conftest.py index c0310e3..2324071 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,13 +2,16 @@ import builtins import sys -from collections.abc import Callable from pathlib import Path +from typing import TYPE_CHECKING import pytest from PIL import Image from typer.testing import CliRunner +if TYPE_CHECKING: + from tests._types import ImageColor, ImportFailurePatcher, WriteImageFile + _TESTS_DIR = Path(__file__).resolve().parent _REPO_ROOT = _TESTS_DIR.parent _REPO_SRC_PATH = Path(__file__).resolve().parents[1] / "src" @@ -35,13 +38,13 @@ def test_artifact_dir_path() -> Path: @pytest.fixture -def write_image_file(tmp_path: Path) -> Callable[..., Path]: +def write_image_file(tmp_path: Path) -> WriteImageFile: def _write_image_file( *, size: tuple[int, int] = (10, 10), filename: str = "sample.png", mode: str = "RGB", - color: str | tuple[int, int, int] | tuple[int, int, int, int] = "white", + color: ImageColor = "white", ) -> Path: image_path = tmp_path / filename Image.new(mode, size, color=color).save(image_path) @@ -51,7 +54,7 @@ def _write_image_file( @pytest.fixture -def patch_import_failure(monkeypatch: pytest.MonkeyPatch) -> Callable[..., None]: +def patch_import_failure(monkeypatch: pytest.MonkeyPatch) -> ImportFailurePatcher: real_import = builtins.__import__ def _patch_import_failure( @@ -67,7 +70,8 @@ def _fake_import( level: int = 0, ) -> object: if name == failing_name: - raise exception_type(f"missing {failing_name}") + message = f"missing {failing_name}" + raise exception_type(message) return real_import(name, globals, locals, fromlist, level) monkeypatch.setattr(builtins, "__import__", _fake_import) From 0fa1147769f888a15ddb039c8ab4e58a06183586 Mon Sep 17 00:00:00 2001 From: Sina Date: Mon, 13 Apr 2026 18:10:11 +0000 Subject: [PATCH 31/58] build(typecheck): tighten lint and package check typing --- docs/conf.py | 14 +++++++-- ruff.toml | 28 ++++++++++++++++- scripts/package_check.py | 60 ++++++++++++++++++++++++------------- tests/test_package_check.py | 8 +++-- ty.toml | 16 ++++++++++ 5 files changed, 99 insertions(+), 27 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 9e8786a..1930332 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -2,10 +2,11 @@ from __future__ import annotations -from pathlib import Path import shutil import sys import tomllib +from pathlib import Path +from typing import Protocol DOCS_DIR = Path(__file__).resolve().parent ROOT = DOCS_DIR.parent @@ -100,7 +101,13 @@ } -def _copy_build_artifacts(app, exception) -> None: +class _SphinxApp(Protocol): + outdir: str + + def connect(self, event_name: str, callback: object) -> object: ... + + +def _copy_build_artifacts(app: _SphinxApp, exception: BaseException | None) -> None: if exception is not None: return @@ -114,5 +121,6 @@ def _copy_build_artifacts(app, exception) -> None: shutil.copytree(ROOT / "static", Path(app.outdir) / "static", dirs_exist_ok=True) -def setup(app) -> None: +def setup(app: _SphinxApp) -> None: + """Register the build-finished hook for copying static artifacts.""" app.connect("build-finished", _copy_build_artifacts) diff --git a/ruff.toml b/ruff.toml index 869de7c..009acf9 100644 --- a/ruff.toml +++ b/ruff.toml @@ -2,7 +2,33 @@ line-length = 110 required-version = "==0.15.9" [lint] -select = ["E", "F", "I", "B", "UP", "ASYNC", "SIM", "D"] +select = [ + "E", # pycodestyle errors + "F", # Pyflakes (unused imports, undefined names, etc.) + "I", # isort (import sorting/organization) + "N", # pep8-naming + "B", # flake8-bugbear (likely bugs and design issues) + "UP", # pyupgrade (modernize syntax for newer Python) + "ASYNC", # flake8-async (async/await correctness checks) + "BLE", # flake8-blind-except + "SIM", # flake8-simplify (suggest simpler code patterns) + "C4", # flake8-comprehensions + "D", # pydocstyle (docstring style and completeness) + "PIE", # flake8-pie + "PERF", # Perflint + "PTH", # flake8-use-pathlib + "PT", # flake8-pytest-style + "RET504", # flake8-return: unnecessary variable assignment before return + "ARG", # flake8-unused-arguments + "ANN", # flake8-annotations + "TRY", # tryceratops + "RUF100", # Ruff-specific: unused noqa directives + "RUF022", # Ruff-specific: __all__ sorting/format consistency + "RUF023", # Ruff-specific: __slots__ should be a tuple/string literal + "RUF043", # Ruff-specific: detect potentially invalid regex patterns/usages + "RUF046", # Ruff-specific: detect unnecessary cast/collection conversion patterns + "TC", # flake8-type-checking +] [format] docstring-code-format = true diff --git a/scripts/package_check.py b/scripts/package_check.py index 6dd41cd..ed26301 100644 --- a/scripts/package_check.py +++ b/scripts/package_check.py @@ -10,12 +10,14 @@ import tempfile import zipfile from email import message_from_string -from email.message import Message from importlib import metadata from pathlib import Path +from typing import TYPE_CHECKING -from packaging.requirements import InvalidRequirement -from packaging.requirements import Requirement +from packaging.requirements import InvalidRequirement, Requirement + +if TYPE_CHECKING: + from email.message import Message ROOT = Path(__file__).resolve().parents[1] DIST_DIR = ROOT / "dist" @@ -69,6 +71,10 @@ def _run(*args: str, cwd: Path | None = None) -> str: return completed.stdout +def _package_check_error(message: str) -> RuntimeError: + return RuntimeError(message) + + def _remove_if_exists(path: Path) -> None: if path.is_dir(): shutil.rmtree(path) @@ -87,7 +93,8 @@ def _build_distributions() -> tuple[Path, Path]: wheel = next(DIST_DIR.glob("*.whl"), None) sdist = next(DIST_DIR.glob("*.tar.gz"), None) if wheel is None or sdist is None: - raise RuntimeError("Expected both wheel and sdist artifacts in dist/.") + message = "Expected both wheel and sdist artifacts in dist/." + raise _package_check_error(message) return wheel, sdist @@ -109,20 +116,24 @@ def _twine_check(wheel: Path, sdist: Path) -> None: def _assert_metadata(metadata_message: Message, entry_points_text: str) -> None: name = metadata_message["Name"] if name != "churro-ocr": - raise RuntimeError(f"Unexpected package name {name!r}.") + message = f"Unexpected package name {name!r}." + raise _package_check_error(message) if metadata_message["Requires-Python"] != ">=3.12": - raise RuntimeError("Requires-Python metadata no longer matches the documented support policy.") + message = "Requires-Python metadata no longer matches the documented support policy." + raise _package_check_error(message) project_urls: dict[str, str] = {} for raw_value in metadata_message.get_all("Project-URL", []): label, value = raw_value.split(", ", maxsplit=1) project_urls[label] = value if project_urls != EXPECTED_PROJECT_URLS: - raise RuntimeError(f"Project URLs do not match the expected package repository: {project_urls!r}.") + message = f"Project URLs do not match the expected package repository: {project_urls!r}." + raise _package_check_error(message) provides_extra = set(metadata_message.get_all("Provides-Extra", [])) if provides_extra != EXPECTED_EXTRAS: - raise RuntimeError(f"Unexpected extras set: {sorted(provides_extra)!r}.") + message = f"Unexpected extras set: {sorted(provides_extra)!r}." + raise _package_check_error(message) _assert_local_runtime_packaging_policy(metadata_message) @@ -130,7 +141,8 @@ def _assert_metadata(metadata_message: Message, entry_points_text: str) -> None: "[console_scripts]" not in entry_points_text or "churro-ocr = churro_ocr.cli:main" not in entry_points_text ): - raise RuntimeError("Console script entry point is missing or incorrect.") + message = "Console script entry point is missing or incorrect." + raise _package_check_error(message) def _iter_requirements_for_extra(metadata_message: Message, extra: str) -> list[Requirement]: @@ -155,10 +167,11 @@ def _assert_local_runtime_packaging_policy(metadata_message: Message) -> None: disallowed_runtime_reqs.append(f"{extra}:{requirement}") if disallowed_runtime_reqs: formatted = ", ".join(sorted(disallowed_runtime_reqs)) - raise RuntimeError( + message = ( "PyPI extras for active-environment runtimes must not pin local PyTorch or vLLM runtimes. " f"Found disallowed requirements: {formatted}." ) + raise _package_check_error(message) def _assert_runtime_only_artifacts(wheel: Path, sdist: Path) -> None: @@ -167,18 +180,22 @@ def _assert_runtime_only_artifacts(wheel: Path, sdist: Path) -> None: for name in wheel_names: normalized = f"/{name}" if any(segment in normalized for segment in FORBIDDEN_ARTIFACT_SEGMENTS): - raise RuntimeError(f"Wheel unexpectedly includes repo-only content: {name}") + message = f"Wheel unexpectedly includes repo-only content: {name}" + raise _package_check_error(message) if any(normalized.endswith(suffix) for suffix in FORBIDDEN_ARTIFACT_SUFFIXES): - raise RuntimeError(f"Wheel unexpectedly includes repo-only documentation: {name}") + message = f"Wheel unexpectedly includes repo-only documentation: {name}" + raise _package_check_error(message) with tarfile.open(sdist) as tar_file: sdist_names = tar_file.getnames() for name in sdist_names: normalized = f"/{name}" if any(segment in normalized for segment in FORBIDDEN_ARTIFACT_SEGMENTS): - raise RuntimeError(f"sdist unexpectedly includes repo-only content: {name}") + message = f"sdist unexpectedly includes repo-only content: {name}" + raise _package_check_error(message) if any(normalized.endswith(suffix) for suffix in FORBIDDEN_ARTIFACT_SUFFIXES): - raise RuntimeError(f"sdist unexpectedly includes repo-only documentation: {name}") + message = f"sdist unexpectedly includes repo-only documentation: {name}" + raise _package_check_error(message) def _venv_python(venv_dir: Path) -> Path: @@ -198,10 +215,12 @@ def _smoke_install(requirement: str, *, label: str, import_check: str) -> None: _run(str(python), "-m", "pip", "install", requirement, cwd=workspace_dir) _run(str(python), "-c", import_check, cwd=workspace_dir) if (workspace_dir / "debug.log").exists(): - raise RuntimeError(f"{label} created an unexpected debug.log file.") + message = f"{label} created an unexpected debug.log file." + raise _package_check_error(message) _run(str(python), "-m", "churro_ocr", "--help", cwd=workspace_dir) if (workspace_dir / "debug.log").exists(): - raise RuntimeError(f"{label} CLI help created an unexpected debug.log file.") + message = f"{label} CLI help created an unexpected debug.log file." + raise _package_check_error(message) def _requirement_name(requirement: str) -> str | None: @@ -294,14 +313,15 @@ def _audit_dependency_licenses(metadata_message: Message) -> None: unknown.append(f"{dependency_name}=={distribution.version}") if incompatible: - raise RuntimeError( - "Incompatible direct dependency licenses detected: " + ", ".join(incompatible) + "." - ) + message = "Incompatible direct dependency licenses detected: " + ", ".join(incompatible) + "." + raise _package_check_error(message) if unknown: - raise RuntimeError("Unknown direct dependency licenses detected: " + ", ".join(unknown) + ".") + message = "Unknown direct dependency licenses detected: " + ", ".join(unknown) + "." + raise _package_check_error(message) def main() -> int: + """Build artifacts, validate metadata, and smoke-test package installs.""" print("==> Cleaning build artifacts") _clean_build_artifacts() diff --git a/tests/test_package_check.py b/tests/test_package_check.py index a4dfcfa..ed200fb 100644 --- a/tests/test_package_check.py +++ b/tests/test_package_check.py @@ -2,11 +2,13 @@ from importlib import metadata as importlib_metadata from importlib.util import module_from_spec, spec_from_file_location from pathlib import Path +from types import ModuleType +from typing import Never import pytest -def _load_package_check_module(): +def _load_package_check_module() -> ModuleType: path = Path(__file__).resolve().parents[1] / "scripts" / "package_check.py" spec = spec_from_file_location("package_check", path) assert spec is not None @@ -27,7 +29,7 @@ def _metadata_message(*requirements: str) -> Message: def test_license_audit_skips_missing_optional_extra_dependency(monkeypatch: pytest.MonkeyPatch) -> None: - def _always_missing(_: str): + def _always_missing(_: str) -> Never: raise importlib_metadata.PackageNotFoundError monkeypatch.setattr(package_check.metadata, "distribution", _always_missing) @@ -36,7 +38,7 @@ def _always_missing(_: str): def test_license_audit_fails_for_missing_base_dependency(monkeypatch: pytest.MonkeyPatch) -> None: - def _always_missing(_: str): + def _always_missing(_: str) -> Never: raise importlib_metadata.PackageNotFoundError monkeypatch.setattr(package_check.metadata, "distribution", _always_missing) diff --git a/ty.toml b/ty.toml index 4175e6d..c55328d 100644 --- a/ty.toml +++ b/ty.toml @@ -1,5 +1,21 @@ [src] include = ["src", "tests"] +[rules] +unused-ignore-comment = "error" +unused-type-ignore-comment = "error" +redundant-cast = "error" +possibly-unresolved-reference = "warn" + +[environment] +python-version = "3.12" + [terminal] +error-on-warning = true output-format = "concise" + +[[overrides]] +include = ["tests/**"] + +[overrides.rules] +possibly-unresolved-reference = "ignore" From 26b20f2ddae9ecd06c0e65e501d185c64f9c352f Mon Sep 17 00:00:00 2001 From: Sina Date: Mon, 13 Apr 2026 18:10:16 +0000 Subject: [PATCH 32/58] refactor(core): tighten CLI and helper typing --- src/churro_ocr/__init__.py | 8 +- src/churro_ocr/_internal/image.py | 7 +- src/churro_ocr/_internal/install.py | 20 +- src/churro_ocr/_internal/logging.py | 65 +++- src/churro_ocr/_internal/pdf.py | 23 +- src/churro_ocr/_internal/prompt_logging.py | 7 +- src/churro_ocr/_internal/retry.py | 7 +- src/churro_ocr/_internal/runtime.py | 13 +- src/churro_ocr/cli.py | 46 ++- src/churro_ocr/prompts/__init__.py | 2 +- src/churro_ocr/templates/__init__.py | 16 +- src/churro_ocr/templates/presets.py | 14 +- tests/test_cli.py | 371 +++++++-------------- tests/test_cli_contract.py | 25 +- tests/test_install.py | 20 +- tests/test_internal_helpers.py | 109 +++--- tests/test_public_api_contracts.py | 22 +- 17 files changed, 380 insertions(+), 395 deletions(-) diff --git a/src/churro_ocr/__init__.py b/src/churro_ocr/__init__.py index 3234379..48ec16e 100644 --- a/src/churro_ocr/__init__.py +++ b/src/churro_ocr/__init__.py @@ -21,21 +21,21 @@ __all__ = [ "CHURRO_3B_MODEL_ID", "CHURRO_3B_XML_TEMPLATE", + "DEFAULT_OCR_TEMPLATE", "BatchOCRBackend", "ChurroError", "ConfigurationError", - "DocumentPage", "DocumentOCRPipeline", "DocumentOCRResult", + "DocumentPage", "DocumentPageDetector", - "DEFAULT_OCR_TEMPLATE", "OCRBackend", "OCRClient", "OCRResult", - "PageDetectionBackend", - "PageDetector", "PageCandidate", + "PageDetectionBackend", "PageDetectionRequest", "PageDetectionResult", + "PageDetector", "ProviderError", ] diff --git a/src/churro_ocr/_internal/image.py b/src/churro_ocr/_internal/image.py index 7a915a8..9f7a765 100644 --- a/src/churro_ocr/_internal/image.py +++ b/src/churro_ocr/_internal/image.py @@ -13,11 +13,16 @@ MAX_INLINE_IMAGE_DIM = 2_500 +def _configuration_error(message: str) -> ConfigurationError: + return ConfigurationError(message) + + def load_image(path: str | Path) -> Image.Image: """Load an image from disk and normalize EXIF orientation.""" resolved = Path(path) if not resolved.exists(): - raise ConfigurationError(f"Image path does not exist: {resolved}") + message = f"Image path does not exist: {resolved}" + raise _configuration_error(message) with Image.open(resolved) as image: normalized = ImageOps.exif_transpose(image) assert normalized is not None diff --git a/src/churro_ocr/_internal/install.py b/src/churro_ocr/_internal/install.py index e1e7ff0..336039f 100644 --- a/src/churro_ocr/_internal/install.py +++ b/src/churro_ocr/_internal/install.py @@ -34,6 +34,10 @@ _PYTORCH_PACKAGES: Final[tuple[str, ...]] = ("torch", "torchvision") +def _configuration_error(message: str) -> ConfigurationError: + return ConfigurationError(message) + + @dataclass(frozen=True, slots=True) class RuntimeInstallResult: """Summary of a completed runtime installation.""" @@ -57,7 +61,8 @@ def install_runtime_dependencies( normalized_target = target.strip().lower() if normalized_target not in INSTALL_TARGETS: supported = ", ".join(INSTALL_TARGETS) - raise ConfigurationError(f"Unknown install target '{target}'. Choose one of: {supported}.") + message = f"Unknown install target '{target}'. Choose one of: {supported}." + raise _configuration_error(message) uv_executable = _require_uv_executable() executed_commands: list[tuple[str, ...]] = [] @@ -106,9 +111,8 @@ def install_runtime_dependencies( def _require_uv_executable() -> str: uv_executable = shutil.which("uv") if uv_executable is None: - raise ConfigurationError( - "`churro-ocr install` requires `uv` on PATH. Install uv and rerun the command." - ) + message = "`churro-ocr install` requires `uv` on PATH. Install uv and rerun the command." + raise _configuration_error(message) return uv_executable @@ -116,9 +120,8 @@ def _distribution_requirements() -> list[str]: try: distribution = metadata.distribution(PROJECT_DISTRIBUTION_NAME) except metadata.PackageNotFoundError as exc: # pragma: no cover - depends on install mode - raise ConfigurationError( - "The Churro installer must run from an installed `churro-ocr` environment." - ) from exc + message = "The Churro installer must run from an installed `churro-ocr` environment." + raise _configuration_error(message) from exc return list(distribution.requires or []) @@ -141,5 +144,6 @@ def _run_command(command: list[str]) -> tuple[str, ...]: subprocess.run(command, check=True) except (OSError, subprocess.CalledProcessError) as exc: rendered_command = " ".join(command) - raise ConfigurationError(f"Command failed: {rendered_command}") from exc + message = f"Command failed: {rendered_command}" + raise _configuration_error(message) from exc return tuple(command) diff --git a/src/churro_ocr/_internal/logging.py b/src/churro_ocr/_internal/logging.py index f11de42..6b8b4ac 100644 --- a/src/churro_ocr/_internal/logging.py +++ b/src/churro_ocr/_internal/logging.py @@ -2,20 +2,43 @@ from __future__ import annotations -from typing import Any +from typing import TYPE_CHECKING, Protocol, cast from loguru import logger as _loguru_logger from rich.logging import RichHandler +if TYPE_CHECKING: + from collections.abc import Callable -def _configure_default_logger() -> Any: + +class _RichLoggerLike(Protocol): + def bind(self, **kwargs: object) -> _RichLoggerLike: ... + + def debug(self, message: str) -> None: ... + + def info(self, message: str) -> None: ... + + def success(self, message: str) -> None: ... + + def warning(self, message: str) -> None: ... + + def error(self, message: str) -> None: ... + + def critical(self, message: str) -> None: ... + + def exception(self, message: str) -> None: ... + + def log(self, level: str, message: str) -> None: ... + + +def _configure_default_logger() -> _RichLoggerLike: _loguru_logger.remove() _loguru_logger.add( RichHandler(markup=True, show_time=False), level="WARNING", format="{message}", ) - return _loguru_logger + return cast("_RichLoggerLike", _loguru_logger) _default_logger = _configure_default_logger().bind(app="churro-ocr") @@ -26,39 +49,53 @@ class _LoggerAdapter: __slots__ = ("_logger",) - def __init__(self, wrapped_logger: Any) -> None: + def __init__(self, wrapped_logger: object) -> None: self._logger = wrapped_logger def _format(self, message: str, *args: object) -> str: return message % args if args else message + def _message_logger(self, method_name: str) -> Callable[[str], object]: + logger_method = getattr(self._logger, method_name, None) + if callable(logger_method): + return cast("Callable[[str], object]", logger_method) + message = f"Wrapped logger does not define `{method_name}(...)`." + raise AttributeError(message) + + def _level_logger(self) -> Callable[[str, str], object]: + logger_method = getattr(self._logger, "log", None) + if callable(logger_method): + return cast("Callable[[str, str], object]", logger_method) + message = "Wrapped logger does not define `log(level, message)`." + raise AttributeError(message) + def debug(self, message: str, *args: object) -> None: - self._logger.debug(self._format(message, *args)) + self._message_logger("debug")(self._format(message, *args)) def info(self, message: str, *args: object) -> None: - self._logger.info(self._format(message, *args)) + self._message_logger("info")(self._format(message, *args)) def success(self, message: str, *args: object) -> None: success = getattr(self._logger, "success", None) - if success is not None: - success(self._format(message, *args)) + if callable(success): + cast("Callable[[str], object]", success)(self._format(message, *args)) return - self._logger.info(self._format(message, *args)) + self._message_logger("info")(self._format(message, *args)) def warning(self, message: str, *args: object) -> None: - self._logger.warning(self._format(message, *args)) + self._message_logger("warning")(self._format(message, *args)) def error(self, message: str, *args: object) -> None: - self._logger.error(self._format(message, *args)) + self._message_logger("error")(self._format(message, *args)) def critical(self, message: str, *args: object) -> None: - self._logger.critical(self._format(message, *args)) + self._message_logger("critical")(self._format(message, *args)) def exception(self, message: str, *args: object) -> None: - self._logger.exception(self._format(message, *args)) + self._message_logger("exception")(self._format(message, *args)) def log(self, level: str, message: str, *args: object) -> None: - self._logger.log(level, self._format(message, *args)) + self._level_logger()(level, self._format(message, *args)) logger = _LoggerAdapter(_default_logger) diff --git a/src/churro_ocr/_internal/pdf.py b/src/churro_ocr/_internal/pdf.py index 770819d..3d9d396 100644 --- a/src/churro_ocr/_internal/pdf.py +++ b/src/churro_ocr/_internal/pdf.py @@ -2,26 +2,35 @@ from __future__ import annotations -from pathlib import Path - -from PIL import Image +from typing import TYPE_CHECKING from churro_ocr._internal.install import install_command_hint from churro_ocr.errors import ConfigurationError +if TYPE_CHECKING: + from pathlib import Path + + from PIL import Image + + +def _configuration_error(message: str) -> ConfigurationError: + return ConfigurationError(message) + def rasterize_pdf(path: str | Path, *, dpi: int = 300) -> list[Image.Image]: """Rasterize a PDF into PIL images.""" + from pathlib import Path + try: import pypdfium2 except ImportError as exc: # pragma: no cover - depends on optional extra - raise ConfigurationError( - f"PDF support requires the `pdf` runtime. {install_command_hint('pdf')}" - ) from exc + message = f"PDF support requires the `pdf` runtime. {install_command_hint('pdf')}" + raise _configuration_error(message) from exc resolved = Path(path) if not resolved.exists(): - raise ConfigurationError(f"PDF path does not exist: {resolved}") + message = f"PDF path does not exist: {resolved}" + raise _configuration_error(message) images: list[Image.Image] = [] scale = max(dpi, 1) / 72.0 diff --git a/src/churro_ocr/_internal/prompt_logging.py b/src/churro_ocr/_internal/prompt_logging.py index 6c84906..312be83 100644 --- a/src/churro_ocr/_internal/prompt_logging.py +++ b/src/churro_ocr/_internal/prompt_logging.py @@ -4,14 +4,17 @@ import json from base64 import b64encode -from collections.abc import Callable -from threading import Lock +from typing import TYPE_CHECKING from PIL import Image from churro_ocr._internal.image import image_to_base64 from churro_ocr._internal.logging import logger +if TYPE_CHECKING: + from collections.abc import Callable + from threading import Lock + _IMAGE_PREVIEW_CHARS = 96 diff --git a/src/churro_ocr/_internal/retry.py b/src/churro_ocr/_internal/retry.py index 188280d..ffd7e83 100644 --- a/src/churro_ocr/_internal/retry.py +++ b/src/churro_ocr/_internal/retry.py @@ -43,6 +43,10 @@ type RetryPredicate = Callable[[BaseException], bool] +def _assertion_error(message: str) -> AssertionError: + return AssertionError(message) + + def _coerce_status_code(value: object) -> int | None: if isinstance(value, int): return value @@ -229,7 +233,8 @@ def _retry_wait_seconds(retry_state: RetryCallState) -> float: with attempt: return await fn() - raise AssertionError("AsyncRetrying exited without returning or raising.") + message = "AsyncRetrying exited without returning or raising." + raise _assertion_error(message) __all__ = [ diff --git a/src/churro_ocr/_internal/runtime.py b/src/churro_ocr/_internal/runtime.py index 24f3c5e..a182804 100644 --- a/src/churro_ocr/_internal/runtime.py +++ b/src/churro_ocr/_internal/runtime.py @@ -3,12 +3,18 @@ from __future__ import annotations import asyncio -from collections.abc import Coroutine -from typing import Any, TypeVar +from typing import TYPE_CHECKING, Any, TypeVar + +if TYPE_CHECKING: + from collections.abc import Coroutine T = TypeVar("T") +def _runtime_error(message: str) -> RuntimeError: + return RuntimeError(message) + + def run_sync[T](awaitable: Coroutine[Any, Any, T]) -> T: """Run an awaitable from sync code. @@ -19,6 +25,7 @@ def run_sync[T](awaitable: Coroutine[Any, Any, T]) -> T: asyncio.get_running_loop() except RuntimeError: return asyncio.run(awaitable) - raise RuntimeError( + message = ( "Synchronous churro-ocr APIs cannot be used from an active event loop. Use the async API instead." ) + raise _runtime_error(message) diff --git a/src/churro_ocr/cli.py b/src/churro_ocr/cli.py index 70f061c..790a6bb 100644 --- a/src/churro_ocr/cli.py +++ b/src/churro_ocr/cli.py @@ -2,7 +2,7 @@ from __future__ import annotations -from pathlib import Path +from pathlib import Path # noqa: TC003 - Typer evaluates these annotations at runtime. import typer @@ -11,8 +11,13 @@ install_runtime_dependencies, ) from churro_ocr.errors import ConfigurationError -from churro_ocr.ocr import OCRClient -from churro_ocr.page_detection import DocumentPage, DocumentPageDetector, PageDetectionRequest +from churro_ocr.ocr import OCRBackend, OCRClient +from churro_ocr.page_detection import ( + DocumentPage, + DocumentPageDetector, + PageDetectionBackendLike, + PageDetectionRequest, +) from churro_ocr.providers import ( AzureDocumentIntelligenceOptions, AzurePageDetector, @@ -34,6 +39,10 @@ ) +def _bad_parameter(message: str) -> typer.BadParameter: + return typer.BadParameter(message) + + def _build_ocr_backend( *, backend: str, @@ -42,10 +51,11 @@ def _build_ocr_backend( api_key: str | None, base_url: str | None, api_version: str | None, -): +) -> OCRBackend: if backend == "litellm": if not model: - raise typer.BadParameter("--model is required for backend=litellm") + message = "--model is required for backend=litellm" + raise _bad_parameter(message) return build_ocr_backend( OCRBackendSpec( provider="litellm", @@ -59,7 +69,8 @@ def _build_ocr_backend( ) if backend == "openai-compatible": if not model or not base_url: - raise typer.BadParameter("--model and --base-url are required for backend=openai-compatible") + message = "--model and --base-url are required for backend=openai-compatible" + raise _bad_parameter(message) return build_ocr_backend( OCRBackendSpec( provider="openai-compatible", @@ -74,7 +85,8 @@ def _build_ocr_backend( ) if backend == "azure": if not endpoint or not api_key: - raise typer.BadParameter("--endpoint and --api-key are required for backend=azure") + message = "--endpoint and --api-key are required for backend=azure" + raise _bad_parameter(message) return build_ocr_backend( OCRBackendSpec( provider="azure", @@ -87,7 +99,8 @@ def _build_ocr_backend( ) if backend == "mistral": if not api_key: - raise typer.BadParameter("--api-key is required for backend=mistral") + message = "--api-key is required for backend=mistral" + raise _bad_parameter(message) try: mistral_model = validate_mistral_ocr_model(model) except ConfigurationError as exc: @@ -101,7 +114,8 @@ def _build_ocr_backend( ) if backend == "hf": if not model: - raise typer.BadParameter("--model is required for backend=hf") + message = "--model is required for backend=hf" + raise _bad_parameter(message) return build_ocr_backend( OCRBackendSpec( provider="hf", @@ -109,7 +123,8 @@ def _build_ocr_backend( options=HuggingFaceOptions(model_kwargs={"device_map": "auto", "torch_dtype": "auto"}), ) ) - raise typer.BadParameter(f"Unsupported backend: {backend}") + message = f"Unsupported backend: {backend}" + raise _bad_parameter(message) def _build_page_detector( @@ -120,7 +135,7 @@ def _build_page_detector( api_key: str | None, base_url: str | None, api_version: str | None, -): +) -> PageDetectionBackendLike | None: transport = None if base_url or api_key or api_version: transport = LiteLLMTransportConfig( @@ -131,14 +146,16 @@ def _build_page_detector( detector_backend = None if page_detector == "llm": if not model: - raise typer.BadParameter("--model is required when --page-detector=llm") + message = "--model is required when --page-detector=llm" + raise _bad_parameter(message) detector_backend = LLMPageDetector( model=model, transport=transport, ) elif page_detector == "azure": if not endpoint or not api_key: - raise typer.BadParameter("--endpoint and --api-key are required when --page-detector=azure") + message = "--endpoint and --api-key are required when --page-detector=azure" + raise _bad_parameter(message) detector_backend = AzurePageDetector(endpoint=endpoint, api_key=api_key) return detector_backend @@ -207,7 +224,8 @@ def extract_pages_command( ) -> None: """Extract page crops as PNG files and print each written path.""" if (image is None) == (pdf is None): - raise typer.BadParameter("Provide exactly one of --image or --pdf.") + message = "Provide exactly one of --image or --pdf." + raise _bad_parameter(message) detector_backend = _build_page_detector( page_detector=page_detector, model=model, diff --git a/src/churro_ocr/prompts/__init__.py b/src/churro_ocr/prompts/__init__.py index 170e0b5..ac8a192 100644 --- a/src/churro_ocr/prompts/__init__.py +++ b/src/churro_ocr/prompts/__init__.py @@ -24,6 +24,6 @@ "OLMOCR_V4_YAML_PROMPT", "parse_chandra_response", "parse_olmocr_response", - "strip_rich_ocr_markup_to_plain_text", "strip_ocr_output_tag", + "strip_rich_ocr_markup_to_plain_text", ] diff --git a/src/churro_ocr/templates/__init__.py b/src/churro_ocr/templates/__init__.py index 257c3fc..acdf42c 100644 --- a/src/churro_ocr/templates/__init__.py +++ b/src/churro_ocr/templates/__init__.py @@ -50,11 +50,10 @@ ) __all__ = [ - "build_ocr_conversation", - "CHURRO_3B_MODEL_ID", - "CHURRO_3B_XML_TEMPLATE", "CHANDRA_OCR_2_MODEL_ID", "CHANDRA_OCR_2_OCR_TEMPLATE", + "CHURRO_3B_MODEL_ID", + "CHURRO_3B_XML_TEMPLATE", "DEEPSEEK_OCR_2_MODEL_ID", "DEEPSEEK_OCR_2_OCR_PROMPT", "DEEPSEEK_OCR_2_OCR_TEMPLATE", @@ -69,6 +68,8 @@ "INFINITY_PARSER_7B_OCR_PROMPT", "INFINITY_PARSER_7B_OCR_TEMPLATE", "INFINITY_PARSER_7B_SYSTEM_PROMPT", + "LFM2_5_VL_1_6B_MODEL_ID", + "LFM2_5_VL_1_6B_OCR_TEMPLATE", "MINERU2_5_2509_1_2B_FORMULA_PROMPT", "MINERU2_5_2509_1_2B_FORMULA_TEMPLATE", "MINERU2_5_2509_1_2B_IMAGE_ANALYSIS_PROMPT", @@ -81,17 +82,16 @@ "MINERU2_5_2509_1_2B_SYSTEM_PROMPT", "MINERU2_5_2509_1_2B_TABLE_PROMPT", "MINERU2_5_2509_1_2B_TABLE_TEMPLATE", - "LFM2_5_VL_1_6B_MODEL_ID", - "LFM2_5_VL_1_6B_OCR_TEMPLATE", + "OLMOCR_2_7B_1025_FP8_MODEL_ID", + "OLMOCR_2_7B_1025_MODEL_ID", + "OLMOCR_2_7B_1025_OCR_TEMPLATE", "PADDLEOCR_VL_1_5_MODEL_ID", "PADDLEOCR_VL_1_5_OCR_PROMPT", "PADDLEOCR_VL_1_5_OCR_TEMPLATE", "HFChatTemplate", - "OLMOCR_2_7B_1025_FP8_MODEL_ID", - "OLMOCR_2_7B_1025_MODEL_ID", - "OLMOCR_2_7B_1025_OCR_TEMPLATE", "OCRConversation", "OCRPromptTemplate", "OCRPromptTemplateCallable", "OCRPromptTemplateLike", + "build_ocr_conversation", ] diff --git a/src/churro_ocr/templates/presets.py b/src/churro_ocr/templates/presets.py index 970a4f5..82a92cb 100644 --- a/src/churro_ocr/templates/presets.py +++ b/src/churro_ocr/templates/presets.py @@ -107,10 +107,10 @@ __all__ = [ - "CHURRO_3B_MODEL_ID", - "CHURRO_3B_XML_TEMPLATE", "CHANDRA_OCR_2_MODEL_ID", "CHANDRA_OCR_2_OCR_TEMPLATE", + "CHURRO_3B_MODEL_ID", + "CHURRO_3B_XML_TEMPLATE", "DEEPSEEK_OCR_2_MODEL_ID", "DEEPSEEK_OCR_2_OCR_PROMPT", "DEEPSEEK_OCR_2_OCR_TEMPLATE", @@ -125,6 +125,8 @@ "INFINITY_PARSER_7B_OCR_PROMPT", "INFINITY_PARSER_7B_OCR_TEMPLATE", "INFINITY_PARSER_7B_SYSTEM_PROMPT", + "LFM2_5_VL_1_6B_MODEL_ID", + "LFM2_5_VL_1_6B_OCR_TEMPLATE", "MINERU2_5_2509_1_2B_FORMULA_PROMPT", "MINERU2_5_2509_1_2B_FORMULA_TEMPLATE", "MINERU2_5_2509_1_2B_IMAGE_ANALYSIS_PROMPT", @@ -137,12 +139,10 @@ "MINERU2_5_2509_1_2B_SYSTEM_PROMPT", "MINERU2_5_2509_1_2B_TABLE_PROMPT", "MINERU2_5_2509_1_2B_TABLE_TEMPLATE", - "PADDLEOCR_VL_1_5_MODEL_ID", - "PADDLEOCR_VL_1_5_OCR_PROMPT", - "PADDLEOCR_VL_1_5_OCR_TEMPLATE", "OLMOCR_2_7B_1025_FP8_MODEL_ID", "OLMOCR_2_7B_1025_MODEL_ID", "OLMOCR_2_7B_1025_OCR_TEMPLATE", - "LFM2_5_VL_1_6B_MODEL_ID", - "LFM2_5_VL_1_6B_OCR_TEMPLATE", + "PADDLEOCR_VL_1_5_MODEL_ID", + "PADDLEOCR_VL_1_5_OCR_PROMPT", + "PADDLEOCR_VL_1_5_OCR_TEMPLATE", ] diff --git a/tests/test_cli.py b/tests/test_cli.py index 343cc66..f87372e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3,8 +3,8 @@ import shutil import subprocess import sys -from pathlib import Path from types import SimpleNamespace +from typing import TYPE_CHECKING, cast import pytest import typer @@ -35,18 +35,85 @@ PADDLEOCR_VL_1_5_OCR_TEMPLATE, ) +if TYPE_CHECKING: + from pathlib import Path + + from typer.testing import CliRunner + + from churro_ocr.page_detection import PageDetectionRequest + from churro_ocr.providers.hf import HuggingFaceVisionOCRBackend + from churro_ocr.providers.ocr import LiteLLMVisionOCRBackend, MistralOCRBackend + from churro_ocr.templates.hf import HFChatTemplate + from tests._types import WriteImageFile + + +def _build_litellm_backend(model: str) -> LiteLLMVisionOCRBackend: + return cast( + "LiteLLMVisionOCRBackend", + cli_module._build_ocr_backend( + backend="litellm", + model=model, + endpoint=None, + api_key=None, + base_url=None, + api_version=None, + ), + ) + + +def _build_hf_backend(model: str) -> HuggingFaceVisionOCRBackend: + return cast( + "HuggingFaceVisionOCRBackend", + cli_module._build_ocr_backend( + backend="hf", + model=model, + endpoint=None, + api_key=None, + base_url=None, + api_version=None, + ), + ) + + +def _build_openai_compatible_backend(model: str) -> LiteLLMVisionOCRBackend: + return cast( + "LiteLLMVisionOCRBackend", + cli_module._build_ocr_backend( + backend="openai-compatible", + model=model, + endpoint=None, + api_key=None, + base_url="http://127.0.0.1:8000/v1", + api_version=None, + ), + ) + + +def _build_mistral_backend(model: str) -> MistralOCRBackend: + return cast( + "MistralOCRBackend", + cli_module._build_ocr_backend( + backend="mistral", + model=model, + endpoint=None, + api_key="secret", + base_url=None, + api_version=None, + ), + ) + def test_transcribe_cli_writes_output( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, - cli_runner, - write_image_file, + cli_runner: CliRunner, + write_image_file: WriteImageFile, ) -> None: image_path = write_image_file(size=(10, 10)) output_path = tmp_path / "out.txt" class _FakeBackend: - async def ocr(self, page): # noqa: ANN001 + async def ocr(self, page: DocumentPage) -> OCRResult: image = page.image return OCRResult( text=f"ocr:{image.width}x{image.height}", @@ -74,8 +141,8 @@ async def ocr(self, page): # noqa: ANN001 def test_extract_pages_cli_writes_page_images( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, - cli_runner, - write_image_file, + cli_runner: CliRunner, + write_image_file: WriteImageFile, ) -> None: image_path = write_image_file(size=(10, 10)) output_dir = tmp_path / "pages" @@ -84,7 +151,7 @@ class _FakePageDetector: def __init__(self, **_: object) -> None: pass - def detect_image_sync(self, request): # noqa: ANN001 + def detect_image_sync(self, request: PageDetectionRequest) -> PageDetectionResult: _ = request.require_image() return PageDetectionResult( pages=[ @@ -115,40 +182,22 @@ def detect_image_sync(self, request): # noqa: ANN001 def test_build_ocr_backend_aligns_templates_for_generic_models() -> None: - litellm_backend = cli_module._build_ocr_backend( - backend="litellm", - model="example/model", - endpoint=None, - api_key=None, - base_url=None, - api_version=None, - ) - hf_backend = cli_module._build_ocr_backend( - backend="hf", - model="example/model", - endpoint=None, - api_key=None, - base_url=None, - api_version=None, - ) - openai_backend = cli_module._build_ocr_backend( - backend="openai-compatible", - model="example/model", - endpoint=None, - api_key=None, - base_url="http://127.0.0.1:8000/v1", - api_version=None, - ) + litellm_backend = _build_litellm_backend("example/model") + hf_backend = _build_hf_backend("example/model") + openai_backend = _build_openai_compatible_backend("example/model") assert litellm_backend.template == DEFAULT_OCR_TEMPLATE assert litellm_backend.template == hf_backend.template == openai_backend.template assert litellm_backend.model_name == "example/model" assert hf_backend.model_name == "example/model" assert openai_backend.model_name == "example/model" - assert f"<{DEFAULT_OCR_OUTPUT_TAG}>" in litellm_backend.template.system_message - assert f"" in litellm_backend.template.system_message - assert f"<{DEFAULT_OCR_OUTPUT_TAG}>" in litellm_backend.template.user_prompt - assert f"" in litellm_backend.template.user_prompt + template = cast("HFChatTemplate", litellm_backend.template) + assert template.system_message is not None + assert template.user_prompt is not None + assert f"<{DEFAULT_OCR_OUTPUT_TAG}>" in template.system_message + assert f"" in template.system_message + assert f"<{DEFAULT_OCR_OUTPUT_TAG}>" in template.user_prompt + assert f"" in template.user_prompt def test_build_ocr_backend_requires_pinned_mistral_model() -> None: @@ -164,43 +213,15 @@ def test_build_ocr_backend_requires_pinned_mistral_model() -> None: def test_build_ocr_backend_accepts_pinned_mistral_model() -> None: - mistral_backend = cli_module._build_ocr_backend( - backend="mistral", - model="mistral-ocr-2512", - endpoint=None, - api_key="secret", - base_url=None, - api_version=None, - ) + mistral_backend = _build_mistral_backend("mistral-ocr-2512") assert mistral_backend.model == "mistral-ocr-2512" def test_build_ocr_backend_aligns_templates_for_dots() -> None: - litellm_backend = cli_module._build_ocr_backend( - backend="litellm", - model="kristaller486/dots.ocr-1.5", - endpoint=None, - api_key=None, - base_url=None, - api_version=None, - ) - hf_backend = cli_module._build_ocr_backend( - backend="hf", - model="kristaller486/dots.ocr-1.5", - endpoint=None, - api_key=None, - base_url=None, - api_version=None, - ) - openai_backend = cli_module._build_ocr_backend( - backend="openai-compatible", - model="kristaller486/dots.ocr-1.5", - endpoint=None, - api_key=None, - base_url="http://127.0.0.1:8000/v1", - api_version=None, - ) + litellm_backend = _build_litellm_backend("kristaller486/dots.ocr-1.5") + hf_backend = _build_hf_backend("kristaller486/dots.ocr-1.5") + openai_backend = _build_openai_compatible_backend("kristaller486/dots.ocr-1.5") assert litellm_backend.template == DOTS_OCR_1_5_OCR_TEMPLATE assert litellm_backend.template == hf_backend.template == openai_backend.template @@ -218,30 +239,9 @@ def test_build_ocr_backend_aligns_templates_for_dots() -> None: def test_build_ocr_backend_aligns_templates_for_dots_mocr() -> None: - litellm_backend = cli_module._build_ocr_backend( - backend="litellm", - model="rednote-hilab/dots.mocr", - endpoint=None, - api_key=None, - base_url=None, - api_version=None, - ) - hf_backend = cli_module._build_ocr_backend( - backend="hf", - model="rednote-hilab/dots.mocr", - endpoint=None, - api_key=None, - base_url=None, - api_version=None, - ) - openai_backend = cli_module._build_ocr_backend( - backend="openai-compatible", - model="rednote-hilab/dots.mocr", - endpoint=None, - api_key=None, - base_url="http://127.0.0.1:8000/v1", - api_version=None, - ) + litellm_backend = _build_litellm_backend("rednote-hilab/dots.mocr") + hf_backend = _build_hf_backend("rednote-hilab/dots.mocr") + openai_backend = _build_openai_compatible_backend("rednote-hilab/dots.mocr") assert litellm_backend.template == DOTS_MOCR_OCR_TEMPLATE assert litellm_backend.template == hf_backend.template == openai_backend.template @@ -259,30 +259,9 @@ def test_build_ocr_backend_aligns_templates_for_dots_mocr() -> None: def test_build_ocr_backend_aligns_templates_for_deepseek_ocr_2() -> None: - litellm_backend = cli_module._build_ocr_backend( - backend="litellm", - model=DEEPSEEK_OCR_2_MODEL_ID, - endpoint=None, - api_key=None, - base_url=None, - api_version=None, - ) - hf_backend = cli_module._build_ocr_backend( - backend="hf", - model=DEEPSEEK_OCR_2_MODEL_ID, - endpoint=None, - api_key=None, - base_url=None, - api_version=None, - ) - openai_backend = cli_module._build_ocr_backend( - backend="openai-compatible", - model=DEEPSEEK_OCR_2_MODEL_ID, - endpoint=None, - api_key=None, - base_url="http://127.0.0.1:8000/v1", - api_version=None, - ) + litellm_backend = _build_litellm_backend(DEEPSEEK_OCR_2_MODEL_ID) + hf_backend = _build_hf_backend(DEEPSEEK_OCR_2_MODEL_ID) + openai_backend = _build_openai_compatible_backend(DEEPSEEK_OCR_2_MODEL_ID) assert litellm_backend.template == DEEPSEEK_OCR_2_OCR_TEMPLATE assert litellm_backend.template == hf_backend.template == openai_backend.template @@ -300,30 +279,9 @@ def test_build_ocr_backend_aligns_templates_for_deepseek_ocr_2() -> None: def test_build_ocr_backend_aligns_templates_for_paddleocr_vl() -> None: - litellm_backend = cli_module._build_ocr_backend( - backend="litellm", - model=PADDLEOCR_VL_1_5_MODEL_ID, - endpoint=None, - api_key=None, - base_url=None, - api_version=None, - ) - hf_backend = cli_module._build_ocr_backend( - backend="hf", - model=PADDLEOCR_VL_1_5_MODEL_ID, - endpoint=None, - api_key=None, - base_url=None, - api_version=None, - ) - openai_backend = cli_module._build_ocr_backend( - backend="openai-compatible", - model=PADDLEOCR_VL_1_5_MODEL_ID, - endpoint=None, - api_key=None, - base_url="http://127.0.0.1:8000/v1", - api_version=None, - ) + litellm_backend = _build_litellm_backend(PADDLEOCR_VL_1_5_MODEL_ID) + hf_backend = _build_hf_backend(PADDLEOCR_VL_1_5_MODEL_ID) + openai_backend = _build_openai_compatible_backend(PADDLEOCR_VL_1_5_MODEL_ID) assert litellm_backend.template == PADDLEOCR_VL_1_5_OCR_TEMPLATE assert litellm_backend.template == hf_backend.template == openai_backend.template @@ -341,22 +299,8 @@ def test_build_ocr_backend_aligns_templates_for_paddleocr_vl() -> None: def test_build_ocr_backend_aligns_templates_for_mineru2_5() -> None: - hf_backend = cli_module._build_ocr_backend( - backend="hf", - model=MINERU2_5_2509_1_2B_MODEL_ID, - endpoint=None, - api_key=None, - base_url=None, - api_version=None, - ) - openai_backend = cli_module._build_ocr_backend( - backend="openai-compatible", - model=MINERU2_5_2509_1_2B_MODEL_ID, - endpoint=None, - api_key=None, - base_url="http://127.0.0.1:8000/v1", - api_version=None, - ) + hf_backend = _build_hf_backend(MINERU2_5_2509_1_2B_MODEL_ID) + openai_backend = _build_openai_compatible_backend(MINERU2_5_2509_1_2B_MODEL_ID) assert hf_backend.template == MINERU2_5_2509_1_2B_OCR_TEMPLATE assert hf_backend.template == openai_backend.template @@ -366,30 +310,9 @@ def test_build_ocr_backend_aligns_templates_for_mineru2_5() -> None: def test_build_ocr_backend_aligns_templates_for_infinity_parser() -> None: - litellm_backend = cli_module._build_ocr_backend( - backend="litellm", - model=INFINITY_PARSER_7B_MODEL_ID, - endpoint=None, - api_key=None, - base_url=None, - api_version=None, - ) - hf_backend = cli_module._build_ocr_backend( - backend="hf", - model=INFINITY_PARSER_7B_MODEL_ID, - endpoint=None, - api_key=None, - base_url=None, - api_version=None, - ) - openai_backend = cli_module._build_ocr_backend( - backend="openai-compatible", - model=INFINITY_PARSER_7B_MODEL_ID, - endpoint=None, - api_key=None, - base_url="http://127.0.0.1:8000/v1", - api_version=None, - ) + litellm_backend = _build_litellm_backend(INFINITY_PARSER_7B_MODEL_ID) + hf_backend = _build_hf_backend(INFINITY_PARSER_7B_MODEL_ID) + openai_backend = _build_openai_compatible_backend(INFINITY_PARSER_7B_MODEL_ID) assert litellm_backend.template == INFINITY_PARSER_7B_OCR_TEMPLATE assert litellm_backend.template == hf_backend.template == openai_backend.template @@ -409,7 +332,7 @@ def test_build_ocr_backend_aligns_templates_for_infinity_parser() -> None: def test_build_ocr_backend_rejects_mineru2_5_for_litellm() -> None: - with pytest.raises(ConfigurationError, match="MinerU2.5 requires the built-in two-step pipeline"): + with pytest.raises(ConfigurationError, match=r"MinerU2\.5 requires the built-in two-step pipeline"): cli_module._build_ocr_backend( backend="litellm", model=MINERU2_5_2509_1_2B_MODEL_ID, @@ -421,30 +344,9 @@ def test_build_ocr_backend_rejects_mineru2_5_for_litellm() -> None: def test_build_ocr_backend_uses_generic_defaults_for_qwen_3_5_0_8b() -> None: - litellm_backend = cli_module._build_ocr_backend( - backend="litellm", - model="Qwen/Qwen3.5-0.8B", - endpoint=None, - api_key=None, - base_url=None, - api_version=None, - ) - hf_backend = cli_module._build_ocr_backend( - backend="hf", - model="Qwen/Qwen3.5-0.8B", - endpoint=None, - api_key=None, - base_url=None, - api_version=None, - ) - openai_backend = cli_module._build_ocr_backend( - backend="openai-compatible", - model="Qwen/Qwen3.5-0.8B", - endpoint=None, - api_key=None, - base_url="http://127.0.0.1:8000/v1", - api_version=None, - ) + litellm_backend = _build_litellm_backend("Qwen/Qwen3.5-0.8B") + hf_backend = _build_hf_backend("Qwen/Qwen3.5-0.8B") + openai_backend = _build_openai_compatible_backend("Qwen/Qwen3.5-0.8B") assert litellm_backend.template == DEFAULT_OCR_TEMPLATE assert litellm_backend.template == hf_backend.template == openai_backend.template @@ -454,30 +356,9 @@ def test_build_ocr_backend_uses_generic_defaults_for_qwen_3_5_0_8b() -> None: def test_build_ocr_backend_aligns_templates_for_olmocr() -> None: - litellm_backend = cli_module._build_ocr_backend( - backend="litellm", - model=OLMOCR_2_7B_1025_MODEL_ID, - endpoint=None, - api_key=None, - base_url=None, - api_version=None, - ) - hf_backend = cli_module._build_ocr_backend( - backend="hf", - model=OLMOCR_2_7B_1025_MODEL_ID, - endpoint=None, - api_key=None, - base_url=None, - api_version=None, - ) - openai_backend = cli_module._build_ocr_backend( - backend="openai-compatible", - model=OLMOCR_2_7B_1025_MODEL_ID, - endpoint=None, - api_key=None, - base_url="http://127.0.0.1:8000/v1", - api_version=None, - ) + litellm_backend = _build_litellm_backend(OLMOCR_2_7B_1025_MODEL_ID) + hf_backend = _build_hf_backend(OLMOCR_2_7B_1025_MODEL_ID) + openai_backend = _build_openai_compatible_backend(OLMOCR_2_7B_1025_MODEL_ID) assert litellm_backend.template == OLMOCR_2_7B_1025_OCR_TEMPLATE assert litellm_backend.template == hf_backend.template == openai_backend.template @@ -491,30 +372,9 @@ def test_build_ocr_backend_aligns_templates_for_olmocr() -> None: def test_build_ocr_backend_aligns_templates_for_chandra() -> None: - litellm_backend = cli_module._build_ocr_backend( - backend="litellm", - model=CHANDRA_OCR_2_MODEL_ID, - endpoint=None, - api_key=None, - base_url=None, - api_version=None, - ) - hf_backend = cli_module._build_ocr_backend( - backend="hf", - model=CHANDRA_OCR_2_MODEL_ID, - endpoint=None, - api_key=None, - base_url=None, - api_version=None, - ) - openai_backend = cli_module._build_ocr_backend( - backend="openai-compatible", - model=CHANDRA_OCR_2_MODEL_ID, - endpoint=None, - api_key=None, - base_url="http://127.0.0.1:8000/v1", - api_version=None, - ) + litellm_backend = _build_litellm_backend(CHANDRA_OCR_2_MODEL_ID) + hf_backend = _build_hf_backend(CHANDRA_OCR_2_MODEL_ID) + openai_backend = _build_openai_compatible_backend(CHANDRA_OCR_2_MODEL_ID) assert litellm_backend.template == CHANDRA_OCR_2_OCR_TEMPLATE assert litellm_backend.template == hf_backend.template == openai_backend.template @@ -530,7 +390,7 @@ def test_build_ocr_backend_aligns_templates_for_chandra() -> None: def test_install_command_invokes_runtime_installer( monkeypatch: pytest.MonkeyPatch, - cli_runner, + cli_runner: CliRunner, ) -> None: captured: dict[str, object] = {} @@ -567,10 +427,11 @@ def _fake_install_runtime_dependencies(**kwargs: object) -> SimpleNamespace: def test_install_command_surfaces_configuration_errors( monkeypatch: pytest.MonkeyPatch, - cli_runner, + cli_runner: CliRunner, ) -> None: def _raise_configuration_error(**_: object) -> SimpleNamespace: - raise ConfigurationError("missing uv") + message = "missing uv" + raise ConfigurationError(message) monkeypatch.setattr( "churro_ocr.cli.install_runtime_dependencies", diff --git a/tests/test_cli_contract.py b/tests/test_cli_contract.py index d269117..a4c22d8 100644 --- a/tests/test_cli_contract.py +++ b/tests/test_cli_contract.py @@ -2,6 +2,7 @@ import runpy from pathlib import Path +from typing import TYPE_CHECKING import pytest from PIL import Image @@ -11,9 +12,14 @@ from churro_ocr.ocr import OCRResult from churro_ocr.page_detection import DocumentPage, PageDetectionResult +if TYPE_CHECKING: + from typer.testing import CliRunner + + from tests._types import WriteImageFile + @pytest.fixture -def sample_image_path(write_image_file) -> Path: +def sample_image_path(write_image_file: WriteImageFile) -> Path: return write_image_file(size=(12, 12)) @@ -42,7 +48,7 @@ def test_transcribe_cli_validates_backend_requirements( sample_image_path: Path, args: list[str], expected_parts: tuple[str, ...], - cli_runner, + cli_runner: CliRunner, ) -> None: result = cli_runner.invoke( app, @@ -58,7 +64,7 @@ def test_transcribe_cli_validates_backend_requirements( def test_transcribe_cli_allows_openai_compatible_backend_without_api_key( monkeypatch: pytest.MonkeyPatch, sample_image_path: Path, - cli_runner, + cli_runner: CliRunner, ) -> None: captured: dict[str, object] = {} @@ -104,7 +110,7 @@ def _fake_build_ocr_backend(spec: cli_module.OCRBackendSpec) -> _FakeBackend: def test_transcribe_cli_rejects_unsupported_backend( sample_image_path: Path, backend: str, - cli_runner, + cli_runner: CliRunner, ) -> None: result = cli_runner.invoke( app, @@ -126,7 +132,7 @@ def test_transcribe_cli_rejects_unsupported_backend( def test_transcribe_cli_echoes_text_without_output( monkeypatch: pytest.MonkeyPatch, sample_image_path: Path, - cli_runner, + cli_runner: CliRunner, ) -> None: class _FakeBackend: async def ocr(self, page: DocumentPage) -> OCRResult: @@ -171,7 +177,7 @@ def test_extract_pages_cli_requires_exactly_one_image_or_pdf( tmp_path: Path, command_args: list[str], minimal_pdf_path: Path, - cli_runner, + cli_runner: CliRunner, ) -> None: output_dir = tmp_path / "pages" args = [ @@ -208,7 +214,7 @@ def test_extract_pages_cli_validates_page_detector_requirements( tmp_path: Path, args: list[str], expected_parts: tuple[str, ...], - cli_runner, + cli_runner: CliRunner, ) -> None: output_dir = tmp_path / "pages" result = cli_runner.invoke( @@ -233,7 +239,7 @@ def test_extract_pages_cli_writes_pdf_page_images( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, minimal_pdf_path: Path, - cli_runner, + cli_runner: CliRunner, ) -> None: calls: dict[str, object] = {} @@ -242,7 +248,8 @@ def __init__(self, *, backend: object | None = None) -> None: calls["backend"] = backend def detect_image_sync(self, request: object) -> PageDetectionResult: - raise AssertionError(f"Unexpected image request: {request!r}") + message = f"Unexpected image request: {request!r}" + raise AssertionError(message) def detect_pdf_sync(self, path: Path, *, dpi: int, trim_margin: int) -> PageDetectionResult: calls["path"] = Path(path) diff --git a/tests/test_install.py b/tests/test_install.py index 7748202..9079ec0 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -18,6 +18,10 @@ def test_install_runtime_dependencies_installs_hf_and_torch_with_uv( monkeypatch: pytest.MonkeyPatch, ) -> None: commands: list[list[str]] = [] + def _run(command: list[str], *, check: bool = True) -> SimpleNamespace: + _ = check + commands.append(list(command)) + return SimpleNamespace(returncode=0) monkeypatch.setattr( install_module.metadata, @@ -30,11 +34,7 @@ def test_install_runtime_dependencies_installs_hf_and_torch_with_uv( ), ) monkeypatch.setattr(install_module.shutil, "which", lambda name: "/usr/bin/uv" if name == "uv" else None) - monkeypatch.setattr( - install_module.subprocess, - "run", - lambda command, check=True: commands.append(list(command)) or SimpleNamespace(returncode=0), - ) + monkeypatch.setattr(install_module.subprocess, "run", _run) result = install_module.install_runtime_dependencies( target="hf", @@ -71,6 +71,10 @@ def test_install_runtime_dependencies_installs_local_client_with_uv( monkeypatch: pytest.MonkeyPatch, ) -> None: commands: list[list[str]] = [] + def _run(command: list[str], *, check: bool = True) -> SimpleNamespace: + _ = check + commands.append(list(command)) + return SimpleNamespace(returncode=0) monkeypatch.setattr( install_module.metadata, @@ -78,11 +82,7 @@ def test_install_runtime_dependencies_installs_local_client_with_uv( lambda _: _FakeDistribution(requires=['litellm[caching]==1.82.3; extra == "local"']), ) monkeypatch.setattr(install_module.shutil, "which", lambda name: "/usr/bin/uv" if name == "uv" else None) - monkeypatch.setattr( - install_module.subprocess, - "run", - lambda command, check=True: commands.append(list(command)) or SimpleNamespace(returncode=0), - ) + monkeypatch.setattr(install_module.subprocess, "run", _run) result = install_module.install_runtime_dependencies(target="local") diff --git a/tests/test_internal_helpers.py b/tests/test_internal_helpers.py index 8109225..7a67c02 100644 --- a/tests/test_internal_helpers.py +++ b/tests/test_internal_helpers.py @@ -6,7 +6,7 @@ from base64 import b64encode from threading import Lock from types import ModuleType, SimpleNamespace -from typing import Any, cast +from typing import TYPE_CHECKING, Any, cast import pytest from PIL import Image @@ -24,15 +24,29 @@ from churro_ocr.providers.specs import LiteLLMTransportConfig from churro_ocr.templates import HFChatTemplate +if TYPE_CHECKING: + from collections.abc import Awaitable, Callable + from pathlib import Path + + from tests._types import ImportFailurePatcher + + +def _connection_error(message: str) -> ConnectionError: + return ConnectionError(message) + + +def _import_error(message: str) -> ImportError: + return ImportError(message) + def _make_fake_litellm_module(*, acompletion: object, completion_cost: object | None = None) -> ModuleType: - module = cast(Any, ModuleType("litellm")) + module = cast("Any", ModuleType("litellm")) module.acompletion = acompletion module.completion_cost = completion_cost or (lambda **_: None) module.turn_off_message_logging = False module.success_callback = ["stale"] module.failure_callback = ["stale"] - module._logging = SimpleNamespace(_logged_requests=["stale"]) # noqa: SLF001 + module._logging = SimpleNamespace(_logged_requests=["stale"]) module.drop_params = False module.suppress_debug_info = False module.set_verbose = True @@ -41,7 +55,7 @@ def _make_fake_litellm_module(*, acompletion: object, completion_cost: object | return module -def test_load_image_rejects_missing_path(tmp_path) -> None: +def test_load_image_rejects_missing_path(tmp_path: Path) -> None: missing = tmp_path / "missing.png" with pytest.raises(ConfigurationError, match="Image path does not exist"): @@ -253,12 +267,14 @@ def test_ensure_initialized_wraps_logging_worker_when_present(monkeypatch: pytes assert fake_module.failure_callback == [] -def test_configure_disk_cache_enables_and_updates_cache(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None: +def test_configure_disk_cache_enables_and_updates_cache( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: fake_module = _make_fake_litellm_module(acompletion=lambda **_: None) enable_calls: list[dict[str, object]] = [] update_calls: list[dict[str, object]] = [] - caching_module = cast(Any, ModuleType("litellm.caching.caching")) + caching_module = cast("Any", ModuleType("litellm.caching.caching")) caching_module.enable_cache = lambda **kwargs: enable_calls.append(kwargs) caching_module.update_cache = lambda **kwargs: update_calls.append(kwargs) @@ -272,7 +288,7 @@ def test_configure_disk_cache_enables_and_updates_cache(monkeypatch: pytest.Monk second_cache_dir = tmp_path / "second" litellm_module.configure_disk_cache(disk_cache_dir=first_cache_dir) - fake_module = cast(Any, fake_module) + fake_module = cast("Any", fake_module) fake_module.cache = object() fake_module.input_callback = ["cache"] litellm_module.configure_disk_cache(disk_cache_dir=second_cache_dir) @@ -358,7 +374,8 @@ async def _fake_sleep(delay: float) -> None: async def _always_fail() -> object: calls["count"] += 1 - raise ConnectionError("still failing") + message = "still failing" + raise _connection_error(message) monkeypatch.setattr(retry_module, "retry_sleep", _fake_sleep) monkeypatch.setattr(retry_module, "monotonic", lambda: now["value"]) @@ -393,7 +410,7 @@ def __init__(self, status_code: int) -> None: async def _flaky_acompletion(**kwargs: object) -> object: calls["acompletion"] += 1 - attempt_timeouts.append(float(cast(float, kwargs["timeout"]))) + attempt_timeouts.append(float(cast("float", kwargs["timeout"]))) if calls["acompletion"] == 1: now["value"] = 103.0 raise FakeLiteLLMError(429) @@ -403,7 +420,7 @@ async def _flaky_acompletion(**kwargs: object) -> object: ) async def _fake_retry_api_call( - fn: Any, + fn: Callable[[], Awaitable[object]], *, operation_name: str, context: str | None = None, @@ -444,6 +461,37 @@ async def _fake_retry_api_call( } +@pytest.mark.asyncio +async def test_transport_complete_text_enforces_wall_clock_timeout_budget( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls = {"acompletion": 0} + + async def _hanging_acompletion(**kwargs: object) -> object: + calls["acompletion"] += 1 + await asyncio.sleep(float(cast("float", kwargs["timeout"])) * 10) + return SimpleNamespace( + choices=[SimpleNamespace(message=SimpleNamespace(content="late"))], + _hidden_params={}, + ) + + fake_module = _make_fake_litellm_module(acompletion=_hanging_acompletion) + monkeypatch.setitem(sys.modules, "litellm", fake_module) + monkeypatch.setattr(litellm_module, "_INITIALIZED", False) + + transport = LiteLLMTransport() + with pytest.raises( + ProviderError, + match="LiteLLM request failed for model 'example/model':", + ): + await transport.complete_text( + model="example/model", + messages=[{"role": "user", "content": [{"type": "text", "text": "hello"}]}], + timeout_seconds=0.01, + ) + assert calls == {"acompletion": 1} + + @pytest.mark.asyncio async def test_close_litellm_async_clients_closes_cached_async_clients( monkeypatch: pytest.MonkeyPatch, @@ -459,7 +507,7 @@ async def aclose(self) -> None: closed.append("http") fake_module = _make_fake_litellm_module(acompletion=lambda **_: None) - fake_litellm_module = cast(Any, fake_module) + fake_litellm_module = cast("Any", fake_module) fake_litellm_module.in_memory_llm_clients_cache = SimpleNamespace( cache_dict={ "openai": FakeAsyncOpenAI(), @@ -476,38 +524,6 @@ async def aclose(self) -> None: assert fake_litellm_module.aclient_session is None -@pytest.mark.asyncio -async def test_transport_complete_text_enforces_wall_clock_timeout_budget( - monkeypatch: pytest.MonkeyPatch, -) -> None: - calls = {"acompletion": 0} - - async def _hanging_acompletion(**kwargs: object) -> object: - calls["acompletion"] += 1 - await asyncio.sleep(float(cast(float, kwargs["timeout"])) * 10) - return SimpleNamespace( - choices=[SimpleNamespace(message=SimpleNamespace(content="late"))], - _hidden_params={}, - ) - - fake_module = _make_fake_litellm_module(acompletion=_hanging_acompletion) - monkeypatch.setitem(sys.modules, "litellm", fake_module) - monkeypatch.setattr(litellm_module, "_INITIALIZED", False) - - transport = LiteLLMTransport() - with pytest.raises( - ProviderError, - match="LiteLLM request failed for model 'example/model':", - ): - await transport.complete_text( - model="example/model", - messages=[{"role": "user", "content": [{"type": "text", "text": "hello"}]}], - timeout_seconds=0.01, - ) - - assert calls == {"acompletion": 1} - - @pytest.mark.asyncio async def test_transport_complete_text_rejects_empty_output(monkeypatch: pytest.MonkeyPatch) -> None: async def _empty_acompletion(**_: object) -> object: @@ -652,17 +668,18 @@ def debug(self, message: str, *args: object) -> None: ], ) def test_optional_dependency_loaders_raise_configuration_error( - loader: Any, + loader: Callable[[], object], dependency_name: str, message: str, - patch_import_failure, + patch_import_failure: ImportFailurePatcher, monkeypatch: pytest.MonkeyPatch, ) -> None: if dependency_name == "torch": def _fake_import_module(name: str) -> object: if name == "torch": - raise ImportError("missing torch") + error_message = "missing torch" + raise _import_error(error_message) return __import__(name) monkeypatch.setattr("churro_ocr.providers.hf.import_module", _fake_import_module) diff --git a/tests/test_public_api_contracts.py b/tests/test_public_api_contracts.py index 16ff2ad..a63e7c4 100644 --- a/tests/test_public_api_contracts.py +++ b/tests/test_public_api_contracts.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import TYPE_CHECKING + import pytest from PIL import Image @@ -13,6 +15,9 @@ PageDetector, ) +if TYPE_CHECKING: + from tests._types import WriteImageFile + class _MetadataEchoOCRBackend(OCRBackend): async def ocr(self, page: DocumentPage) -> OCRResult: @@ -24,6 +29,14 @@ async def ocr(self, page: DocumentPage) -> OCRResult: ) +def _fake_rasterize_pdf(_path: str, *, dpi: int) -> list[Image.Image]: + assert dpi == 144 + return [ + Image.new("RGB", (10, 10), color="white"), + Image.new("RGB", (10, 10), color="white"), + ] + + def test_document_page_properties_and_with_ocr() -> None: page = DocumentPage.from_image( Image.new("RGB", (14, 9), color="white"), @@ -128,10 +141,7 @@ async def test_document_page_detector_detect_pdf_async_preserves_source_indexes( ) -> None: monkeypatch.setattr( "churro_ocr.page_detection.rasterize_pdf", - lambda path, *, dpi: [ - Image.new("RGB", (10, 10), color="white"), - Image.new("RGB", (12, 8), color="white"), - ], + _fake_rasterize_pdf, ) result = await DocumentPageDetector().detect_pdf("sample.pdf", dpi=144, trim_margin=0) @@ -161,7 +171,9 @@ def test_ocr_client_ocr_image_propagates_metadata_and_indexes() -> None: @pytest.mark.asyncio -async def test_ocr_client_aocr_image_from_path_propagates_metadata_and_indexes(write_image_file) -> None: +async def test_ocr_client_aocr_image_from_path_propagates_metadata_and_indexes( + write_image_file: WriteImageFile, +) -> None: image_path = write_image_file(size=(9, 7)) page = await OCRClient(_MetadataEchoOCRBackend()).aocr_image( image_path=image_path, From 2c47a353a3d62990c27ba9fb6e71c8ad33337c27 Mon Sep 17 00:00:00 2001 From: Sina Date: Mon, 13 Apr 2026 18:10:19 +0000 Subject: [PATCH 33/58] refactor(evaluation): tighten OCR API and tooling typing --- src/churro_ocr/document.py | 7 +- src/churro_ocr/ocr.py | 14 +- src/churro_ocr/page_detection.py | 14 +- tests/test_document_pipeline.py | 6 +- tests/test_layout_api.py | 11 +- tests/test_ocr_api.py | 11 +- tests/test_tooling_benchmark.py | 78 +++++--- tests/test_tooling_evaluate_page.py | 69 ++++--- tests/test_tooling_metrics.py | 18 +- tests/test_tooling_support.py | 282 +++++++++++++--------------- tooling/benchmarking/benchmark.py | 66 ++++--- tooling/benchmarking/dataset.py | 50 +++-- tooling/evaluation/evaluate_page.py | 40 ++-- tooling/evaluation/metrics.py | 24 +-- tooling/evaluation/normalization.py | 55 +++--- tooling/evaluation/xml_utils.py | 8 +- 16 files changed, 448 insertions(+), 305 deletions(-) diff --git a/src/churro_ocr/document.py b/src/churro_ocr/document.py index 887bb0b..6619fa2 100644 --- a/src/churro_ocr/document.py +++ b/src/churro_ocr/document.py @@ -22,6 +22,10 @@ from churro_ocr.types import MetadataDict +def _configuration_error(message: str) -> ConfigurationError: + return ConfigurationError(message) + + @dataclass(slots=True) class DocumentOCRResult: """Document OCR output across all detected pages. @@ -84,7 +88,8 @@ def __init__( :raises ConfigurationError: If ``max_concurrency`` is less than 1. """ if max_concurrency < 1: - raise ConfigurationError("DocumentOCRPipeline max_concurrency must be at least 1.") + message = "DocumentOCRPipeline max_concurrency must be at least 1." + raise _configuration_error(message) self._ocr_client = OCRClient(ocr_backend) self._page_detector = page_detector or DocumentPageDetector(backend=detection_backend) self.max_concurrency = max_concurrency diff --git a/src/churro_ocr/ocr.py b/src/churro_ocr/ocr.py index b1946f8..ef203ae 100644 --- a/src/churro_ocr/ocr.py +++ b/src/churro_ocr/ocr.py @@ -19,6 +19,14 @@ from churro_ocr.types import MetadataDict +def _assertion_error(message: str) -> AssertionError: + return AssertionError(message) + + +def _configuration_error(message: str) -> ConfigurationError: + return ConfigurationError(message) + + @dataclass(slots=True) class OCRResult: """Provider-agnostic OCR result. @@ -179,7 +187,8 @@ def _page_from_image_input( metadata: MetadataDict | None, ) -> DocumentPage: if (image is None) == (image_path is None): - raise ConfigurationError("OCR image helpers require exactly one of `image` or `image_path`.") + message = "OCR image helpers require exactly one of `image` or `image_path`." + raise _configuration_error(message) if image is not None: return DocumentPage.from_image( image, @@ -194,4 +203,5 @@ def _page_from_image_input( source_index=source_index, metadata=metadata, ) - raise AssertionError("Unreachable exact-one image input guard.") + message = "Unreachable exact-one image input guard." + raise _assertion_error(message) diff --git a/src/churro_ocr/page_detection.py b/src/churro_ocr/page_detection.py index 59ec4ee..a900e59 100644 --- a/src/churro_ocr/page_detection.py +++ b/src/churro_ocr/page_detection.py @@ -19,6 +19,14 @@ from churro_ocr.types import BoundingBox, MetadataDict, Polygon +def _assertion_error(message: str) -> AssertionError: + return AssertionError(message) + + +def _configuration_error(message: str) -> ConfigurationError: + return ConfigurationError(message) + + @dataclass(slots=True) class PageCandidate: """Intermediate page candidate returned by a page detector. @@ -168,12 +176,14 @@ def require_image(self) -> Image.Image: ``image_path`` are provided. """ if (self.image is None) == (self.image_path is None): - raise ConfigurationError("PageDetectionRequest requires exactly one of `image` or `image_path`.") + message = "PageDetectionRequest requires exactly one of `image` or `image_path`." + raise _configuration_error(message) if self.image is not None: return self.image.copy() if self.image_path is not None: return load_image(self.image_path) - raise AssertionError("Unreachable exact-one image input guard.") + message = "Unreachable exact-one image input guard." + raise _assertion_error(message) @dataclass(slots=True) diff --git a/tests/test_document_pipeline.py b/tests/test_document_pipeline.py index b83b735..25be906 100644 --- a/tests/test_document_pipeline.py +++ b/tests/test_document_pipeline.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +from typing import TYPE_CHECKING import pytest from PIL import Image @@ -10,6 +11,9 @@ from churro_ocr.ocr import OCRBackend, OCRResult from churro_ocr.page_detection import DocumentPage, PageCandidate, PageDetectionRequest +if TYPE_CHECKING: + from pathlib import Path + class _EchoOCRBackend(OCRBackend): async def ocr(self, page: DocumentPage) -> OCRResult: @@ -43,7 +47,7 @@ def test_document_ocr_pipeline_process_image_sync() -> None: assert result.pages[0].metadata["kind"] == "tight" -def test_document_ocr_pipeline_process_pdf_sync(minimal_pdf_path) -> None: +def test_document_ocr_pipeline_process_pdf_sync(minimal_pdf_path: Path) -> None: result = DocumentOCRPipeline(_EchoOCRBackend()).process_pdf_sync( minimal_pdf_path, dpi=150, diff --git a/tests/test_layout_api.py b/tests/test_layout_api.py index 3c7db97..e534a09 100644 --- a/tests/test_layout_api.py +++ b/tests/test_layout_api.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import TYPE_CHECKING + import pytest from PIL import Image @@ -11,6 +13,11 @@ PageDetector, ) +if TYPE_CHECKING: + from pathlib import Path + + from tests._types import WriteImageFile + async def _two_pages(_: Image.Image) -> list[PageCandidate]: return [ @@ -42,7 +49,7 @@ def test_page_detector_returns_page_list() -> None: assert pages[1].page_index == 1 -def test_document_page_detector_detect_pdf_sync_uses_real_pdf(minimal_pdf_path) -> None: +def test_document_page_detector_detect_pdf_sync_uses_real_pdf(minimal_pdf_path: Path) -> None: result = DocumentPageDetector().detect_pdf_sync(minimal_pdf_path, dpi=150, trim_margin=0) assert result.source_type == "pdf" @@ -50,7 +57,7 @@ def test_document_page_detector_detect_pdf_sync_uses_real_pdf(minimal_pdf_path) assert result.pages[0].image.width > 0 -def test_page_detection_request_requires_exactly_one_image_input(write_image_file) -> None: +def test_page_detection_request_requires_exactly_one_image_input(write_image_file: WriteImageFile) -> None: image_path = write_image_file(size=(12, 12)) with pytest.raises(ConfigurationError, match="exactly one"): PageDetectionRequest().require_image() diff --git a/tests/test_ocr_api.py b/tests/test_ocr_api.py index 31f5f26..a86ac2d 100644 --- a/tests/test_ocr_api.py +++ b/tests/test_ocr_api.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import TYPE_CHECKING + import pytest from PIL import Image @@ -8,6 +10,9 @@ from churro_ocr.page_detection import DocumentPage from churro_ocr.prompts import strip_ocr_output_tag +if TYPE_CHECKING: + from tests._types import WriteImageFile + class _FakeOCRBackend(OCRBackend): async def ocr(self, page: DocumentPage) -> OCRResult: @@ -19,7 +24,7 @@ async def ocr(self, page: DocumentPage) -> OCRResult: ) -def test_document_page_loads_image_from_path(write_image_file) -> None: +def test_document_page_loads_image_from_path(write_image_file: WriteImageFile) -> None: image_path = write_image_file(size=(12, 34)) page = DocumentPage.from_image_path(image_path) image = page.image @@ -27,7 +32,7 @@ def test_document_page_loads_image_from_path(write_image_file) -> None: assert image.size == (12, 34) -def test_ocr_client_sync(write_image_file) -> None: +def test_ocr_client_sync(write_image_file: WriteImageFile) -> None: image_path = write_image_file(size=(20, 10)) result = OCRClient(_FakeOCRBackend()).ocr(DocumentPage.from_image_path(image_path)) @@ -64,7 +69,7 @@ def test_prepare_ocr_page_resizes_and_normalizes_image() -> None: assert prepared_page.image.mode == "RGB" -def test_ocr_client_image_helpers_require_exactly_one_input(write_image_file) -> None: +def test_ocr_client_image_helpers_require_exactly_one_input(write_image_file: WriteImageFile) -> None: image_path = write_image_file(size=(20, 10)) client = OCRClient(_FakeOCRBackend()) diff --git a/tests/test_tooling_benchmark.py b/tests/test_tooling_benchmark.py index c44ddc4..d7c819a 100644 --- a/tests/test_tooling_benchmark.py +++ b/tests/test_tooling_benchmark.py @@ -1,8 +1,7 @@ from __future__ import annotations import asyncio -from pathlib import Path -from typing import cast +from typing import TYPE_CHECKING, cast import pytest from datasets import Dataset @@ -10,7 +9,6 @@ from churro_ocr.ocr import OCRResult from churro_ocr.providers.hf import HuggingFaceVisionOCRBackend -from churro_ocr.providers.ocr import LiteLLMVisionOCRBackend from churro_ocr.providers.specs import DEFAULT_OCR_MAX_TOKENS from churro_ocr.templates import ( CHURRO_3B_XML_TEMPLATE, @@ -22,7 +20,17 @@ PADDLEOCR_VL_1_5_OCR_TEMPLATE, ) from tooling.benchmarking import benchmark -from tooling.evaluation.types import BenchmarkDatasetExample + +if TYPE_CHECKING: + from collections.abc import Iterable + from pathlib import Path + + from churro_ocr.providers.ocr import LiteLLMVisionOCRBackend + from tooling.evaluation.types import BenchmarkDatasetExample + + +def _benchmark_runtime_error(message: str) -> RuntimeError: + return RuntimeError(message) def _benchmark_example( @@ -640,7 +648,7 @@ def test_build_ocr_backend_aligns_hf_and_openai_compatible_templates_for_generic @pytest.mark.asyncio -async def test_run_executes_pipeline(monkeypatch, tmp_path: Path) -> None: +async def test_run_executes_pipeline(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: dataset: list[BenchmarkDatasetExample] = [ _benchmark_example("0", transcription="first"), _benchmark_example( @@ -659,14 +667,19 @@ async def test_run_executes_pipeline(monkeypatch, tmp_path: Path) -> None: ), ] - def fake_load_dataset(dataset_id: str, *, split: str): # noqa: ANN001 + def fake_load_dataset(dataset_id: str, *, split: str) -> list[BenchmarkDatasetExample]: assert dataset_id == benchmark.CHURRO_DATASET_ID assert split == "dev" return dataset monkeypatch.setattr(benchmark, "_load_dataset", fake_load_dataset) - async def fake_predict(ds, options, *, total_pages): # noqa: ANN001 + async def fake_predict( + ds: Iterable[BenchmarkDatasetExample], + options: benchmark.BenchmarkOptions, + *, + total_pages: int | None, + ) -> tuple[list[object], list[dict[str, object]]]: selected = list(ds) assert len(selected) == 1 assert selected[0]["example_id"] == "1" @@ -692,7 +705,12 @@ async def fake_cleanup() -> None: captured: dict[str, object] = {} - def fake_compute_metrics(ds, predictions, output_prefix, elapsed_time): # noqa: ANN001 + def fake_compute_metrics( + ds: list[object], + predictions: list[dict[str, object]], + output_prefix: str, + elapsed_time: float, + ) -> dict[str, str]: call_order.append("compute_metrics") captured["dataset"] = ds captured["predictions"] = predictions @@ -840,7 +858,9 @@ def test_selected_dataset_examples_filters_materialized_dataset() -> None: @pytest.mark.asyncio -async def test_predict_texts_updates_progress_and_preserves_order(monkeypatch) -> None: +async def test_predict_texts_updates_progress_and_preserves_order( + monkeypatch: pytest.MonkeyPatch, +) -> None: dataset: list[BenchmarkDatasetExample] = [ _benchmark_example("0", size=(3, 3), transcription="alpha"), _benchmark_example( @@ -922,7 +942,7 @@ def fake_tqdm( return progress_bar class FakeOCRBackend: - async def ocr(self, page): # noqa: ANN001 + async def ocr(self, page: benchmark.DocumentPage) -> OCRResult: await asyncio.sleep(page.width / 1000) return OCRResult( text=f"page-{page.width}", @@ -970,7 +990,9 @@ async def ocr(self, page): # noqa: ANN001 @pytest.mark.asyncio -async def test_predict_texts_uses_batch_backend_with_max_concurrency_as_batch_size(monkeypatch) -> None: +async def test_predict_texts_uses_batch_backend_with_max_concurrency_as_batch_size( + monkeypatch: pytest.MonkeyPatch, +) -> None: dataset: list[BenchmarkDatasetExample] = [ _benchmark_example("0", size=(3, 3), transcription="alpha"), _benchmark_example( @@ -993,7 +1015,7 @@ async def test_predict_texts_uses_batch_backend_with_max_concurrency_as_batch_si captured_batch_sizes: list[int] = [] class FakeBatchBackend: - async def ocr_batch(self, pages): # noqa: ANN001 + async def ocr_batch(self, pages: list[benchmark.DocumentPage]) -> list[OCRResult]: captured_batch_sizes.append(len(pages)) return [ OCRResult( @@ -1030,7 +1052,7 @@ async def ocr_batch(self, pages): # noqa: ANN001 @pytest.mark.asyncio -async def test_predict_texts_logs_first_batch_output_once(monkeypatch) -> None: +async def test_predict_texts_logs_first_batch_output_once(monkeypatch: pytest.MonkeyPatch) -> None: dataset = [ _benchmark_example("0", size=(3, 3), transcription="alpha"), _benchmark_example("1", size=(1, 1), transcription="beta"), @@ -1042,7 +1064,7 @@ def info(self, message: str, *args: object) -> None: logged_messages.append(message % args if args else message) class FakeBatchBackend: - async def ocr_batch(self, pages): # noqa: ANN001 + async def ocr_batch(self, pages: list[benchmark.DocumentPage]) -> list[OCRResult]: return [ OCRResult( text=f"page-{page.width}", @@ -1079,7 +1101,9 @@ async def ocr_batch(self, pages): # noqa: ANN001 @pytest.mark.asyncio -async def test_predict_texts_logs_first_submitted_output_once_for_non_batch_backend(monkeypatch) -> None: +async def test_predict_texts_logs_first_submitted_output_once_for_non_batch_backend( + monkeypatch: pytest.MonkeyPatch, +) -> None: dataset: list[BenchmarkDatasetExample] = [ _benchmark_example("0", size=(3, 3), transcription="alpha"), _benchmark_example("1", size=(1, 1), transcription="beta"), @@ -1092,7 +1116,7 @@ def info(self, message: str, *args: object) -> None: logged_messages.append(message % args if args else message) class FakeOCRBackend: - async def ocr(self, page): # noqa: ANN001 + async def ocr(self, page: benchmark.DocumentPage) -> OCRResult: await asyncio.sleep(page.width / 1000) return OCRResult( text=f"page-{page.width}", @@ -1127,7 +1151,9 @@ async def ocr(self, page): # noqa: ANN001 @pytest.mark.asyncio -async def test_predict_texts_continues_after_non_batch_page_failure(monkeypatch) -> None: +async def test_predict_texts_continues_after_non_batch_page_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: dataset: list[BenchmarkDatasetExample] = [ _benchmark_example("0", size=(3, 3), transcription="alpha"), _benchmark_example("1", size=(1, 1), transcription="beta"), @@ -1136,16 +1162,17 @@ async def test_predict_texts_continues_after_non_batch_page_failure(monkeypatch) logged_messages: list[str] = [] class FakeLogger: - def info(self, _message: str, *args: object) -> None: # noqa: ANN001 + def info(self, _message: str, *_args: object) -> None: return None def exception(self, message: str, *args: object) -> None: logged_messages.append(message % args if args else message) class FakeOCRBackend: - async def ocr(self, page): # noqa: ANN001 + async def ocr(self, page: benchmark.DocumentPage) -> OCRResult: if page.width == 1: - raise RuntimeError("timed out") + message = "timed out" + raise _benchmark_runtime_error(message) return OCRResult( text=f"page-{page.width}", provider_name="fake", @@ -1186,7 +1213,9 @@ async def ocr(self, page): # noqa: ANN001 @pytest.mark.asyncio -async def test_predict_texts_continues_after_batch_failure(monkeypatch) -> None: +async def test_predict_texts_continues_after_batch_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: dataset: list[BenchmarkDatasetExample] = [ _benchmark_example("0", size=(3, 3), transcription="alpha"), _benchmark_example("1", size=(1, 1), transcription="beta"), @@ -1196,17 +1225,18 @@ async def test_predict_texts_continues_after_batch_failure(monkeypatch) -> None: call_count = {"ocr_batch": 0} class FakeLogger: - def info(self, _message: str, *args: object) -> None: # noqa: ANN001 + def info(self, _message: str, *_args: object) -> None: return None def exception(self, message: str, *args: object) -> None: logged_messages.append(message % args if args else message) class FakeBatchBackend: - async def ocr_batch(self, pages): # noqa: ANN001 + async def ocr_batch(self, pages: list[benchmark.DocumentPage]) -> list[OCRResult]: call_count["ocr_batch"] += 1 if call_count["ocr_batch"] == 1: - raise RuntimeError("batch timed out") + message = "batch timed out" + raise _benchmark_runtime_error(message) return [ OCRResult( text=f"page-{page.width}", diff --git a/tests/test_tooling_evaluate_page.py b/tests/test_tooling_evaluate_page.py index d0c0293..d3c6d51 100644 --- a/tests/test_tooling_evaluate_page.py +++ b/tests/test_tooling_evaluate_page.py @@ -2,24 +2,53 @@ import importlib from types import SimpleNamespace +from typing import TYPE_CHECKING import pytest -from tooling.evaluation.types import EvaluationExample, MetricInputExample +if TYPE_CHECKING: + from tooling.evaluation.types import EvaluationExample, MetricInputExample evaluate_page_module = importlib.import_module("tooling.evaluation.evaluate_page") -def test_evaluate_page_supports_current_example_fields(monkeypatch) -> None: - example: EvaluationExample = { - "example_id": "ahisto/1069_69", - "cleaned_transcription": "clean", - "main_language": "Czech", - "main_script": "Latin", - "document_type": "print", - "dataset_id": "ahisto", +def _evaluation_example( + example_id: str = "ahisto/1069_69", + *, + cleaned_transcription: str = "clean", + main_language: str = "Czech", + main_script: str = "Latin", + document_type: str = "print", + dataset_id: str = "ahisto", +) -> EvaluationExample: + return { + "example_id": example_id, + "cleaned_transcription": cleaned_transcription, + "main_language": main_language, + "main_script": main_script, + "document_type": document_type, + "dataset_id": dataset_id, } + +def _metric_input_example( + example_id: str = "ahisto/1069_69", + *, + cleaned_transcription: str = "gold", + main_language: str = "Czech", + main_script: str = "Latin", +) -> MetricInputExample: + return { + "example_id": example_id, + "cleaned_transcription": cleaned_transcription, + "main_language": main_language, + "main_script": main_script, + } + + +def test_evaluate_page_supports_current_example_fields(monkeypatch: pytest.MonkeyPatch) -> None: + example = _evaluation_example() + monkeypatch.setattr( evaluate_page_module, "calculate_metrics", @@ -37,7 +66,7 @@ def test_evaluate_page_supports_current_example_fields(monkeypatch) -> None: assert result["dataset_id"] == "ahisto" -def test_calculate_metrics_uses_cleaned_transcription(monkeypatch) -> None: +def test_calculate_metrics_uses_cleaned_transcription(monkeypatch: pytest.MonkeyPatch) -> None: captured: dict[str, str] = {} def fake_core(predicted_text: str, gold_text: str, language: str, script: str) -> dict[str, object]: @@ -49,12 +78,7 @@ def fake_core(predicted_text: str, gold_text: str, language: str, script: str) - monkeypatch.setattr(evaluate_page_module, "_compute_text_metrics_core", fake_core) - example: MetricInputExample = { - "example_id": "ahisto/1069_69", - "cleaned_transcription": "new", - "main_language": "Czech", - "main_script": "Latin", - } + example = _metric_input_example(cleaned_transcription="new") result = evaluate_page_module.calculate_metrics((example, "pred")) @@ -76,7 +100,7 @@ def fake_core(predicted_text: str, gold_text: str, language: str, script: str) - ], ) def test_calculate_metrics_strips_output_tags_before_normalization( - monkeypatch, + monkeypatch: pytest.MonkeyPatch, predicted_text: str, expected: str, ) -> None: @@ -87,19 +111,16 @@ def test_calculate_metrics_strips_output_tags_before_normalization( SimpleNamespace(compute=lambda *_args, **_kwargs: {"bleu": 0.0}), ) - example: MetricInputExample = { - "example_id": "ahisto/1069_69", - "cleaned_transcription": "gold", - "main_language": "Czech", - "main_script": "Latin", - } + example = _metric_input_example() result = evaluate_page_module.calculate_metrics((example, predicted_text)) assert result["normalized_predicted_text"] == expected -def test_calculate_metrics_from_text_lazily_initializes_bleu_metric(monkeypatch) -> None: +def test_calculate_metrics_from_text_lazily_initializes_bleu_metric( + monkeypatch: pytest.MonkeyPatch, +) -> None: init_calls = 0 fake_metric = SimpleNamespace(compute=lambda *_args, **_kwargs: {"bleu": 0.25}) diff --git a/tests/test_tooling_metrics.py b/tests/test_tooling_metrics.py index 0493f52..2270d0d 100644 --- a/tests/test_tooling_metrics.py +++ b/tests/test_tooling_metrics.py @@ -1,11 +1,16 @@ from __future__ import annotations import json -from pathlib import Path -from typing import cast +from typing import TYPE_CHECKING, cast from tooling.evaluation import metrics -from tooling.evaluation.types import BenchmarkPrediction, EvaluationExample, PageEvaluationResult + +if TYPE_CHECKING: + from pathlib import Path + + import pytest + + from tooling.evaluation.types import BenchmarkPrediction, EvaluationExample, PageEvaluationResult def test_calculate_language_and_type_metrics_handles_missing_categories() -> None: @@ -43,7 +48,7 @@ def test_to_rounded_percentage_preserves_non_numeric_values() -> None: def test_compute_metrics_writes_expected_outputs( tmp_path: Path, - monkeypatch, + monkeypatch: pytest.MonkeyPatch, ) -> None: dataset: list[EvaluationExample] = [ { @@ -57,7 +62,10 @@ def test_compute_metrics_writes_expected_outputs( ] predictions: list[BenchmarkPrediction] = [{"text": "", "metadata": {"raw_html": "

"}}] - def fake_batch_evaluate(ds, preds): # noqa: ANN001 + def fake_batch_evaluate( + ds: list[EvaluationExample], + preds: list[str], + ) -> tuple[dict[str, float], list[PageEvaluationResult]]: assert ds == dataset assert preds == [""] return ( diff --git a/tests/test_tooling_support.py b/tests/test_tooling_support.py index b0a2fd9..c6281b0 100644 --- a/tests/test_tooling_support.py +++ b/tests/test_tooling_support.py @@ -2,7 +2,7 @@ import importlib from types import SimpleNamespace -from typing import cast +from typing import TYPE_CHECKING, Never, cast import datasets import pytest @@ -12,11 +12,86 @@ import tooling.evaluation.normalization as normalization_module import tooling.evaluation.xml_utils as xml_utils_module from tooling.evaluation.repetition import has_long_repetition -from tooling.evaluation.types import BenchmarkDatasetExample, MetricInputExample, PageEvaluationResult + +if TYPE_CHECKING: + from collections.abc import Callable, Iterable, Iterator + + from tooling.evaluation.types import BenchmarkDatasetExample, MetricInputExample, PageEvaluationResult evaluate_page_module = importlib.import_module("tooling.evaluation.evaluate_page") +def _boom_error() -> RuntimeError: + return RuntimeError("boom") + + +def _bad_value_error() -> ValueError: + return ValueError("bad") + + +def _raise_core_metrics_error(*_args: object, **_kwargs: object) -> Never: + raise _boom_error() + + +def _raise_bad_value_error(_text: str) -> Never: + raise _bad_value_error() + + +def _metric_input_example( + example_id: str, + *, + cleaned_transcription: str = "", + main_language: str = "English", + main_script: str = "Latin", +) -> MetricInputExample: + return { + "example_id": example_id, + "cleaned_transcription": cleaned_transcription, + "main_language": main_language, + "main_script": main_script, + } + + +def _benchmark_dataset_example( + example_id: str, + *, + image: object = "image", + cleaned_transcription: str = "", + dataset_id: str | None = None, + document_type: str = "print", + main_language: str = "English", + main_script: str = "Latin", +) -> BenchmarkDatasetExample: + return cast( + "BenchmarkDatasetExample", + { + "image": image, + "cleaned_transcription": cleaned_transcription, + "dataset_id": dataset_id or f"dataset-{example_id}", + "document_type": document_type, + "example_id": example_id, + "main_language": main_language, + "main_script": main_script, + }, + ) + + +def _page_evaluation_result( + example_id: str, + *, + normalized_levenshtein_similarity: float = 1.0, + is_empty: float = 0.0, +) -> PageEvaluationResult: + return cast( + "PageEvaluationResult", + { + "example_id": example_id, + "normalized_levenshtein_similarity": normalized_levenshtein_similarity, + "is_empty": is_empty, + }, + ) + + def test_extract_actual_text_from_xml_handles_plain_text_namespaces_and_parse_errors( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -42,7 +117,8 @@ def warning(self, message: str, *args: object) -> None: assert xml_utils_module.extract_actual_text_from_xml("plain text") == "plain text" assert xml_utils_module.extract_actual_text_from_xml(xml_content) == "Header line\nBody line\nFooter line" assert xml_utils_module.extract_actual_text_from_xml("") == "" - assert warnings and "Failed to parse XML content during evaluation" in warnings[0] + assert warnings + assert "Failed to parse XML content during evaluation" in warnings[0] def test_normalize_text_for_evaluation_handles_markdown_linebreaks_and_substitutions() -> None: @@ -106,11 +182,20 @@ class _BuilderWithoutFiles: "load_dataset", lambda name, **kwargs: parquet_calls.append((name, kwargs)) or {"name": name, "kwargs": kwargs}, ) - monkeypatch.setattr(datasets, "load_dataset_builder", lambda dataset_id: _BuilderWithFiles()) + + def _load_dataset_builder_with_files(dataset_id: str) -> _BuilderWithFiles: + del dataset_id + return _BuilderWithFiles() + + monkeypatch.setattr(datasets, "load_dataset_builder", _load_dataset_builder_with_files) parquet_result = dataset_module.load_dataset_split("dataset/id", "dev", columns=["keep"]) - monkeypatch.setattr(datasets, "load_dataset_builder", lambda dataset_id: _BuilderWithoutFiles()) + def _load_dataset_builder_without_files(dataset_id: str) -> _BuilderWithoutFiles: + del dataset_id + return _BuilderWithoutFiles() + + monkeypatch.setattr(datasets, "load_dataset_builder", _load_dataset_builder_without_files) fallback_result = dataset_module.load_dataset_split("dataset/id", "test") assert parquet_result == { @@ -133,33 +218,23 @@ def test_dataset_subset_and_selection_cover_iterable_and_materialized_paths() -> selection = dataset_module.DatasetSelection(subset=subset, offset=1, limit=1) examples: list[BenchmarkDatasetExample] = [ - { - "image": Image.new("RGB", (4, 4), color="white"), - "cleaned_transcription": "", - "dataset_id": "dataset-1", - "document_type": "handwritten page", - "example_id": "one", - "main_language": "english", - "main_script": "Latin", - }, - { - "image": Image.new("RGB", (4, 4), color="white"), - "cleaned_transcription": "", - "dataset_id": "dataset-2", - "document_type": "handwritten page", - "example_id": "two", - "main_language": "english", - "main_script": "Latin", - }, - { - "image": Image.new("RGB", (4, 4), color="white"), - "cleaned_transcription": "", - "dataset_id": "dataset-3", - "document_type": "print", - "example_id": "three", - "main_language": "english", - "main_script": "Latin", - }, + _benchmark_dataset_example( + "one", + image=Image.new("RGB", (4, 4), color="white"), + document_type="handwritten page", + main_language="english", + ), + _benchmark_dataset_example( + "two", + image=Image.new("RGB", (4, 4), color="white"), + document_type="handwritten page", + main_language="english", + ), + _benchmark_dataset_example( + "three", + image=Image.new("RGB", (4, 4), color="white"), + main_language="english", + ), ] assert subset.is_active() is True @@ -185,7 +260,7 @@ def test_dataset_subset_and_selection_cover_iterable_and_materialized_paths() -> }, ] ) - selected = selection._select_materialized_dataset(materialized) + selected = cast("datasets.Dataset", selection._select_materialized_dataset(materialized)) assert selected.num_rows == 1 assert cast("str", selected[0]["example_id"]) == "two" @@ -196,17 +271,12 @@ def test_dataset_subset_and_selection_cover_iterable_and_materialized_paths() -> def test_evaluate_page_helpers_cover_failure_and_aggregation_paths( monkeypatch: pytest.MonkeyPatch, ) -> None: - example: MetricInputExample = { - "example_id": "example-1", - "cleaned_transcription": "gold", - "main_language": "English", - "main_script": "Latin", - } + example = _metric_input_example("example-1", cleaned_transcription="gold") monkeypatch.setattr( evaluate_page_module, "_compute_text_metrics_core", - lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError("boom")), + _raise_core_metrics_error, ) failed = evaluate_page_module.calculate_metrics((example, "predicted")) assert failed["is_empty"] == 1.0 @@ -215,22 +285,8 @@ def test_evaluate_page_helpers_cover_failure_and_aggregation_paths( assert evaluate_page_module.aggregate_results([]) == ({}, []) aggregate, rows = evaluate_page_module.aggregate_results( [ - cast( - "PageEvaluationResult", - { - "example_id": "one", - "normalized_levenshtein_similarity": 0.5, - "is_empty": 0.0, - }, - ), - cast( - "PageEvaluationResult", - { - "example_id": "two", - "normalized_levenshtein_similarity": 1.0, - "is_empty": 1.0, - }, - ), + _page_evaluation_result("one", normalized_levenshtein_similarity=0.5), + _page_evaluation_result("two", is_empty=1.0), ] ) assert aggregate == {"normalized_levenshtein_similarity": 0.75, "is_empty": 0.5} @@ -251,30 +307,10 @@ def test_evaluate_page_metric_helpers_cover_initialization_and_single_batch_path monkeypatch.setattr( evaluate_page_module, "evaluate_page", - lambda inputs: cast( - "PageEvaluationResult", - { - "example_id": inputs[0]["example_id"], - "normalized_levenshtein_similarity": 1.0, - "is_empty": 0.0, - }, - ), + lambda inputs: _page_evaluation_result(str(inputs[0]["example_id"])), ) aggregate, rows = evaluate_page_module.batch_evaluate( - dataset=[ - cast( - "BenchmarkDatasetExample", - { - "image": "image", - "cleaned_transcription": "", - "dataset_id": "dataset-1", - "document_type": "print", - "example_id": "row-1", - "main_language": "English", - "main_script": "Latin", - }, - ) - ], + dataset=[_benchmark_dataset_example("row-1", dataset_id="dataset-1")], predicted_texts=["predicted"], ) @@ -303,10 +339,16 @@ def __enter__(self) -> _FakePool: captured_initializer() return self - def __exit__(self, exc_type, exc, tb) -> bool: # noqa: ANN001 + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: object | None, + ) -> bool: + del exc_type, exc, tb return False - def imap(self, func, iterable): # noqa: ANN001 + def imap(self, func: Callable[[object], object], iterable: Iterable[object]) -> Iterator[object]: return map(func, iterable) monkeypatch.setattr(evaluate_page_module, "initialize_metrics", fake_initialize_metrics) @@ -316,42 +358,13 @@ def imap(self, func, iterable): # noqa: ANN001 monkeypatch.setattr( evaluate_page_module, "evaluate_page", - lambda inputs: cast( - "PageEvaluationResult", - { - "example_id": inputs[0]["example_id"], - "normalized_levenshtein_similarity": 1.0, - "is_empty": 0.0, - }, - ), + lambda inputs: _page_evaluation_result(str(inputs[0]["example_id"])), ) aggregate, rows = evaluate_page_module.batch_evaluate( dataset=[ - cast( - "BenchmarkDatasetExample", - { - "image": "image", - "cleaned_transcription": "", - "dataset_id": "dataset-1", - "document_type": "print", - "example_id": "row-1", - "main_language": "English", - "main_script": "Latin", - }, - ), - cast( - "BenchmarkDatasetExample", - { - "image": "image", - "cleaned_transcription": "", - "dataset_id": "dataset-2", - "document_type": "print", - "example_id": "row-2", - "main_language": "English", - "main_script": "Latin", - }, - ), + _benchmark_dataset_example("row-1", dataset_id="dataset-1"), + _benchmark_dataset_example("row-2", dataset_id="dataset-2"), ], predicted_texts=["predicted-1", "predicted-2"], ) @@ -371,9 +384,10 @@ def fake_initialize_metrics() -> None: nonlocal init_calls init_calls += 1 - def _unexpected_pool(*args: object, **kwargs: object) -> object: + def _unexpected_pool(*args: object, **kwargs: object) -> Never: del args, kwargs - raise AssertionError("multiprocessing pool should not be used") + message = "multiprocessing pool should not be used" + raise AssertionError(message) monkeypatch.setattr(evaluate_page_module, "initialize_metrics", fake_initialize_metrics) monkeypatch.setattr(evaluate_page_module, "_should_use_multiprocessing_pool", lambda: False) @@ -382,42 +396,13 @@ def _unexpected_pool(*args: object, **kwargs: object) -> object: monkeypatch.setattr( evaluate_page_module, "evaluate_page", - lambda inputs: cast( - "PageEvaluationResult", - { - "example_id": inputs[0]["example_id"], - "normalized_levenshtein_similarity": 1.0, - "is_empty": 0.0, - }, - ), + lambda inputs: _page_evaluation_result(str(inputs[0]["example_id"])), ) aggregate, rows = evaluate_page_module.batch_evaluate( dataset=[ - cast( - "BenchmarkDatasetExample", - { - "image": "image", - "cleaned_transcription": "", - "dataset_id": "dataset-1", - "document_type": "print", - "example_id": "row-1", - "main_language": "English", - "main_script": "Latin", - }, - ), - cast( - "BenchmarkDatasetExample", - { - "image": "image", - "cleaned_transcription": "", - "dataset_id": "dataset-2", - "document_type": "print", - "example_id": "row-2", - "main_language": "English", - "main_script": "Latin", - }, - ), + _benchmark_dataset_example("row-1", dataset_id="dataset-1"), + _benchmark_dataset_example("row-2", dataset_id="dataset-2"), ], predicted_texts=["predicted-1", "predicted-2"], ) @@ -448,7 +433,7 @@ def error(self, message: str, *args: object) -> None: monkeypatch.setattr( evaluate_page_module, "strip_ocr_output_tag", - lambda text: (_ for _ in ()).throw(ValueError("bad")), + _raise_bad_value_error, ) failed = evaluate_page_module._compute_text_metrics_core("pred", "gold", "English", "Latin") @@ -456,4 +441,5 @@ def error(self, message: str, *args: object) -> None: assert failed["normalized_levenshtein_similarity"] == 0.0 assert failed["repetition"] == 0.0 assert failed["is_empty"] == 0.0 - assert errors and "Error in metric computation: bad" in errors[0] + assert errors + assert "Error in metric computation: bad" in errors[0] diff --git a/tooling/benchmarking/benchmark.py b/tooling/benchmarking/benchmark.py index 0b96ab7..81379f4 100644 --- a/tooling/benchmarking/benchmark.py +++ b/tooling/benchmarking/benchmark.py @@ -4,13 +4,12 @@ import argparse import asyncio -from collections.abc import Iterable -from dataclasses import dataclass -from pathlib import Path import sys import threading +from dataclasses import dataclass +from pathlib import Path from time import time -from typing import Any +from typing import TYPE_CHECKING, cast from PIL import Image from tqdm import tqdm @@ -21,33 +20,37 @@ sys.path.remove(_REPO_SRC_PATH_STR) sys.path.insert(0, _REPO_SRC_PATH_STR) -from churro_ocr._internal.litellm import close_litellm_async_clients -from churro_ocr._internal.logging import logger -from churro_ocr.errors import ConfigurationError -from churro_ocr.ocr import BatchOCRBackend, OCRBackend, OCRBackendLike -from churro_ocr.page_detection import DocumentPage -from churro_ocr.providers import ( +from churro_ocr._internal.litellm import close_litellm_async_clients # noqa: E402 +from churro_ocr._internal.logging import logger # noqa: E402 +from churro_ocr.errors import ConfigurationError # noqa: E402 +from churro_ocr.ocr import BatchOCRBackend, OCRBackend, OCRBackendLike # noqa: E402 +from churro_ocr.page_detection import DocumentPage # noqa: E402 +from churro_ocr.providers import ( # noqa: E402 AzureDocumentIntelligenceOptions, - build_ocr_backend, HuggingFaceOptions, LiteLLMTransportConfig, MistralOptions, OCRBackendSpec, OpenAICompatibleOptions, + build_ocr_backend, ) -from churro_ocr.providers.specs import MISTRAL_OCR_MODEL_IDS, validate_mistral_ocr_model -from tooling.benchmarking.dataset import ( +from churro_ocr.providers.specs import MISTRAL_OCR_MODEL_IDS, validate_mistral_ocr_model # noqa: E402 +from tooling.benchmarking.dataset import ( # noqa: E402 DatasetSelection, DatasetSubset, load_dataset_split, ) -from tooling.evaluation.metrics import compute_metrics -from tooling.evaluation.types import ( - BenchmarkDatasetExample, - BenchmarkPrediction, - EvaluationExample, - to_evaluation_example, -) +from tooling.evaluation.metrics import compute_metrics # noqa: E402 +from tooling.evaluation.types import to_evaluation_example # noqa: E402 + +if TYPE_CHECKING: + from collections.abc import Iterable + + from tooling.evaluation.types import ( + BenchmarkDatasetExample, + BenchmarkPrediction, + EvaluationExample, + ) CHURRO_DATASET_ID = "stanford-oval/churro-dataset" VALID_DATASET_SPLITS = {"dev", "test"} @@ -63,6 +66,13 @@ "main_language", "main_script", ) +_PREDICTION_FAILURES = ( + AssertionError, + OSError, + RuntimeError, + TypeError, + ValueError, +) @dataclass(slots=True) @@ -201,8 +211,11 @@ def create_output_prefix(options: BenchmarkOptions) -> str: return str(output_dir) -def _load_dataset(dataset_id: str, *, split: str) -> Any: - return load_dataset_split(dataset_id, split, columns=BENCHMARK_DATASET_COLUMNS) +def _load_dataset(dataset_id: str, *, split: str) -> Iterable[BenchmarkDatasetExample]: + return cast( + "Iterable[BenchmarkDatasetExample]", + load_dataset_split(dataset_id, split, columns=BENCHMARK_DATASET_COLUMNS), + ) def _default_litellm_cache_dir() -> Path: @@ -316,9 +329,9 @@ def _log_first_benchmark_output(*, options: BenchmarkOptions, text: str) -> None ) -def _failure_metadata(exc: BaseException) -> dict[str, Any]: +def _failure_metadata(exc: BaseException) -> dict[str, object]: message = str(exc).strip() - metadata: dict[str, Any] = { + metadata: dict[str, object] = { "benchmark_error": { "type": type(exc).__name__, } @@ -341,6 +354,7 @@ def _log_prediction_failure( example: BenchmarkDatasetExample, exc: BaseException, ) -> None: + del exc logger.exception( "Benchmark OCR failed for example_id=%s dataset_id=%s backend=%s model=%s; " "treating prediction as empty.", @@ -404,7 +418,7 @@ async def _predict_texts( } for result in batch_results ) - except Exception as exc: + except _PREDICTION_FAILURES as exc: for example in batch_examples: _log_prediction_failure(options=options, example=example, exc=exc) predictions.extend(_empty_prediction_for_failure(exc) for _ in pages) @@ -422,7 +436,7 @@ async def _predict(index: int, example: BenchmarkDatasetExample) -> tuple[int, B else: assert isinstance(ocr_backend, OCRBackend) result = await ocr_backend.ocr(page) - except Exception as exc: + except _PREDICTION_FAILURES as exc: _log_prediction_failure(options=options, example=example, exc=exc) return index, _empty_prediction_for_failure(exc) return index, { diff --git a/tooling/benchmarking/dataset.py b/tooling/benchmarking/dataset.py index d757491..f0fd599 100644 --- a/tooling/benchmarking/dataset.py +++ b/tooling/benchmarking/dataset.py @@ -2,12 +2,22 @@ from __future__ import annotations -from collections.abc import Iterable, Sequence from dataclasses import dataclass from itertools import islice -from typing import Any +from typing import TYPE_CHECKING, Protocol, cast -from tooling.evaluation.types import BenchmarkDatasetExample, EvaluationExample +if TYPE_CHECKING: + from collections.abc import Iterable, Sequence + + from tooling.evaluation.types import BenchmarkDatasetExample, EvaluationExample + + +class _FilterableDataset(Protocol): + def filter(self, function: object, *, input_columns: list[str]) -> object: ... + + +class _SelectableDataset(Protocol): + def select(self, indices: range) -> object: ... def _normalize_filter_value(value: str | None) -> str | None: @@ -23,7 +33,7 @@ def load_dataset_split( split: str, *, columns: Sequence[str] | None = None, -) -> Any: +) -> object: """Load one dataset split directly from its parquet shards.""" from datasets import Features, load_dataset, load_dataset_builder @@ -67,12 +77,10 @@ def matches(self, example: EvaluationExample | BenchmarkDatasetExample) -> bool: """Return whether an example belongs to this subset.""" if self.language is not None and _normalize_filter_value(example["main_language"]) != self.language: return False - if ( - self.document_type is not None - and _normalize_filter_value(example["document_type"]) != self.document_type - ): - return False - return True + return ( + self.document_type is None + or _normalize_filter_value(example["document_type"]) == self.document_type + ) def output_suffixes(self) -> list[str]: """Build stable directory suffixes for filtered benchmark runs.""" @@ -95,17 +103,20 @@ class DatasetSelection: def select(self, dataset_stream: Iterable[BenchmarkDatasetExample]) -> Iterable[BenchmarkDatasetExample]: """Yield the requested dataset subset without materializing it upfront.""" if hasattr(dataset_stream, "filter") and hasattr(dataset_stream, "select"): - return self._select_materialized_dataset(dataset_stream) + return cast( + "Iterable[BenchmarkDatasetExample]", + self._select_materialized_dataset(dataset_stream), + ) filtered_stream = (example for example in dataset_stream if self.subset.matches(example)) end_index = self.offset + self.limit if self.limit > 0 else None return islice(filtered_stream, self.offset, end_index) - def _select_materialized_dataset(self, dataset: Any) -> Any: + def _select_materialized_dataset(self, dataset: object) -> object: """Apply subset filters and slicing to a materialized HF dataset.""" selected = dataset if self.subset.is_active(): - selected = selected.filter( + selected = cast("_FilterableDataset", selected).filter( self._matches_materialized_row, input_columns=["main_language", "document_type"], ) @@ -119,15 +130,16 @@ def _select_materialized_dataset(self, dataset: Any) -> Any: start_index = min(self.offset, total_rows) end_index = total_rows if self.limit <= 0 else min(start_index + self.limit, total_rows) - return selected.select(range(start_index, end_index)) + return cast("_SelectableDataset", selected).select(range(start_index, end_index)) def _matches_materialized_row(self, main_language: str, document_type: str) -> bool: """Return whether one materialized row matches the active subset filters.""" - if self.subset.language is not None and _normalize_filter_value(main_language) != self.subset.language: - return False if ( - self.subset.document_type is not None - and _normalize_filter_value(document_type) != self.subset.document_type + self.subset.language is not None + and _normalize_filter_value(main_language) != self.subset.language ): return False - return True + return ( + self.subset.document_type is None + or _normalize_filter_value(document_type) == self.subset.document_type + ) diff --git a/tooling/evaluation/evaluate_page.py b/tooling/evaluation/evaluate_page.py index 3819474..980613e 100644 --- a/tooling/evaluation/evaluate_page.py +++ b/tooling/evaluation/evaluate_page.py @@ -3,7 +3,7 @@ from __future__ import annotations import multiprocessing -from typing import Any +from typing import TYPE_CHECKING, Any try: # pragma: no cover - optional dependency import nltk @@ -16,15 +16,29 @@ from churro_ocr.prompts import strip_ocr_output_tag from tooling.evaluation.normalization import normalize_text_for_evaluation from tooling.evaluation.repetition import has_long_repetition -from tooling.evaluation.types import ( - EvaluationExample, - MetricInputExample, - PageEvaluationMetrics, - PageEvaluationResult, -) from tooling.evaluation.xml_utils import extract_actual_text_from_xml +if TYPE_CHECKING: + from tooling.evaluation.types import ( + EvaluationExample, + MetricInputExample, + PageEvaluationMetrics, + PageEvaluationResult, + ) + bleu_metric: Any | None = None +_METRIC_COMPUTATION_ERRORS = ( + AttributeError, + LookupError, + ModuleNotFoundError, + RuntimeError, + TypeError, + ValueError, +) + + +def _missing_dependency_error(message: str) -> ModuleNotFoundError: + return ModuleNotFoundError(message) def initialize_metrics() -> None: @@ -33,13 +47,13 @@ def initialize_metrics() -> None: if bleu_metric is not None: return if nltk is None: - raise ModuleNotFoundError("BLEU evaluation requires the optional dependency 'nltk'.") + message = "BLEU evaluation requires the optional dependency 'nltk'." + raise _missing_dependency_error(message) try: # pragma: no cover - optional dependency import evaluate except ModuleNotFoundError as exc: # pragma: no cover - optional dependency - raise ModuleNotFoundError( - "BLEU evaluation requires the optional dependency 'evaluate'." - ) from exc + message = "BLEU evaluation requires the optional dependency 'evaluate'." + raise _missing_dependency_error(message) from exc nltk.download("wordnet", quiet=True) nltk.download("punkt_tab", quiet=True) nltk.download("omw-1.4", quiet=True) @@ -98,7 +112,7 @@ def _compute_text_metrics_core( predictions=[predicted_text], references=[[gold_text]], )["bleu"] - except Exception as exc: # pragma: no cover - defensive guard + except _METRIC_COMPUTATION_ERRORS as exc: # pragma: no cover - defensive guard logger.error("Error in metric computation: %s", exc) return { @@ -165,7 +179,7 @@ def calculate_metrics(inputs: tuple[MetricInputExample, str]) -> PageEvaluationM language=main_language, script=main_script, ) - except Exception as exc: # pragma: no cover - defensive guard + except _METRIC_COMPUTATION_ERRORS as exc: # pragma: no cover - defensive guard logger.error("Error in evaluation of %s: %s", example_id, exc) return _build_failed_metrics( predicted_text=predicted_text, diff --git a/tooling/evaluation/metrics.py b/tooling/evaluation/metrics.py index ba1ac3c..1b86234 100644 --- a/tooling/evaluation/metrics.py +++ b/tooling/evaluation/metrics.py @@ -2,20 +2,23 @@ from __future__ import annotations -from collections import defaultdict -from collections.abc import Sequence import json +from collections import defaultdict from pathlib import Path -from typing import Any +from typing import TYPE_CHECKING, Any from churro_ocr._internal.logging import logger from tooling.evaluation.evaluate_page import batch_evaluate -from tooling.evaluation.types import ( - BenchmarkOutputRow, - BenchmarkPrediction, - EvaluationExample, - PageEvaluationResult, -) + +if TYPE_CHECKING: + from collections.abc import Sequence + + from tooling.evaluation.types import ( + BenchmarkOutputRow, + BenchmarkPrediction, + EvaluationExample, + PageEvaluationResult, + ) def _get_llm_total_cost() -> float: @@ -70,8 +73,7 @@ def calculate_language_and_type_metrics( for document_type, values in type_to_metrics.items() } averaged_language_type = { - key: sum(values) / len(values) if values else 0.0 - for key, values in language_type_to_metrics.items() + key: sum(values) / len(values) if values else 0.0 for key, values in language_type_to_metrics.items() } return averaged_language, averaged_type, averaged_language_type diff --git a/tooling/evaluation/normalization.py b/tooling/evaluation/normalization.py index 882efc0..181286a 100644 --- a/tooling/evaluation/normalization.py +++ b/tooling/evaluation/normalization.py @@ -4,12 +4,18 @@ import re import unicodedata -from collections.abc import Callable -from typing import cast +from typing import TYPE_CHECKING, cast + +if TYPE_CHECKING: + from collections.abc import Callable from churro_ocr.prompts import strip_rich_ocr_markup_to_plain_text +def _missing_dependency_error(message: str) -> ModuleNotFoundError: + return ModuleNotFoundError(message) + + normalize_hamza: Callable[..., str] | None strip_harakat: Callable[..., str] | None strip_lastharaka: Callable[..., str] | None @@ -19,17 +25,25 @@ try: # pragma: no cover - optional dependency from pyarabic.araby import ( normalize_hamza as _normalize_hamza, + ) + from pyarabic.araby import ( strip_harakat as _strip_harakat, + ) + from pyarabic.araby import ( strip_lastharaka as _strip_lastharaka, + ) + from pyarabic.araby import ( strip_tashkeel as _strip_tashkeel, + ) + from pyarabic.araby import ( strip_tatweel as _strip_tatweel, ) - normalize_hamza = cast(Callable[..., str], _normalize_hamza) - strip_harakat = cast(Callable[..., str], _strip_harakat) - strip_lastharaka = cast(Callable[..., str], _strip_lastharaka) - strip_tashkeel = cast(Callable[..., str], _strip_tashkeel) - strip_tatweel = cast(Callable[..., str], _strip_tatweel) + normalize_hamza = cast("Callable[..., str]", _normalize_hamza) + strip_harakat = cast("Callable[..., str]", _strip_harakat) + strip_lastharaka = cast("Callable[..., str]", _strip_lastharaka) + strip_tashkeel = cast("Callable[..., str]", _strip_tashkeel) + strip_tatweel = cast("Callable[..., str]", _strip_tatweel) except ModuleNotFoundError: # pragma: no cover - optional dependency normalize_hamza = None strip_harakat = None @@ -63,9 +77,11 @@ def normalize_characters(text: str, *, keep_long_s: bool = True) -> str: if keep_long_s: text = text.replace(placeholder, "ſ") - text = SUBSTITUTION_PATTERN.sub(lambda match: SUBSTITUTIONS[match.group(0)], text) - text = re.sub(r"(^|\s)~(?=\w)", r"\1", text) - return text + return re.sub( + r"(^|\s)~(?=\w)", + r"\1", + SUBSTITUTION_PATTERN.sub(lambda match: SUBSTITUTIONS[match.group(0)], text), + ) def normalize_text_for_evaluation(text: str, *, normalize_arabic: bool = False) -> str: @@ -80,14 +96,13 @@ def normalize_text_for_evaluation(text: str, *, normalize_arabic: bool = False) or strip_tatweel is None or normalize_hamza is None ): - raise ModuleNotFoundError( - "Arabic normalization requires the optional dependency 'pyarabic'." - ) - strip_tashkeel_fn = cast(Callable[[str], str], strip_tashkeel) - strip_harakat_fn = cast(Callable[[str], str], strip_harakat) - strip_lastharaka_fn = cast(Callable[[str], str], strip_lastharaka) - strip_tatweel_fn = cast(Callable[[str], str], strip_tatweel) - normalize_hamza_fn = cast(Callable[[str], str], normalize_hamza) + message = "Arabic normalization requires the optional dependency 'pyarabic'." + raise _missing_dependency_error(message) + strip_tashkeel_fn = cast("Callable[[str], str]", strip_tashkeel) + strip_harakat_fn = cast("Callable[[str], str]", strip_harakat) + strip_lastharaka_fn = cast("Callable[[str], str]", strip_lastharaka) + strip_tatweel_fn = cast("Callable[[str], str]", strip_tatweel) + normalize_hamza_fn = cast("Callable[[str], str]", normalize_hamza) text = strip_tashkeel_fn(text) text = strip_harakat_fn(text) @@ -103,6 +118,4 @@ def normalize_text_for_evaluation(text: str, *, normalize_arabic: bool = False) text = re.sub(r"\s+([.,?!;:])", r"\1", text) text = re.sub(r"(\w+)-\s*\n\s*(\w+)", r"\1\2", text) text = text.strip("-") - text = normalize_characters(text, keep_long_s=False) - text = re.sub(r"\s+", " ", text).strip() - return text + return re.sub(r"\s+", " ", normalize_characters(text, keep_long_s=False)).strip() diff --git a/tooling/evaluation/xml_utils.py b/tooling/evaluation/xml_utils.py index 5e765cd..5f4da49 100644 --- a/tooling/evaluation/xml_utils.py +++ b/tooling/evaluation/xml_utils.py @@ -17,9 +17,11 @@ def _local_name(tag: str) -> str: def _remove_tag(xml_content: str, tag_name: str) -> str: if f"<{tag_name}" not in xml_content: return xml_content - xml_content = re.sub(rf"<{tag_name}\b[^>]*>.*?", "", xml_content, flags=re.DOTALL) - xml_content = re.sub(rf"<{tag_name}\b[^>]*/>", "", xml_content) - return xml_content + return re.sub( + rf"<{tag_name}\b[^>]*/>", + "", + re.sub(rf"<{tag_name}\b[^>]*>.*?", "", xml_content, flags=re.DOTALL), + ) def extract_actual_text_from_xml(xml_content: str) -> str: From b42f03b08dde8b6440dd35bbb4ceb4dfa4d68129 Mon Sep 17 00:00:00 2001 From: Sina Date: Mon, 13 Apr 2026 18:10:23 +0000 Subject: [PATCH 34/58] refactor(providers): tighten provider backend typing --- src/churro_ocr/_internal/litellm.py | 75 +++-- src/churro_ocr/providers/__init__.py | 21 +- src/churro_ocr/providers/_mineru25.py | 42 ++- src/churro_ocr/providers/_shared.py | 7 +- src/churro_ocr/providers/builder.py | 39 ++- src/churro_ocr/providers/hf.py | 292 ++++++++++++------ src/churro_ocr/providers/ocr.py | 109 +++++-- src/churro_ocr/providers/page_detection.py | 195 ++++++++---- src/churro_ocr/providers/specs.py | 45 +-- tests/test_hf_ocr.py | 56 ++-- tests/test_hf_ocr_integration.py | 9 +- tests/test_page_detection_integration.py | 5 +- tests/test_page_detection_provider_helpers.py | 25 +- tests/test_providers.py | 99 +++--- 14 files changed, 664 insertions(+), 355 deletions(-) diff --git a/src/churro_ocr/_internal/litellm.py b/src/churro_ocr/_internal/litellm.py index e2f57d2..ba53d07 100644 --- a/src/churro_ocr/_internal/litellm.py +++ b/src/churro_ocr/_internal/litellm.py @@ -5,26 +5,42 @@ import asyncio import inspect import logging -from collections.abc import Sequence from contextlib import suppress from importlib import import_module from pathlib import Path from time import monotonic -from typing import Any, cast - -from PIL import Image +from typing import TYPE_CHECKING, Any, cast from churro_ocr._internal.image import image_to_base64 from churro_ocr._internal.install import install_command_hint from churro_ocr._internal.retry import retry_api_call from churro_ocr.errors import ConfigurationError, ProviderError from churro_ocr.providers.specs import LiteLLMTransportConfig -from churro_ocr.templates import OCRConversation + +if TYPE_CHECKING: + from collections.abc import Sequence + + from PIL import Image + + from churro_ocr.templates import OCRConversation + from churro_ocr.types import OCRConversationContentItem _INITIALIZED = False _DISK_CACHE_DIR: str | None = None +def _configuration_error(message: str) -> ConfigurationError: + return ConfigurationError(message) + + +def _provider_error(message: str) -> ProviderError: + return ProviderError(message) + + +def _timeout_error(message: str) -> TimeoutError: + return TimeoutError(message) + + def _ensure_initialized() -> None: global _INITIALIZED if _INITIALIZED: @@ -32,11 +48,10 @@ def _ensure_initialized() -> None: try: import litellm except ImportError as exc: # pragma: no cover - optional extra path - raise ConfigurationError( - f"LiteLLM-backed providers require the `llm` runtime. {install_command_hint('llm')}" - ) from exc + message = f"LiteLLM-backed providers require the `llm` runtime. {install_command_hint('llm')}" + raise _configuration_error(message) from exc - litellm_any = cast(Any, litellm) + litellm_any = cast("Any", litellm) litellm_any.turn_off_message_logging = True litellm_any.success_callback = [] litellm_any.failure_callback = [] @@ -57,15 +72,17 @@ def _ensure_initialized() -> None: if global_logging_worker is not None: original_enqueue = global_logging_worker.ensure_initialized_and_enqueue - def _enqueue_if_enabled(async_coroutine: Any) -> None: + def _enqueue_if_enabled(async_coroutine: object) -> None: if getattr(litellm, "turn_off_message_logging", False): - with suppress(Exception): - async_coroutine.close() + close_method = getattr(async_coroutine, "close", None) + if callable(close_method): + with suppress(Exception): + close_method() return original_enqueue(async_coroutine) global_logging_worker.ensure_initialized_and_enqueue = _enqueue_if_enabled - except Exception: + except (AttributeError, ImportError, TypeError): pass _INITIALIZED = True @@ -129,7 +146,7 @@ async def close_litellm_async_clients() -> None: except ImportError: return - litellm_any = cast(Any, litellm) + litellm_any = cast("Any", litellm) resources: list[object] = [] cache = getattr(litellm_any, "in_memory_llm_clients_cache", None) @@ -242,16 +259,18 @@ async def complete_text( if self._config.completion_kwargs: kwargs.update(self._config.completion_kwargs) request_timeout_seconds = float(timeout_seconds) + # Keep LiteLLM's provider timeout stable so its client caches can be reused + # across attempts and requests. The outer wait_for still enforces the + # shrinking wall-clock deadline for this overall operation. kwargs["timeout"] = request_timeout_seconds deadline = monotonic() + request_timeout_seconds - async def _run_completion() -> Any: + async def _run_completion() -> object: attempt_kwargs = dict(kwargs) remaining_timeout_seconds = max(0.0, deadline - monotonic()) if remaining_timeout_seconds <= 0: - raise TimeoutError( - f"LiteLLM request exceeded the total timeout of {timeout_seconds} seconds." - ) + message = f"LiteLLM request exceeded the total timeout of {timeout_seconds} seconds." + raise _timeout_error(message) return await asyncio.wait_for( acompletion(**attempt_kwargs), timeout=remaining_timeout_seconds, @@ -265,10 +284,11 @@ async def _run_completion() -> Any: max_total_seconds=float(timeout_seconds), ) except Exception as exc: # pragma: no cover - provider-specific failure path - raise ProviderError(f"LiteLLM request failed for model '{model}': {exc}") from exc + message = f"LiteLLM request failed for model '{model}': {exc}" + raise _provider_error(message) from exc self._record_response_cost(model=model, response=response) - answer = response.choices[0].message.content + answer = cast("Any", response).choices[0].message.content if isinstance(answer, str): if answer.strip(): return answer @@ -277,8 +297,10 @@ async def _run_completion() -> Any: elif answer is None and allow_empty: return "" if not isinstance(answer, str): - raise ProviderError(f"LiteLLM returned empty output for model '{model}'.") - raise ProviderError(f"LiteLLM returned empty output for model '{model}'.") + message = f"LiteLLM returned empty output for model '{model}'." + raise _provider_error(message) + message = f"LiteLLM returned empty output for model '{model}'." + raise _provider_error(message) def _resolved_image_detail(self) -> str | None: return "high" if self._config.image_detail is None else self._config.image_detail @@ -336,12 +358,12 @@ def _extract_response_cost(*, model: str, response: object) -> float | None: try: from litellm import completion_cost - except Exception: + except ImportError: return None try: cost = completion_cost(completion_response=response, model=model) - except Exception: + except (AttributeError, KeyError, LookupError, TypeError, ValueError): return None if not isinstance(cost, (int, float)): return None @@ -356,7 +378,7 @@ def _prepare_messages_from_conversation( """Convert a structured OCR conversation into LiteLLM/OpenAI-style messages.""" messages: list[dict[str, Any]] = [] for message in conversation: - content_items = cast("list[dict[str, Any]]", message["content"]) + content_items = cast("list[OCRConversationContentItem]", message["content"]) content: list[dict[str, Any]] = [] for item in content_items: if item.get("type") == "image": @@ -434,9 +456,10 @@ async def complete_text( __all__ = [ + "LiteLLMTransport", + "close_litellm_async_clients", "complete_text", "configure_disk_cache", - "LiteLLMTransport", "prepare_messages", "prepare_messages_from_conversation", ] diff --git a/src/churro_ocr/providers/__init__.py b/src/churro_ocr/providers/__init__.py index 0a96528..f28fb6a 100644 --- a/src/churro_ocr/providers/__init__.py +++ b/src/churro_ocr/providers/__init__.py @@ -3,7 +3,7 @@ from __future__ import annotations from importlib import import_module -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING from churro_ocr.ocr import BatchOCRBackend @@ -55,30 +55,35 @@ } __all__ = [ + "DEFAULT_OCR_MAX_TOKENS", "AzureDocumentIntelligenceOptions", "AzurePageDetector", "BatchOCRBackend", - "build_ocr_backend", - "DEFAULT_OCR_MAX_TOKENS", "HuggingFaceOptions", - "LiteLLMTransportConfig", "LLMPageDetector", - "locate_text_block_bbox_with_llm", - "locate_text_block_bbox_with_llm_sync", + "LiteLLMTransportConfig", "MistralOptions", "OCRBackendSpec", "OCRModelProfile", "OpenAICompatibleOptions", + "build_ocr_backend", + "locate_text_block_bbox_with_llm", + "locate_text_block_bbox_with_llm_sync", "resolve_ocr_profile", ] -def __getattr__(name: str) -> Any: +def _attribute_error(message: str) -> AttributeError: + return AttributeError(message) + + +def __getattr__(name: str) -> object: """Lazy-load provider exports to avoid circular imports during package init.""" try: module_name, attr_name = _LAZY_EXPORTS[name] except KeyError as exc: - raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from exc + message = f"module {__name__!r} has no attribute {name!r}" + raise _attribute_error(message) from exc value = getattr(import_module(module_name), attr_name) globals()[name] = value diff --git a/src/churro_ocr/providers/_mineru25.py b/src/churro_ocr/providers/_mineru25.py index 19e971e..ead1513 100644 --- a/src/churro_ocr/providers/_mineru25.py +++ b/src/churro_ocr/providers/_mineru25.py @@ -9,13 +9,15 @@ import math import random import re -from collections.abc import Awaitable, Callable from dataclasses import dataclass, replace from io import BytesIO -from typing import Literal, cast +from typing import TYPE_CHECKING, Literal, cast from PIL import Image, ImageDraw, ImageFont +if TYPE_CHECKING: + from collections.abc import Awaitable, Callable + MINERU2_5_LAYOUT_IMAGE_SIZE = (1_036, 1_036) MINERU2_5_MIN_IMAGE_EDGE = 28 MINERU2_5_MAX_IMAGE_EDGE_RATIO = 50 @@ -124,6 +126,22 @@ } +def _attribute_error(message: str) -> AttributeError: + return AttributeError(message) + + +def _runtime_error(message: str) -> RuntimeError: + return RuntimeError(message) + + +def _type_error(message: str) -> TypeError: + return TypeError(message) + + +def _value_error(message: str) -> ValueError: + return ValueError(message) + + @dataclass(slots=True, frozen=True) class MinerU25SamplingParams: """Sampling parameters used by the MinerU2.5 two-step pipeline.""" @@ -161,9 +179,11 @@ def __init__( ) -> None: super().__init__() if type not in _SUPPORTED_BLOCK_TYPES: - raise ValueError(f"Unknown MinerU2.5 block type {type!r}.") + message = f"Unknown MinerU2.5 block type {type!r}." + raise _value_error(message) if len(bbox) != 4 or bbox[0] >= bbox[2] or bbox[1] >= bbox[3]: - raise ValueError(f"Invalid MinerU2.5 bbox {bbox!r}.") + message = f"Invalid MinerU2.5 bbox {bbox!r}." + raise _value_error(message) self["type"] = type self["bbox"] = bbox self["angle"] = angle @@ -178,7 +198,8 @@ def type(self) -> str: @type.setter def type(self, value: str) -> None: if value not in _SUPPORTED_BLOCK_TYPES: - raise ValueError(f"Unknown MinerU2.5 block type {value!r}.") + message = f"Unknown MinerU2.5 block type {value!r}." + raise _value_error(message) merge_prev = self.get("merge_prev", False) self["type"] = value if value == "text": @@ -190,7 +211,8 @@ def type(self, value: str) -> None: def bbox(self) -> list[float]: bbox = self["bbox"] if not isinstance(bbox, list): - raise TypeError(f"MinerU2.5 bbox payload must be a list, got {type(bbox).__name__}.") + message = f"MinerU2.5 bbox payload must be a list, got {type(bbox).__name__}." + raise _type_error(message) return [float(coord) for coord in cast("list[int | float]", bbox)] @bbox.setter @@ -221,7 +243,8 @@ def merge_prev(self) -> bool: @merge_prev.setter def merge_prev(self, value: bool) -> None: if self.type != "text": - raise AttributeError("merge_prev is only valid for MinerU2.5 text blocks.") + message = "merge_prev is only valid for MinerU2.5 text blocks." + raise _attribute_error(message) self["merge_prev"] = bool(value) @@ -425,7 +448,7 @@ def _build_table_image_map( if best_table_index is not None: table_to_images[best_table_index].append(image_index) - for _table_index, image_indices in table_to_images.items(): + for image_indices in table_to_images.values(): image_indices.sort(key=lambda image_index: (blocks[image_index].bbox[1], blocks[image_index].bbox[0])) return table_to_images @@ -540,7 +563,8 @@ def _font_for_box( continue if len(used_token_codes) >= max_token_count: - raise RuntimeError("Exhausted MinerU2.5 table image token space.") + message = "Exhausted MinerU2.5 table image token space." + raise _runtime_error(message) while True: token_code = _generate_uid() diff --git a/src/churro_ocr/providers/_shared.py b/src/churro_ocr/providers/_shared.py index 9b7b167..7225e9d 100644 --- a/src/churro_ocr/providers/_shared.py +++ b/src/churro_ocr/providers/_shared.py @@ -19,6 +19,10 @@ from churro_ocr.types import MetadataDict +def _configuration_error(message: str) -> ConfigurationError: + return ConfigurationError(message) + + def preprocess_backend_page( page: DocumentPage, *, @@ -56,10 +60,11 @@ def render_ocr_prompt( ) return rendered, conversation - raise ConfigurationError( + message = ( "OCR prompt rendering requires either `processor.apply_chat_template(...)`, " "or `processor.tokenizer.apply_chat_template(...)`." ) + raise _configuration_error(message) def normalize_media_inputs(media_inputs: object | None) -> object | None: diff --git a/src/churro_ocr/providers/builder.py b/src/churro_ocr/providers/builder.py index 4d1f4bd..016eb0c 100644 --- a/src/churro_ocr/providers/builder.py +++ b/src/churro_ocr/providers/builder.py @@ -2,9 +2,10 @@ from __future__ import annotations +from typing import TYPE_CHECKING + from churro_ocr._internal.litellm import LiteLLMTransport from churro_ocr.errors import ConfigurationError -from churro_ocr.ocr import OCRBackend from churro_ocr.providers.hf import ( ChandraOCR2OCRBackend, DeepSeekOCR2OCRBackend, @@ -36,6 +37,13 @@ ) from churro_ocr.templates import MINERU2_5_2509_1_2B_MODEL_ID +if TYPE_CHECKING: + from churro_ocr.ocr import OCRBackend + + +def _configuration_error(message: str) -> ConfigurationError: + return ConfigurationError(message) + def _merge_mapping( base: dict[str, object], @@ -111,10 +119,11 @@ def _ensure_options_type[T](options: object | None, expected: type[T], *, provid if options is None: return None if not isinstance(options, expected): - raise ConfigurationError( + message = ( f"OCR provider '{provider}' requires options of type {expected.__name__}, " f"got {type(options).__name__}." ) + raise _configuration_error(message) return options @@ -128,12 +137,14 @@ def _resolve_model_name(profile: OCRModelProfile, model: str | None, *, fallback def _build_litellm_backend(spec: OCRBackendSpec, profile: OCRModelProfile) -> OCRBackend: if spec.model is None: - raise ConfigurationError("OCR provider 'litellm' requires `model`.") + message = "OCR provider 'litellm' requires `model`." + raise _configuration_error(message) if spec.model == MINERU2_5_2509_1_2B_MODEL_ID: - raise ConfigurationError( + message = ( "MinerU2.5 requires the built-in two-step pipeline. Use provider 'hf' for local " "Transformers inference or provider 'openai-compatible' for a served vLLM endpoint." ) + raise _configuration_error(message) transport_config = _merge_transport_config(profile.transport, spec.transport) return LiteLLMVisionOCRBackend( model=spec.model, @@ -147,13 +158,15 @@ def _build_litellm_backend(spec: OCRBackendSpec, profile: OCRModelProfile) -> OC def _build_openai_compatible_backend(spec: OCRBackendSpec, profile: OCRModelProfile) -> OCRBackend: if spec.model is None: - raise ConfigurationError("OCR provider 'openai-compatible' requires `model`.") + message = "OCR provider 'openai-compatible' requires `model`." + raise _configuration_error(message) options = _merge_openai_options( _ensure_options_type(spec.options, OpenAICompatibleOptions, provider=spec.provider) ) transport_config = _merge_transport_config(profile.transport, spec.transport) if not transport_config.api_base: - raise ConfigurationError("OCR provider 'openai-compatible' requires `transport.api_base`.") + message = "OCR provider 'openai-compatible' requires `transport.api_base`." + raise _configuration_error(message) backend_cls: type[OpenAICompatibleOCRBackend] = OpenAICompatibleOCRBackend if spec.model == MINERU2_5_2509_1_2B_MODEL_ID: backend_cls = MinerU25OpenAICompatibleOCRBackend @@ -170,7 +183,8 @@ def _build_openai_compatible_backend(spec: OCRBackendSpec, profile: OCRModelProf def _build_huggingface_backend(spec: OCRBackendSpec, profile: OCRModelProfile) -> OCRBackend: if spec.model is None: - raise ConfigurationError("OCR provider 'hf' requires `model`.") + message = "OCR provider 'hf' requires `model`." + raise _configuration_error(message) options = _merge_huggingface_options( profile.huggingface, _ensure_options_type(spec.options, HuggingFaceOptions, provider=spec.provider), @@ -209,9 +223,8 @@ def _build_huggingface_backend(spec: OCRBackendSpec, profile: OCRModelProfile) - def _build_azure_backend(spec: OCRBackendSpec, profile: OCRModelProfile) -> OCRBackend: options = _ensure_options_type(spec.options, AzureDocumentIntelligenceOptions, provider=spec.provider) if options is None or not options.endpoint or not options.api_key: - raise ConfigurationError( - "OCR provider 'azure' requires AzureDocumentIntelligenceOptions(endpoint=..., api_key=...)." - ) + message = "OCR provider 'azure' requires AzureDocumentIntelligenceOptions(endpoint=..., api_key=...)." + raise _configuration_error(message) model_id = spec.model or "prebuilt-layout" return AzureDocumentIntelligenceOCRBackend( endpoint=options.endpoint, @@ -226,7 +239,8 @@ def _build_azure_backend(spec: OCRBackendSpec, profile: OCRModelProfile) -> OCRB def _build_mistral_backend(spec: OCRBackendSpec, profile: OCRModelProfile) -> OCRBackend: options = _ensure_options_type(spec.options, MistralOptions, provider=spec.provider) if options is None or not options.api_key: - raise ConfigurationError("OCR provider 'mistral' requires MistralOptions(api_key=...).") + message = "OCR provider 'mistral' requires MistralOptions(api_key=...)." + raise _configuration_error(message) model = validate_mistral_ocr_model(spec.model) return MistralOCRBackend( api_key=options.api_key, @@ -257,7 +271,8 @@ def build_ocr_backend(spec: OCRBackendSpec) -> OCRBackend: return _build_azure_backend(spec, profile) if spec.provider == "mistral": return _build_mistral_backend(spec, profile) - raise ConfigurationError(f"Unsupported OCR provider '{spec.provider}'.") + message = f"Unsupported OCR provider '{spec.provider}'." + raise _configuration_error(message) __all__ = ["build_ocr_backend"] diff --git a/src/churro_ocr/providers/hf.py b/src/churro_ocr/providers/hf.py index f54a342..f154563 100644 --- a/src/churro_ocr/providers/hf.py +++ b/src/churro_ocr/providers/hf.py @@ -9,9 +9,7 @@ from pathlib import Path from tempfile import TemporaryDirectory from types import MethodType -from typing import Any, cast - -from PIL import Image +from typing import TYPE_CHECKING, Any, Protocol, cast from churro_ocr._internal.image import ensure_rgb from churro_ocr._internal.install import install_command_hint @@ -70,12 +68,27 @@ build_ocr_conversation, ) +if TYPE_CHECKING: + from collections.abc import Mapping + + from PIL import Image + + from churro_ocr.types import OCRConversationContentItem + _HF_EXTRA_INSTALL_HINT = install_command_hint("hf") _HF_TORCH_INSTALL_HINT = ( f"Hugging Face OCR requires a separately installed PyTorch runtime. {_HF_EXTRA_INSTALL_HINT}" ) +def _configuration_error(message: str) -> ConfigurationError: + return ConfigurationError(message) + + +def _provider_error(message: str) -> ProviderError: + return ProviderError(message) + + @dataclass(slots=True) class _HFRuntime: processor_cls: Any @@ -83,6 +96,43 @@ class _HFRuntime: process_vision_info: Any +class _HFProcessorCallable(Protocol): + def __call__(self, **kwargs: object) -> object: ... + + +class _HFProcessorDecoder(Protocol): + def batch_decode( + self, + token_ids: object, + *, + skip_special_tokens: bool, + clean_up_tokenization_spaces: bool, + ) -> list[str]: ... + + +class _HFChatTemplateProcessor(Protocol): + def apply_chat_template( + self, + conversations: object, + **kwargs: object, + ) -> dict[str, object]: ... + + +class _HFGenerativeModel(Protocol): + def generate(self, **kwargs: object) -> object: ... + + +class _TorchCudaNamespace(Protocol): + def is_available(self) -> bool: ... + + def mem_get_info(self) -> tuple[int, int]: ... + + +class _TorchModuleLike(Protocol): + cuda: _TorchCudaNamespace + bfloat16: object + + def _ensure_hf_torch_runtime() -> None: try: import_module("torch") @@ -90,15 +140,34 @@ def _ensure_hf_torch_runtime() -> None: raise ConfigurationError(_HF_TORCH_INSTALL_HINT) from exc +def _load_torch_module() -> _TorchModuleLike: + return cast("_TorchModuleLike", import_module("torch")) + + +def _call_processor(processor: object, **kwargs: object) -> dict[str, object]: + return cast("dict[str, object]", cast("_HFProcessorCallable", processor)(**kwargs)) + + +def _generate_with_model(model: object, **kwargs: object) -> object: + return cast("_HFGenerativeModel", model).generate(**kwargs) + + +def _apply_chat_template( + processor: object, + conversations: object, + **kwargs: object, +) -> dict[str, object]: + return cast("_HFChatTemplateProcessor", processor).apply_chat_template(conversations, **kwargs) + + def _load_hf_runtime() -> _HFRuntime: _ensure_hf_torch_runtime() try: from qwen_vl_utils import process_vision_info from transformers import AutoModelForImageTextToText, AutoProcessor except ImportError as exc: # pragma: no cover - optional extra path - raise ConfigurationError( - f"Hugging Face OCR requires the `hf` runtime. {_HF_EXTRA_INSTALL_HINT}" - ) from exc + message = f"Hugging Face OCR requires the `hf` runtime. {_HF_EXTRA_INSTALL_HINT}" + raise _configuration_error(message) from exc return _HFRuntime( processor_cls=AutoProcessor, @@ -113,9 +182,8 @@ def _load_hf_causal_runtime() -> _HFRuntime: from qwen_vl_utils import process_vision_info from transformers import AutoModelForCausalLM, AutoProcessor except ImportError as exc: # pragma: no cover - optional extra path - raise ConfigurationError( - f"Hugging Face OCR requires the `hf` runtime. {_HF_EXTRA_INSTALL_HINT}" - ) from exc + message = f"Hugging Face OCR requires the `hf` runtime. {_HF_EXTRA_INSTALL_HINT}" + raise _configuration_error(message) from exc return _HFRuntime( processor_cls=AutoProcessor, @@ -129,9 +197,8 @@ def _load_hf_auto_model_runtime() -> _HFRuntime: try: from transformers import AutoModel, AutoTokenizer except ImportError as exc: # pragma: no cover - optional extra path - raise ConfigurationError( - f"Hugging Face OCR requires the `hf` runtime. {_HF_EXTRA_INSTALL_HINT}" - ) from exc + message = f"Hugging Face OCR requires the `hf` runtime. {_HF_EXTRA_INSTALL_HINT}" + raise _configuration_error(message) from exc return _HFRuntime( processor_cls=AutoTokenizer, @@ -145,9 +212,8 @@ def _load_hf_auto_processor_model_runtime() -> _HFRuntime: try: from transformers import AutoModel, AutoProcessor except ImportError as exc: # pragma: no cover - optional extra path - raise ConfigurationError( - f"Hugging Face OCR requires the `hf` runtime. {_HF_EXTRA_INSTALL_HINT}" - ) from exc + message = f"Hugging Face OCR requires the `hf` runtime. {_HF_EXTRA_INSTALL_HINT}" + raise _configuration_error(message) from exc return _HFRuntime( processor_cls=AutoProcessor, @@ -156,14 +222,15 @@ def _load_hf_auto_processor_model_runtime() -> _HFRuntime: ) -def _ensure_deepseek_ocr_2_cuda_runtime() -> Any: +def _ensure_deepseek_ocr_2_cuda_runtime() -> _TorchModuleLike: _ensure_hf_torch_runtime() - torch = import_module("torch") + torch = _load_torch_module() if not torch.cuda.is_available(): - raise ConfigurationError( + message = ( "DeepSeek-OCR-2 HF backend requires a CUDA-capable PyTorch runtime because " "the upstream `infer(...)` implementation moves inputs to CUDA." ) + raise _configuration_error(message) return torch @@ -220,9 +287,8 @@ def _prepare_dots_ocr_model_dir(model_id: str) -> str: try: from huggingface_hub import snapshot_download except ImportError as exc: # pragma: no cover - transitively provided by transformers - raise ConfigurationError( - f"Hugging Face OCR requires the `hf` runtime. {_HF_EXTRA_INSTALL_HINT}" - ) from exc + message = f"Hugging Face OCR requires the `hf` runtime. {_HF_EXTRA_INSTALL_HINT}" + raise _configuration_error(message) from exc model_dir = ( Path.home() @@ -237,7 +303,7 @@ def _prepare_dots_ocr_model_dir(model_id: str) -> str: return str(model_dir) -def _patch_dots_ocr_prepare_inputs_for_generation(model: Any) -> None: +def _patch_dots_ocr_prepare_inputs_for_generation(model: object) -> None: prepare_inputs_for_generation = getattr(model, "prepare_inputs_for_generation", None) if not callable(prepare_inputs_for_generation): return @@ -260,7 +326,7 @@ def _patch_dots_ocr_prepare_inputs_for_generation(model: Any) -> None: return def _patched_prepare_inputs_for_generation( - self: Any, + _self: object, input_ids: object, past_key_values: object = None, inputs_embeds: object = None, @@ -269,7 +335,7 @@ def _patched_prepare_inputs_for_generation( cache_position: object = None, num_logits_to_keep: object = None, **kwargs: object, - ) -> Any: + ) -> object: model_inputs = base_prepare_inputs_for_generation( input_ids, past_key_values=past_key_values, @@ -284,21 +350,22 @@ def _patched_prepare_inputs_for_generation( if cache_position is not None: try: first_cache_position = int(cast("Any", cache_position)[0]) - except Exception: + except (IndexError, TypeError, ValueError): first_cache_position = None if first_cache_position in (None, 0): model_inputs["pixel_values"] = pixel_values return model_inputs - model.prepare_inputs_for_generation = MethodType(_patched_prepare_inputs_for_generation, model) - model._churro_dots_prepare_inputs_patched = True + model_any = cast("Any", model) + model_any.prepare_inputs_for_generation = MethodType(_patched_prepare_inputs_for_generation, model) + model_any._churro_dots_prepare_inputs_patched = True def _default_dots_ocr_1_5_model_kwargs() -> dict[str, object]: model_kwargs: dict[str, object] = {"dtype": "auto"} try: - torch = import_module("torch") + torch = _load_torch_module() except ImportError: # pragma: no cover - torch is installed separately for local HF use return model_kwargs @@ -324,7 +391,7 @@ def _default_chandra_ocr_2_model_kwargs() -> dict[str, object]: "dtype": "auto", } try: - torch = import_module("torch") + torch = _load_torch_module() except ImportError: # pragma: no cover - torch is installed separately for local HF use return model_kwargs @@ -363,18 +430,19 @@ def _deepseek_ocr_2_prompt_from_conversation(conversation: OCRConversation) -> s has_image = False for message in conversation: if message.get("role") == "system": - content_items = cast("list[dict[str, object]]", message["content"]) + content_items = cast("list[OCRConversationContentItem]", message["content"]) system_text = "\n".join( cast("str", item["text"]).strip() for item in content_items if item.get("type") == "text" and isinstance(item.get("text"), str) ).strip() if system_text: - raise ConfigurationError("DeepSeek-OCR-2 does not support system prompts in the HF backend.") + message = "DeepSeek-OCR-2 does not support system prompts in the HF backend." + raise _configuration_error(message) continue if message.get("role") != "user": continue - content_items = cast("list[dict[str, object]]", message["content"]) + content_items = cast("list[OCRConversationContentItem]", message["content"]) for item in content_items: if item.get("type") == "image": has_image = True @@ -385,26 +453,30 @@ def _deepseek_ocr_2_prompt_from_conversation(conversation: OCRConversation) -> s prompt_lines.append(text) prompt_text = "\n".join(prompt_lines).strip() if not prompt_text: - raise ConfigurationError("DeepSeek-OCR-2 requires a non-empty OCR prompt.") + message = "DeepSeek-OCR-2 requires a non-empty OCR prompt." + raise _configuration_error(message) if has_image: return f"\n{prompt_text}" return prompt_text -def _move_batch_to_model(batch: Any, model: Any) -> Any: +def _move_batch_to_model(batch: dict[str, object], model: object) -> dict[str, object]: model_device = getattr(model, "device", None) if hasattr(batch, "to") and model_device is not None: - batch = batch.to(model_device) + batch = cast("dict[str, object]", cast("Any", batch).to(model_device)) model_dtype = getattr(model, "dtype", None) if model_dtype is not None: - batch_mapping = cast(dict[str, object], batch) - for key, value in batch_mapping.items(): + for key, value in batch.items(): if hasattr(value, "dtype") and getattr(value.dtype, "is_floating_point", False): - batch_mapping[key] = cast(Any, value).to(dtype=model_dtype) + batch[key] = cast("Any", value).to(dtype=model_dtype) return batch -def _decode_completion_texts(processor: Any, batch: Any, generated_ids: Any) -> list[str]: +def _decode_completion_texts( + processor: object, + batch: Mapping[str, object], + generated_ids: object, +) -> list[str]: return _decode_completion_texts_with_options( processor, batch, @@ -413,35 +485,34 @@ def _decode_completion_texts(processor: Any, batch: Any, generated_ids: Any) -> ) -def _completion_ids_from_generated_ids(batch: Any, generated_ids: Any) -> Any: - batch_mapping = cast(dict[str, object], batch) - attention_mask = batch_mapping.get("attention_mask") +def _completion_ids_from_generated_ids(batch: Mapping[str, object], generated_ids: object) -> object: + attention_mask = batch.get("attention_mask") if attention_mask is not None and hasattr(attention_mask, "sum"): - prompt_lengths = cast(Any, attention_mask).sum(dim=1).tolist() + prompt_lengths = cast("Any", attention_mask).sum(dim=1).tolist() return [ output_ids[int(prompt_length) :] - for prompt_length, output_ids in zip(prompt_lengths, generated_ids, strict=True) + for prompt_length, output_ids in zip(prompt_lengths, cast("Any", generated_ids), strict=True) ] - prompt_length = cast(Any, batch_mapping["input_ids"]).shape[1] - return generated_ids[:, prompt_length:] + prompt_length = cast("Any", batch["input_ids"]).shape[1] + return cast("Any", generated_ids)[:, prompt_length:] def _decode_completion_texts_with_options( - processor: Any, - batch: Any, - generated_ids: Any, + processor: object, + batch: Mapping[str, object], + generated_ids: object, *, skip_special_tokens: bool, ) -> list[str]: completion_ids = _completion_ids_from_generated_ids(batch, generated_ids) - return processor.batch_decode( + return cast("_HFProcessorDecoder", processor).batch_decode( completion_ids, skip_special_tokens=skip_special_tokens, clean_up_tokenization_spaces=False, ) -def _resolve_model_max_length(model: Any) -> int | None: +def _resolve_model_max_length(model: object) -> int | None: config = getattr(model, "config", None) max_length = getattr(config, "max_position_embeddings", None) if isinstance(max_length, int): @@ -480,7 +551,7 @@ def _resolve_model_max_length(model: Any) -> int | None: ) -def _paddleocr_vl_processor_kwargs(*, processor: Any, padding: bool) -> dict[str, object]: +def _paddleocr_vl_processor_kwargs(*, processor: object, padding: bool) -> dict[str, object]: processor_kwargs: dict[str, object] = { "text_kwargs": { "padding": padding, @@ -586,9 +657,10 @@ def _ocr_sync(self, page: DocumentPage) -> OCRResult: normalized_video_inputs = normalize_media_inputs(video_inputs) if normalized_video_inputs is not None: batch_kwargs["videos"] = normalized_video_inputs - batch = processor(**batch_kwargs) + batch = _call_processor(processor, **batch_kwargs) batch = _move_batch_to_model(batch, model) - generated_ids = model.generate( + generated_ids = _generate_with_model( + model, **self._generation_inputs(batch), **self.generation_kwargs, ) @@ -645,9 +717,10 @@ def _ocr_batch_sync(self, pages: list[DocumentPage]) -> list[OCRResult]: } if has_videos: batch_kwargs["videos"] = video_batch - batch = processor(**batch_kwargs) + batch = _call_processor(processor, **batch_kwargs) batch = _move_batch_to_model(batch, model) - generated_ids = model.generate( + generated_ids = _generate_with_model( + model, **self._generation_inputs(batch), **self.generation_kwargs, ) @@ -665,7 +738,7 @@ def _ocr_batch_sync(self, pages: list[DocumentPage]) -> list[OCRResult]: def _load_runtime(self) -> _HFRuntime: return _load_hf_runtime() - def _generation_inputs(self, batch: Any) -> dict[str, object]: + def _generation_inputs(self, batch: object) -> dict[str, object]: return dict(cast("dict[str, object]", batch)) def _resolve_model_source(self) -> str: @@ -695,7 +768,7 @@ def _build_vision_inputs( ) return image_inputs, video_inputs - def _get_processor(self, runtime: _HFRuntime) -> Any: + def _get_processor(self, runtime: _HFRuntime) -> object: if self._processor is None: with self._init_lock: if self._processor is None: @@ -706,7 +779,7 @@ def _get_processor(self, runtime: _HFRuntime) -> Any: ) return self._processor - def _get_model(self, runtime: _HFRuntime) -> Any: + def _get_model(self, runtime: _HFRuntime) -> object: if self._model is None: with self._init_lock: if self._model is None: @@ -745,14 +818,14 @@ class ChandraOCR2OCRBackend(HuggingFaceVisionOCRBackend): template: OCRPromptTemplateLike = CHANDRA_OCR_2_OCR_TEMPLATE model_name: str | None = "chandra-ocr-2" - def _get_processor(self, runtime: _HFRuntime) -> Any: + def _get_processor(self, runtime: _HFRuntime) -> object: processor = super()._get_processor(runtime) tokenizer = getattr(processor, "tokenizer", None) if tokenizer is not None and getattr(tokenizer, "padding_side", None) != "left": tokenizer.padding_side = "left" return processor - def _get_model(self, runtime: _HFRuntime) -> Any: + def _get_model(self, runtime: _HFRuntime) -> object: if self._model is None: with self._init_lock: if self._model is None: @@ -770,11 +843,17 @@ def _get_model(self, runtime: _HFRuntime) -> Any: eval_method() return self._model - def _build_chandra_batch(self, processor: Any, conversations: list[OCRConversation]) -> Any: + def _build_chandra_batch( + self, + processor: object, + conversations: list[OCRConversation], + ) -> dict[str, object]: processor_apply = getattr(processor, "apply_chat_template", None) if not callable(processor_apply): - raise ConfigurationError("Chandra OCR 2 requires `processor.apply_chat_template(...)` support.") - return processor_apply( + message = "Chandra OCR 2 requires `processor.apply_chat_template(...)` support." + raise _configuration_error(message) + return _apply_chat_template( + processor, conversations, add_generation_prompt=True, tokenize=True, @@ -783,7 +862,7 @@ def _build_chandra_batch(self, processor: Any, conversations: list[OCRConversati padding=True, ) - def _resolve_chandra_generation_kwargs(self, processor: Any, model: Any) -> dict[str, object]: + def _resolve_chandra_generation_kwargs(self, processor: object, model: object) -> dict[str, object]: generation_kwargs = dict(self.generation_kwargs) eos_token_ids: list[int] = [] eos_token_id = getattr(getattr(model, "generation_config", None), "eos_token_id", None) @@ -825,7 +904,8 @@ def _ocr_sync(self, page: DocumentPage) -> OCRResult: ) batch = self._build_chandra_batch(processor, [conversation]) batch = _move_batch_to_model(batch, model) - generated_ids = model.generate( + generated_ids = _generate_with_model( + model, **batch, **self._resolve_chandra_generation_kwargs(processor, model), ) @@ -867,7 +947,8 @@ def _ocr_batch_sync(self, pages: list[DocumentPage]) -> list[OCRResult]: batch = self._build_chandra_batch(processor, conversations) batch = _move_batch_to_model(batch, model) - generated_ids = model.generate( + generated_ids = _generate_with_model( + model, **batch, **self._resolve_chandra_generation_kwargs(processor, model), ) @@ -909,7 +990,7 @@ class DeepSeekOCR2OCRBackend(HuggingFaceVisionOCRBackend): def _load_runtime(self) -> _HFRuntime: return _load_hf_auto_model_runtime() - def _get_model(self, runtime: _HFRuntime) -> Any: + def _get_model(self, runtime: _HFRuntime) -> object: torch = _ensure_deepseek_ocr_2_cuda_runtime() if self._model is None: with self._init_lock: @@ -932,15 +1013,15 @@ def _get_model(self, runtime: _HFRuntime) -> Any: and "torch_dtype" not in model_kwargs and "dtype" not in model_kwargs ): - model = to_method(torch.bfloat16) + model = to_method(cast("Any", torch).bfloat16) self._model = model return self._model def _infer_deepseek_page( self, *, - tokenizer: Any, - model: Any, + tokenizer: object, + model: object, page: DocumentPage, batch_size: int, ) -> OCRResult: @@ -954,7 +1035,8 @@ def _infer_deepseek_page( infer_method = getattr(model, "infer", None) if not callable(infer_method): - raise ConfigurationError("DeepSeek-OCR-2 requires a model object with `infer(...)` support.") + message = "DeepSeek-OCR-2 requires a model object with `infer(...)` support." + raise _configuration_error(message) with TemporaryDirectory(prefix="churro-deepseek-ocr-2-") as output_dir: image_path = Path(output_dir) / "page.png" @@ -971,7 +1053,8 @@ def _infer_deepseek_page( eval_mode=True, ) if not isinstance(text, str): - raise ProviderError("DeepSeek-OCR-2 returned no OCR text.") + message = "DeepSeek-OCR-2 returned no OCR text." + raise _provider_error(message) return build_ocr_result( text, provider_name=self.provider_name, @@ -1035,7 +1118,7 @@ class DotsOCR15OCRBackend(HuggingFaceVisionOCRBackend): def _load_runtime(self) -> _HFRuntime: return _load_hf_causal_runtime() - def _generation_inputs(self, batch: Any) -> dict[str, object]: + def _generation_inputs(self, batch: object) -> dict[str, object]: generation_inputs = super()._generation_inputs(batch) generation_inputs.pop("mm_token_type_ids", None) return generation_inputs @@ -1043,7 +1126,7 @@ def _generation_inputs(self, batch: Any) -> dict[str, object]: def _resolve_model_source(self) -> str: return _prepare_dots_ocr_model_dir(self.model_id) - def _get_model(self, runtime: _HFRuntime) -> Any: + def _get_model(self, runtime: _HFRuntime) -> object: if self._model is None: with self._init_lock: if self._model is None: @@ -1113,7 +1196,7 @@ def __post_init__(self) -> None: def _load_runtime(self) -> _HFRuntime: return _load_hf_runtime() - def _get_model(self, runtime: _HFRuntime) -> Any: + def _get_model(self, runtime: _HFRuntime) -> object: if self._model is None: with self._init_lock: if self._model is None: @@ -1144,10 +1227,12 @@ def _template_for_step(self, step_key: str) -> OCRPromptTemplateLike: def _resolve_rendered_prompt(self, rendered: object) -> str: if isinstance(rendered, tuple): if not rendered: - raise ProviderError("MinerU2.5 returned an empty chat template render.") + message = "MinerU2.5 returned an empty chat template render." + raise _provider_error(message) rendered = rendered[0] if not isinstance(rendered, str): - raise ProviderError("MinerU2.5 chat template did not render text.") + message = "MinerU2.5 chat template did not render text." + raise _provider_error(message) return rendered def _resolve_step_sampling(self, step_key: str) -> MinerU25SamplingParams: @@ -1163,7 +1248,7 @@ def _resolve_step_sampling(self, step_key: str) -> MinerU25SamplingParams: changes[field_name] = step_value return replace_sampling_param(sampling, **changes) if changes else sampling - def _resolve_generation_kwargs(self, *, step_key: str, model: Any) -> dict[str, object]: + def _resolve_generation_kwargs(self, *, step_key: str, model: object) -> dict[str, object]: sampling = self._resolve_step_sampling(step_key) do_sample = ((sampling.temperature or 0.0) > 0.0) and ((sampling.top_k or 1) > 1) @@ -1204,8 +1289,8 @@ def _infer_step( self, *, runtime: _HFRuntime, - processor: Any, - model: Any, + processor: object, + model: object, image: Image.Image, step_key: str, batch_size: int, @@ -1232,9 +1317,10 @@ def _infer_step( normalized_video_inputs = normalize_media_inputs(video_inputs) if normalized_video_inputs is not None: batch_kwargs["videos"] = normalized_video_inputs - batch = processor(**batch_kwargs) + batch = _call_processor(processor, **batch_kwargs) batch = _move_batch_to_model(batch, model) - generated_ids = model.generate( + generated_ids = _generate_with_model( + model, **self._generation_inputs(batch), **self._resolve_generation_kwargs(step_key=step_key, model=model), ) @@ -1337,14 +1423,14 @@ class PaddleOCRVL15OCRBackend(HuggingFaceVisionOCRBackend): default_factory=lambda: {"max_new_tokens": 4_096, "do_sample": False} ) - def _get_processor(self, runtime: _HFRuntime) -> Any: + def _get_processor(self, runtime: _HFRuntime) -> object: processor = super()._get_processor(runtime) tokenizer = getattr(processor, "tokenizer", None) if tokenizer is not None and getattr(tokenizer, "padding_side", None) != "left": tokenizer.padding_side = "left" return processor - def _get_model(self, runtime: _HFRuntime) -> Any: + def _get_model(self, runtime: _HFRuntime) -> object: model = super()._get_model(runtime) eval_method = getattr(model, "eval", None) if callable(eval_method): @@ -1353,17 +1439,17 @@ def _get_model(self, runtime: _HFRuntime) -> Any: def _build_paddleocr_vl_batch( self, - processor: Any, + processor: object, conversations: OCRConversation | list[OCRConversation], *, padding: bool, - ) -> Any: + ) -> dict[str, object]: processor_apply = getattr(processor, "apply_chat_template", None) if not callable(processor_apply): - raise ConfigurationError( - "PaddleOCR-VL-1.5 requires `processor.apply_chat_template(...)` support." - ) - return processor_apply( + message = "PaddleOCR-VL-1.5 requires `processor.apply_chat_template(...)` support." + raise _configuration_error(message) + return _apply_chat_template( + processor, conversations, add_generation_prompt=True, tokenize=True, @@ -1397,7 +1483,7 @@ def _ocr_sync(self, page: DocumentPage) -> OCRResult: ) batch = self._build_paddleocr_vl_batch(processor, conversation, padding=False) batch = _move_batch_to_model(batch, model) - generated_ids = model.generate(**batch, **self.generation_kwargs) + generated_ids = _generate_with_model(model, **batch, **self.generation_kwargs) text = _decode_completion_texts(processor, batch, generated_ids)[0] return build_ocr_result( text, @@ -1436,7 +1522,7 @@ def _ocr_batch_sync(self, pages: list[DocumentPage]) -> list[OCRResult]: batch = self._build_paddleocr_vl_batch(processor, conversations, padding=True) batch = _move_batch_to_model(batch, model) - generated_ids = model.generate(**batch, **self.generation_kwargs) + generated_ids = _generate_with_model(model, **batch, **self.generation_kwargs) texts = _decode_completion_texts(processor, batch, generated_ids) return [ build_ocr_result( @@ -1458,14 +1544,14 @@ class LFM25VLOCRBackend(HuggingFaceVisionOCRBackend): model_name: str | None = "LFM2.5-VL-1.6B" _has_tied_lm_head: bool = field(default=False, init=False, repr=False) - def _get_processor(self, runtime: _HFRuntime) -> Any: + def _get_processor(self, runtime: _HFRuntime) -> object: processor = super()._get_processor(runtime) tokenizer = getattr(processor, "tokenizer", None) if tokenizer is not None and getattr(tokenizer, "padding_side", None) != "left": tokenizer.padding_side = "left" return processor - def _get_model(self, runtime: _HFRuntime) -> Any: + def _get_model(self, runtime: _HFRuntime) -> object: model = super()._get_model(runtime) if self._has_tied_lm_head: return model @@ -1487,17 +1573,17 @@ def _get_model(self, runtime: _HFRuntime) -> Any: def _build_lfm_batch( self, - processor: Any, + processor: object, conversations: OCRConversation | list[OCRConversation], *, padding: bool, - ) -> Any: + ) -> dict[str, object]: processor_apply = getattr(processor, "apply_chat_template", None) if not callable(processor_apply): - raise ConfigurationError( - "Liquid LFM2.5-VL requires `processor.apply_chat_template(...)` support." - ) - return processor_apply( + message = "Liquid LFM2.5-VL requires `processor.apply_chat_template(...)` support." + raise _configuration_error(message) + return _apply_chat_template( + processor, conversations, add_generation_prompt=True, tokenize=True, @@ -1528,7 +1614,7 @@ def _ocr_sync(self, page: DocumentPage) -> OCRResult: ) batch = self._build_lfm_batch(processor, conversation, padding=False) batch = _move_batch_to_model(batch, model) - generated_ids = model.generate(**batch, **self.generation_kwargs) + generated_ids = _generate_with_model(model, **batch, **self.generation_kwargs) text = _decode_completion_texts(processor, batch, generated_ids)[0] return build_ocr_result( text, @@ -1567,7 +1653,7 @@ def _ocr_batch_sync(self, pages: list[DocumentPage]) -> list[OCRResult]: batch = self._build_lfm_batch(processor, conversations, padding=True) batch = _move_batch_to_model(batch, model) - generated_ids = model.generate(**batch, **self.generation_kwargs) + generated_ids = _generate_with_model(model, **batch, **self.generation_kwargs) texts = _decode_completion_texts(processor, batch, generated_ids) return [ build_ocr_result( diff --git a/src/churro_ocr/providers/ocr.py b/src/churro_ocr/providers/ocr.py index 8c3e703..217233d 100644 --- a/src/churro_ocr/providers/ocr.py +++ b/src/churro_ocr/providers/ocr.py @@ -7,9 +7,7 @@ from dataclasses import dataclass, field from io import BytesIO from threading import Lock -from typing import Any - -from PIL import Image +from typing import TYPE_CHECKING, Protocol, cast from churro_ocr._internal.image import ensure_rgb, image_to_base64 from churro_ocr._internal.install import install_command_hint @@ -51,9 +49,62 @@ build_ocr_conversation, ) +if TYPE_CHECKING: + from collections.abc import Sequence + + from PIL import Image + + +class _AzureAnalyzeResultLike(Protocol): + content: object + + +class _AzurePollerLike(Protocol): + async def result(self) -> _AzureAnalyzeResultLike: ... + + +class _AzureDocumentIntelligenceClientLike(Protocol): + async def begin_analyze_document( + self, + *, + model_id: str, + body: BytesIO, + content_type: str, + ) -> _AzurePollerLike: ... + + +class _MistralOCRPageLike(Protocol): + markdown: str + + +class _MistralOCRResponseLike(Protocol): + pages: Sequence[_MistralOCRPageLike] | None + + +class _MistralOCRNamespaceLike(Protocol): + async def process_async( + self, + *, + model: str, + document: dict[str, str], + ) -> _MistralOCRResponseLike: ... + + +class _MistralClientLike(Protocol): + ocr: _MistralOCRNamespaceLike + + _MISTRAL_REQUEST_TIMEOUT_SECONDS = 60.0 +def _configuration_error(message: str) -> ConfigurationError: + return ConfigurationError(message) + + +def _provider_error(message: str) -> ProviderError: + return ProviderError(message) + + def _with_default_ocr_completion_kwargs(config: LiteLLMTransportConfig) -> LiteLLMTransportConfig: completion_kwargs: dict[str, object] = {"max_tokens": DEFAULT_OCR_MAX_TOKENS} completion_kwargs.update(config.completion_kwargs) @@ -227,11 +278,11 @@ class MinerU25OpenAICompatibleOCRBackend(OpenAICompatibleOCRBackend): """Two-step MinerU2.5 OCR backend for OpenAI-compatible servers such as vLLM.""" __slots__ = ( - "layout_template", - "table_template", + "_helper", "formula_template", "image_analysis_template", - "_helper", + "layout_template", + "table_template", ) layout_template: OCRPromptTemplateLike @@ -364,10 +415,7 @@ async def ocr(self, page: DocumentPage) -> OCRResult: async def ocr_batch(self, pages: list[DocumentPage]) -> list[OCRResult]: """Run the MinerU2.5 two-step OCR pipeline for multiple pages.""" - results: list[OCRResult] = [] - for page in pages: - results.append(await self.ocr(page)) - return results + return [await self.ocr(page) for page in pages] @dataclass(slots=True) @@ -388,12 +436,12 @@ class AzureDocumentIntelligenceOCRBackend(OCRBackend): model_name: str | None = None image_preprocessor: ImagePreprocessor = default_ocr_image_preprocessor text_postprocessor: TextPostprocessor = identity_text_postprocessor - _client: Any | None = field(default=None, init=False, repr=False) + _client: _AzureDocumentIntelligenceClientLike | None = field(default=None, init=False, repr=False) _client_lock: asyncio.Lock = field(default_factory=asyncio.Lock, init=False, repr=False) _has_logged_prompt: bool = field(default=False, init=False, repr=False) _prompt_log_lock: Lock = field(default_factory=Lock, init=False, repr=False) - async def _get_client(self) -> Any: + async def _get_client(self) -> _AzureDocumentIntelligenceClientLike: client = self._client if client is not None: return client @@ -406,13 +454,15 @@ async def _get_client(self) -> Any: from azure.ai.documentintelligence.aio import DocumentIntelligenceClient from azure.core.credentials import AzureKeyCredential except ImportError as exc: # pragma: no cover - optional extra path - raise ConfigurationError( - f"Azure OCR requires the `azure` runtime. {install_command_hint('azure')}" - ) from exc - - client = DocumentIntelligenceClient( - endpoint=self.endpoint, - credential=AzureKeyCredential(self.api_key), + message = f"Azure OCR requires the `azure` runtime. {install_command_hint('azure')}" + raise _configuration_error(message) from exc + + client = cast( + "_AzureDocumentIntelligenceClientLike", + DocumentIntelligenceClient( + endpoint=self.endpoint, + credential=AzureKeyCredential(self.api_key), + ), ) self._client = client return client @@ -444,7 +494,7 @@ async def ocr(self, page: DocumentPage) -> OCRResult: ) client = await self._get_client() - async def _analyze_document() -> Any: + async def _analyze_document() -> _AzureAnalyzeResultLike: poller = await client.begin_analyze_document( model_id=self.model_id, body=BytesIO(image_bytes), @@ -458,7 +508,8 @@ async def _analyze_document() -> Any: context=f"for model {self.model_id}", ) if not isinstance(result.content, str): - raise ProviderError("Azure Document Intelligence returned no OCR text.") + message = "Azure Document Intelligence returned no OCR text." + raise _provider_error(message) return build_ocr_result( result.content, provider_name="azure-document-intelligence", @@ -483,7 +534,7 @@ class MistralOCRBackend(OCRBackend): model_name: str | None = None image_preprocessor: ImagePreprocessor = default_ocr_image_preprocessor text_postprocessor: TextPostprocessor = identity_text_postprocessor - _client: Any | None = field(default=None, init=False, repr=False) + _client: _MistralClientLike | None = field(default=None, init=False, repr=False) _client_lock: asyncio.Lock = field(default_factory=asyncio.Lock, init=False, repr=False) _has_logged_prompt: bool = field(default=False, init=False, repr=False) _prompt_log_lock: Lock = field(default_factory=Lock, init=False, repr=False) @@ -492,7 +543,7 @@ def __post_init__(self) -> None: """Reject unsupported Mistral OCR aliases and unpinned model ids.""" validate_mistral_ocr_model(self.model, context="Mistral OCR backend") - async def _get_client(self) -> Any: + async def _get_client(self) -> _MistralClientLike: client = self._client if client is not None: return client @@ -504,11 +555,10 @@ async def _get_client(self) -> Any: try: from mistralai import Mistral except ImportError as exc: # pragma: no cover - optional extra path - raise ConfigurationError( - f"Mistral OCR requires the `mistral` runtime. {install_command_hint('mistral')}" - ) from exc + message = f"Mistral OCR requires the `mistral` runtime. {install_command_hint('mistral')}" + raise _configuration_error(message) from exc - client = Mistral(api_key=self.api_key) + client = cast("_MistralClientLike", Mistral(api_key=self.api_key)) self._client = client return client @@ -539,7 +589,7 @@ async def ocr(self, page: DocumentPage) -> OCRResult: client = await self._get_client() document = {"type": "image_url", "image_url": image_url} - async def _process_ocr() -> Any: + async def _process_ocr() -> _MistralOCRResponseLike: return await asyncio.wait_for( client.ocr.process_async( model=self.model, @@ -554,7 +604,8 @@ async def _process_ocr() -> Any: context=f"for model {self.model}", ) if not response.pages: - raise ProviderError("Mistral OCR returned no pages.") + message = "Mistral OCR returned no pages." + raise _provider_error(message) return build_ocr_result( response.pages[0].markdown, provider_name="mistral", diff --git a/src/churro_ocr/providers/page_detection.py b/src/churro_ocr/providers/page_detection.py index 3ff6362..ec8c000 100644 --- a/src/churro_ocr/providers/page_detection.py +++ b/src/churro_ocr/providers/page_detection.py @@ -6,7 +6,7 @@ import json from dataclasses import dataclass from io import BytesIO -from typing import TYPE_CHECKING, Any, Literal, cast +from typing import TYPE_CHECKING, Any, Literal, Protocol, cast from PIL import Image, ImageDraw, ImageOps @@ -28,6 +28,8 @@ if TYPE_CHECKING: from collections.abc import Awaitable, Callable, Sequence + from churro_ocr.types import BoundingBox, Polygon + _BORDER_FRACTION = 0.05 _PROCESSED_MAX_DIM = 2500 _PAGE_DETECTION_BOX_WIDTH = 10 @@ -46,13 +48,42 @@ LiteLLMTransportLike = LiteLLMTransportConfig | LiteLLMTransport | None +class _AzurePageLike(Protocol): + polygon: object + width: object + height: object + page_number: object + unit: object + angle: object + + +class _AzureAnalyzeResultLike(Protocol): + pages: Sequence[_AzurePageLike] | None + + +def _configuration_error(message: str) -> ConfigurationError: + return ConfigurationError(message) + + +def _provider_error(message: str) -> ProviderError: + return ProviderError(message) + + +def _type_error(message: str) -> TypeError: + return TypeError(message) + + +def _value_error(message: str) -> ValueError: + return ValueError(message) + + def _full_image_candidate(image: Image.Image) -> PageCandidate: return PageCandidate(bbox=(0.0, 0.0, float(image.width), float(image.height))) def _bbox_from_polygon( - polygon: tuple[tuple[float, float], ...], -) -> tuple[float, float, float, float]: + polygon: Polygon, +) -> BoundingBox: xs = [point[0] for point in polygon] ys = [point[1] for point in polygon] return (min(xs), min(ys), max(xs), max(ys)) @@ -60,7 +91,7 @@ def _bbox_from_polygon( def _normalize_polygon( coordinates: Sequence[float] | None, -) -> tuple[tuple[float, float], ...]: +) -> Polygon: if not coordinates or len(coordinates) < 6: return () pairs = [ @@ -74,7 +105,7 @@ def _normalize_polygon( def _clamp_normalized(value: float) -> int: clamped = max(_NORMALIZED_MIN_COORD, min(_NORMALIZED_MAX_COORD, value)) - rounded = int(round(clamped)) + rounded = round(clamped) return max(0, min(1000, rounded)) @@ -87,7 +118,7 @@ class _PageDetectionTransform: scale_x: float scale_y: float - def map_box_to_original(self, box: _PageBox) -> tuple[float, float, float, float]: + def map_box_to_original(self, box: _PageBox) -> BoundingBox: processed_width, processed_height = self.processed_size original_width, original_height = self.original_size border_width, border_height = self.border @@ -119,14 +150,16 @@ class _PageBox: @classmethod def from_json(cls, payload: dict[str, Any]) -> _PageBox: if "page_index" not in payload: - raise ValueError("Expected 'page_index' key in page-detection response.") + message = "Expected 'page_index' key in page-detection response." + raise _value_error(message) required_keys = {"left", "top", "right", "bottom"} if not required_keys.issubset(payload): missing = required_keys - set(payload) - raise ValueError( + message = ( f"Page-detection response must include keys {sorted(required_keys)}, " f"missing {sorted(missing)}." ) + raise _value_error(message) return cls( page_index=int(payload["page_index"]), ymin=_clamp_normalized(float(payload["top"])), @@ -136,10 +169,10 @@ def from_json(cls, payload: dict[str, Any]) -> _PageBox: ) def denormalize(self, width: int, height: int) -> tuple[int, int, int, int]: - top = max(0, min(height, int(round(self.ymin * height / 1000)))) - left = max(0, min(width, int(round(self.xmin * width / 1000)))) - bottom = max(0, min(height, int(round(self.ymax * height / 1000)))) - right = max(0, min(width, int(round(self.xmax * width / 1000)))) + top = max(0, min(height, round(self.ymin * height / 1000))) + left = max(0, min(width, round(self.xmin * width / 1000))) + bottom = max(0, min(height, round(self.ymax * height / 1000))) + right = max(0, min(width, round(self.xmax * width / 1000))) return left, top, right, bottom @@ -168,8 +201,8 @@ def _add_white_border( ) -> tuple[Image.Image, int, int]: if fraction <= 0: return image, 0, 0 - border_width = max(1, int(round(image.width * fraction))) - border_height = max(1, int(round(image.height * fraction))) + border_width = max(1, round(image.width * fraction)) + border_height = max(1, round(image.height * fraction)) expanded = ImageOps.expand( image, border=(border_width, border_height, border_width, border_height), @@ -184,7 +217,7 @@ def _resize_image_to_fit(image: Image.Image, *, max_dim: int = _PROCESSED_MAX_DI if longest_side <= max_dim: return image scale = max_dim / longest_side - return image.resize((max(1, int(round(width * scale))), max(1, int(round(height * scale))))) + return image.resize((max(1, round(width * scale)), max(1, round(height * scale)))) def _prepare_detection_image(image: Image.Image) -> tuple[Image.Image, _PageDetectionTransform]: @@ -219,23 +252,28 @@ def _parse_page_boxes_json(output: str) -> list[_PageBox]: try: payload = json.loads(response_text) except json.JSONDecodeError as exc: - raise ProviderError("LLM page detection returned invalid JSON.") from exc + message = "LLM page detection returned invalid JSON." + raise _provider_error(message) from exc if not isinstance(payload, dict): - raise ProviderError("LLM page detection response must be a JSON object.") + message = "LLM page detection response must be a JSON object." + raise _provider_error(message) pages = payload.get("pages") if not isinstance(pages, list): - raise ProviderError("LLM page detection response must include a `pages` list.") + message = "LLM page detection response must include a `pages` list." + raise _provider_error(message) boxes: list[_PageBox] = [] for page_index, page in enumerate(pages): if not isinstance(page, dict): - raise ProviderError(f"LLM page detection entry {page_index} must be an object.") + message = f"LLM page detection entry {page_index} must be an object." + raise _provider_error(message) try: boxes.append(_PageBox.from_json(cast("dict[str, Any]", page))) except (TypeError, ValueError) as exc: - raise ProviderError(f"LLM page detection entry {page_index} is invalid: {exc}") from exc + message = f"LLM page detection entry {page_index} is invalid: {exc}" + raise _provider_error(message) from exc return sorted(boxes, key=lambda box: box.page_index) @@ -262,17 +300,20 @@ def _parse_target_box_json( try: payload = json.loads(response_text) except json.JSONDecodeError as exc: - raise ProviderError(f"{error_context} returned invalid JSON.") from exc + message = f"{error_context} returned invalid JSON." + raise _provider_error(message) from exc if not isinstance(payload, dict): - raise ProviderError(f"{error_context} response must be a JSON object.") + message = f"{error_context} response must be a JSON object." + raise _provider_error(message) payload_dict = cast("dict[str, Any]", payload) if {"left", "top", "right", "bottom"}.issubset(payload_dict): try: return _build_target_box_from_payload(payload_dict, target_index=1) except (TypeError, ValueError) as exc: - raise ProviderError(f"{error_context} bbox is invalid: {exc}") from exc + message = f"{error_context} bbox is invalid: {exc}" + raise _provider_error(message) from exc raw_target = payload_dict.get(target_key) if raw_target is None: @@ -281,9 +322,11 @@ def _parse_target_box_json( try: return _build_target_box_from_payload(cast("dict[str, Any]", raw_target), target_index=1) except (TypeError, ValueError) as exc: - raise ProviderError(f"{error_context} bbox is invalid: {exc}") from exc + message = f"{error_context} bbox is invalid: {exc}" + raise _provider_error(message) from exc if raw_target is not None: - raise ProviderError(f"{error_context} response `{target_key}` must be an object or null.") + message = f"{error_context} response `{target_key}` must be an object or null." + raise _provider_error(message) if ( payload_dict.get(found_key) is False @@ -293,10 +336,11 @@ def _parse_target_box_json( ): return None - raise ProviderError( + message = ( f"{error_context} response must include a `{target_key}` object " f"or explicitly mark `{found_key}` false." ) + raise _provider_error(message) def _parse_text_block_box_json(output: str) -> _PageBox | None: @@ -314,24 +358,28 @@ def _parse_edge_review_decision( edge_name: str, ) -> _EdgeReviewDecision: if not isinstance(payload, dict): - raise ValueError(f"Review edge '{edge_name}' must be an object.") + message = f"Review edge '{edge_name}' must be an object." + raise _type_error(message) payload_dict = cast("dict[str, object]", payload) raw_action = payload_dict.get("action") if raw_action is None: raw_action = payload_dict.get("decision") if not isinstance(raw_action, str): - raise ValueError(f"Review edge '{edge_name}' must include string 'action'.") + message = f"Review edge '{edge_name}' must include string 'action'." + raise _type_error(message) action = raw_action.strip().lower() if action not in {"expand", "shrink", "no_change"}: - raise ValueError(f"Review edge '{edge_name}' action must be one of 'expand', 'shrink', 'no_change'.") + message = f"Review edge '{edge_name}' action must be one of 'expand', 'shrink', 'no_change'." + raise _value_error(message) action_literal = cast("EdgeDecisionAction", action) try: raw_amount = payload_dict.get("amount") - amount = 0 if raw_amount is None else int(round(float(cast("Any", raw_amount)))) + amount = 0 if raw_amount is None else round(float(cast("Any", raw_amount))) except (TypeError, ValueError) as exc: - raise ValueError(f"Review edge '{edge_name}' amount must be numeric.") from exc + message = f"Review edge '{edge_name}' amount must be numeric." + raise _value_error(message) from exc amount = max(0, min(1000, amount)) if action_literal == "no_change": amount = 0 @@ -344,19 +392,24 @@ def _parse_single_edge_review_decision_json( try: payload = json.loads(_strip_code_fence(output)) except json.JSONDecodeError as exc: - raise ValueError(f"Failed to decode edge-review response as JSON: {exc}") from exc + message = f"Failed to decode edge-review response as JSON: {exc}" + raise _value_error(message) from exc if not isinstance(payload, dict): - raise ValueError("Edge-review response must be a JSON object.") + message = "Edge-review response must be a JSON object." + raise _type_error(message) if "page_index" not in payload: - raise ValueError("Edge-review response must include 'page_index'.") + message = "Edge-review response must include 'page_index'." + raise _value_error(message) raw_edge = payload.get("edge") if not isinstance(raw_edge, str): - raise ValueError("Edge-review response must include string 'edge'.") + message = "Edge-review response must include string 'edge'." + raise _type_error(message) edge_name = raw_edge.strip().lower() if edge_name not in _EDGE_NAMES: - raise ValueError("Edge-review response 'edge' must be left/top/right/bottom.") + message = "Edge-review response 'edge' must be left/top/right/bottom." + raise _value_error(message) decision_payload = payload.get("decision") if not isinstance(decision_payload, dict): @@ -381,18 +434,22 @@ def _parse_text_block_edge_review_decision_json( try: payload = json.loads(_strip_code_fence(output)) except json.JSONDecodeError as exc: - raise ValueError(f"Failed to decode text-block edge-review response as JSON: {exc}") from exc + message = f"Failed to decode text-block edge-review response as JSON: {exc}" + raise _value_error(message) from exc if not isinstance(payload, dict): - raise ValueError("Text-block edge-review response must be a JSON object.") + message = "Text-block edge-review response must be a JSON object." + raise _type_error(message) payload_dict = cast("dict[str, object]", payload) raw_edge = payload_dict.get("edge") if not isinstance(raw_edge, str): - raise ValueError("Text-block edge-review response must include string 'edge'.") + message = "Text-block edge-review response must include string 'edge'." + raise _type_error(message) edge_name = raw_edge.strip().lower() if edge_name not in _EDGE_NAMES: - raise ValueError("Text-block edge-review response 'edge' must be left/top/right/bottom.") + message = "Text-block edge-review response 'edge' must be left/top/right/bottom." + raise _value_error(message) decision_payload = payload_dict.get("decision") if not isinstance(decision_payload, dict): @@ -419,8 +476,8 @@ def _boxes_equal(left_boxes: Sequence[_PageBox], right_boxes: Sequence[_PageBox] def _bbox_to_polygon( - bbox: tuple[float, float, float, float], -) -> tuple[tuple[float, float], ...]: + bbox: BoundingBox, +) -> Polygon: left, top, right, bottom = bbox return ((left, top), (right, top), (right, bottom), (left, bottom)) @@ -428,7 +485,7 @@ def _bbox_to_polygon( def _normalize_pixel_coord(value: int, size: int) -> int: if size <= 0: return 0 - return max(0, min(1000, int(round(value * 1000 / size)))) + return max(0, min(1000, round(value * 1000 / size))) def _build_box_review_preview( @@ -443,8 +500,8 @@ def _build_box_review_preview( box_width = max(1, right - left) box_height = max(1, bottom - top) - margin_x = max(outline_width * 2, int(round(box_width * margin_fraction))) - margin_y = max(outline_width * 2, int(round(box_height * margin_fraction))) + margin_x = max(outline_width * 2, round(box_width * margin_fraction)) + margin_y = max(outline_width * 2, round(box_height * margin_fraction)) crop_left = max(0, left - margin_x) crop_top = max(0, top - margin_y) @@ -474,10 +531,10 @@ def _build_edge_strip_review_preview( box_width = max(1, right - left) box_height = max(1, bottom - top) - band_half_x = max(outline_width * 3, int(round(box_width * 0.18))) - band_half_y = max(outline_width * 3, int(round(box_height * 0.18))) - orthogonal_pad_x = max(outline_width * 2, int(round(box_width * 0.06))) - orthogonal_pad_y = max(outline_width * 2, int(round(box_height * 0.06))) + band_half_x = max(outline_width * 3, round(box_width * 0.18)) + band_half_y = max(outline_width * 3, round(box_height * 0.18)) + orthogonal_pad_x = max(outline_width * 2, round(box_width * 0.06)) + orthogonal_pad_y = max(outline_width * 2, round(box_height * 0.06)) if edge_name == "left": x0 = max(0, left - band_half_x) @@ -500,10 +557,12 @@ def _build_edge_strip_review_preview( y0 = max(0, bottom - band_half_y) y1 = min(height, bottom + band_half_y) else: - raise ValueError(f"Unsupported edge '{edge_name}'. Expected left/top/right/bottom.") + message = f"Unsupported edge '{edge_name}'. Expected left/top/right/bottom." + raise _value_error(message) if x0 >= x1 or y0 >= y1: - raise ValueError(f"Invalid strip bounds for edge '{edge_name}'.") + message = f"Invalid strip bounds for edge '{edge_name}'." + raise _value_error(message) return image.crop((x0, y0, x1, y1)), (x0, y0, x1, y1) @@ -576,7 +635,8 @@ def _merge_instruction_prompts(*parts: str | None) -> str: """Merge one or more instruction strings into a single non-empty user prompt.""" merged_parts = [part.strip() for part in parts if isinstance(part, str) and part.strip()] if not merged_parts: - raise ValueError("Expected at least one non-empty instruction prompt.") + message = "Expected at least one non-empty instruction prompt." + raise _value_error(message) return "\n\n".join(merged_parts) @@ -641,7 +701,8 @@ async def _review_single_edge_from_strip( ) -> _EdgeReviewDecision: strip_axis_pixels = _strip_axis_size_pixels(strip_bounds, edge_name=edge_name) if strip_axis_pixels <= 0: - raise ValueError(f"Invalid strip axis size for edge '{edge_name}'.") + message = f"Invalid strip axis size for edge '{edge_name}'." + raise _value_error(message) prompt = build_boundary_review_prompt( edge_name=edge_name, @@ -709,7 +770,8 @@ async def _review_single_text_block_edge_from_strip( ) -> _EdgeReviewDecision: strip_axis_pixels = _strip_axis_size_pixels(strip_bounds, edge_name=edge_name) if strip_axis_pixels <= 0: - raise ValueError(f"Invalid strip axis size for edge '{edge_name}'.") + message = f"Invalid strip axis size for edge '{edge_name}'." + raise _value_error(message) prompt = build_text_block_boundary_review_prompt( edge_name=edge_name, @@ -1074,7 +1136,7 @@ async def locate_text_block_bbox_with_llm( model: str, transport: LiteLLMTransportLike = None, max_review_rounds: int = 0, -) -> tuple[float, float, float, float] | None: +) -> BoundingBox | None: """Locate the tight bbox of a specific rendered text block via a multimodal LLM. :param image: Source page image containing the rendered block. @@ -1090,11 +1152,13 @@ async def locate_text_block_bbox_with_llm( """ normalized_block_text = block_text.strip() if not normalized_block_text: - raise ValueError("block_text must not be blank.") + message = "block_text must not be blank." + raise _value_error(message) normalized_block_tag = block_tag.strip() if not normalized_block_tag: - raise ValueError("block_tag must not be blank.") + message = "block_tag must not be blank." + raise _value_error(message) processed_image, transform = _prepare_detection_image(image) llm_transport = transport if isinstance(transport, LiteLLMTransport) else LiteLLMTransport(transport) @@ -1152,7 +1216,7 @@ def locate_text_block_bbox_with_llm_sync( model: str, transport: LiteLLMTransportLike = None, max_review_rounds: int = 0, -) -> tuple[float, float, float, float] | None: +) -> BoundingBox | None: """Synchronously locate the tight bbox of a specific rendered text block via a multimodal LLM. :param image: Source page image containing the rendered block. @@ -1203,9 +1267,8 @@ async def detect(self, image: Image.Image) -> list[PageCandidate]: from azure.ai.documentintelligence.aio import DocumentIntelligenceClient from azure.core.credentials import AzureKeyCredential except ImportError as exc: # pragma: no cover - optional extra path - raise ConfigurationError( - f"Azure page detection requires the `azure` runtime. {install_command_hint('azure')}" - ) from exc + message = f"Azure page detection requires the `azure` runtime. {install_command_hint('azure')}" + raise _configuration_error(message) from exc buffer = BytesIO() image.convert("RGB").save(buffer, format="JPEG") @@ -1216,13 +1279,13 @@ async def detect(self, image: Image.Image) -> list[PageCandidate]: try: image_bytes = buffer.getvalue() - async def _analyze_document() -> Any: + async def _analyze_document() -> _AzureAnalyzeResultLike: poller = await client.begin_analyze_document( model_id=self.model_id, body=BytesIO(image_bytes), content_type="application/octet-stream", ) - return await poller.result() + return cast("_AzureAnalyzeResultLike", await poller.result()) result = await retry_api_call( _analyze_document, @@ -1251,7 +1314,9 @@ async def _analyze_document() -> Any: return candidates or [_full_image_candidate(image)] -def _normalize_azure_page_polygon(page: Any, *, image: Image.Image) -> tuple[tuple[float, float], ...]: +def _normalize_azure_page_polygon( + page: _AzurePageLike, *, image: Image.Image +) -> Polygon: raw_polygon = getattr(page, "polygon", None) polygon = _normalize_polygon(raw_polygon) if not polygon: @@ -1478,7 +1543,7 @@ def _convert_strip_delta_to_local_delta( if strip_delta_normalized <= 0 or strip_axis_pixels <= 0 or local_axis_pixels <= 0: return 0 delta_pixels = strip_delta_normalized * strip_axis_pixels / 1000 - local_delta = int(round(delta_pixels * 1000 / local_axis_pixels)) + local_delta = round(delta_pixels * 1000 / local_axis_pixels) return max(0, min(1000, local_delta)) diff --git a/src/churro_ocr/providers/specs.py b/src/churro_ocr/providers/specs.py index b60d439..5a8ffc7 100644 --- a/src/churro_ocr/providers/specs.py +++ b/src/churro_ocr/providers/specs.py @@ -78,6 +78,10 @@ ) +def _configuration_error(message: str) -> ConfigurationError: + return ConfigurationError(message) + + def validate_mistral_ocr_model( model: str | None, *, @@ -86,10 +90,12 @@ def validate_mistral_ocr_model( """Return a supported pinned Mistral OCR model id or raise a configuration error.""" supported_models = ", ".join(MISTRAL_OCR_MODEL_IDS) if model is None: - raise ConfigurationError(f"{context} requires `model` to be one of: {supported_models}.") + message = f"{context} requires `model` to be one of: {supported_models}." + raise _configuration_error(message) if model not in MISTRAL_OCR_MODEL_IDS: - raise ConfigurationError(f"{context} only supports `model` values {supported_models}; got {model!r}.") - return cast(MistralOCRModel, model) + message = f"{context} only supports `model` values {supported_models}; got {model!r}." + raise _configuration_error(message) + return cast("MistralOCRModel", model) def identity_text_postprocessor(text: str) -> str: @@ -704,7 +710,8 @@ def resolve_ocr_profile( try: return registry[profile] except KeyError as exc: - raise ValueError(f"Unknown OCR profile '{profile}'.") from exc + message = f"Unknown OCR profile '{profile}'." + raise ValueError(message) from exc if model_id is not None and model_id in registry: return registry[model_id] @@ -712,8 +719,20 @@ def resolve_ocr_profile( __all__ = [ - "AzureDocumentIntelligenceOptions", "DEFAULT_OCR_MAX_TOKENS", + "MISTRAL_OCR_MODEL_IDS", + "AzureDocumentIntelligenceOptions", + "HuggingFaceOptions", + "ImagePreprocessor", + "LiteLLMTransportConfig", + "MistralOCRModel", + "MistralOptions", + "OCRBackendSpec", + "OCRModelProfile", + "OCRProvider", + "OpenAICompatibleOptions", + "TextPostprocessor", + "VisionInputBuilder", "chandra_image_preprocessor", "chandra_ocr_2_profile", "chandra_text_postprocessor", @@ -722,28 +741,16 @@ def resolve_ocr_profile( "default_ocr_image_preprocessor", "default_ocr_profile", "default_ocr_text_postprocessor", - "HuggingFaceOptions", + "identity_text_postprocessor", "infinity_parser_7b_profile", "infinity_parser_7b_text_postprocessor", - "identity_text_postprocessor", - "lfm2_5_vl_text_postprocessor", "lfm2_5_vl_1_6b_profile", - "ImagePreprocessor", - "LiteLLMTransportConfig", - "MistralOCRModel", - "MISTRAL_OCR_MODEL_IDS", - "MistralOptions", + "lfm2_5_vl_text_postprocessor", "mineru2_5_2509_1_2b_profile", "olmocr_image_preprocessor", "olmocr_text_postprocessor", "paddleocr_vl_1_5_profile", "paddleocr_vl_text_postprocessor", - "OCRBackendSpec", - "OCRModelProfile", - "OCRProvider", - "OpenAICompatibleOptions", "resolve_ocr_profile", - "TextPostprocessor", "validate_mistral_ocr_model", - "VisionInputBuilder", ] diff --git a/tests/test_hf_ocr.py b/tests/test_hf_ocr.py index 0ed032c..e7c2303 100644 --- a/tests/test_hf_ocr.py +++ b/tests/test_hf_ocr.py @@ -806,7 +806,9 @@ def apply_chat_template( ) def __call__(self, **kwargs: object) -> object: - raise AssertionError("processor(...) should not be used for LFM2.5-VL") + del kwargs + message = "processor(...) should not be used for LFM2.5-VL" + raise AssertionError(message) def batch_decode( self, @@ -950,7 +952,9 @@ def apply_chat_template( ) def __call__(self, **kwargs: object) -> object: - raise AssertionError("processor(...) should not be used for LFM2.5-VL batches") + del kwargs + message = "processor(...) should not be used for LFM2.5-VL batches" + raise AssertionError(message) def batch_decode( self, @@ -1256,6 +1260,7 @@ def batch_decode( skip_special_tokens: bool, clean_up_tokenization_spaces: bool, ) -> list[str]: + del skip_special_tokens, clean_up_tokenization_spaces captured["generated_ids"] = generated_ids return ["dots transcription"] @@ -1392,18 +1397,18 @@ def apply_chat_template( user_content = cast("list[dict[str, object]]", conversation_messages[1]["content"]) prompt = cast("str", user_content[1]["text"]) return (f"",) - raise AssertionError("tokenized chat template should not be used for MinerU2.5") + message = "tokenized chat template should not be used for MinerU2.5" + raise AssertionError(message) def __call__(self, **kwargs: object) -> FakeBatch: captured.setdefault("processor_call_kwargs", []).append(kwargs) - batch = FakeBatch( + return FakeBatch( { "input_ids": object(), "attention_mask": object(), "pixel_values": object(), } ) - return batch class FakeProcessorCls: @staticmethod @@ -1687,7 +1692,9 @@ def apply_chat_template( ) def __call__(self, **kwargs: object) -> object: - raise AssertionError("processor(...) should not be used for PaddleOCR-VL") + del kwargs + message = "processor(...) should not be used for PaddleOCR-VL" + raise AssertionError(message) def batch_decode( self, @@ -1919,6 +1926,7 @@ def prepare_inputs_for_generation( num_logits_to_keep: object = None, **kwargs: object, ) -> dict[str, object]: + del past_key_values, inputs_embeds, num_logits_to_keep, kwargs if cast("Any", cache_position)[0] == 0: return {"pixel_values": pixel_values} return { @@ -2063,7 +2071,8 @@ def apply_chat_template( tokenize: bool, ) -> str: del add_generation_prompt, tokenize - image = cast(Image.Image, cast(list[dict[str, object]], conversation[0]["content"])[0]["image"]) + image_content = cast("list[dict[str, object]]", conversation[0]["content"]) + image = cast("Image.Image", image_content[0]["image"]) return f"" def __call__(self, **kwargs: object) -> FakeBatch: @@ -2252,15 +2261,15 @@ def test_hf_runtime_loaders_use_installed_modules( ) -> None: process_vision_info = object() qwen_module = ModuleType("qwen_vl_utils") - cast(Any, qwen_module).process_vision_info = process_vision_info + cast("Any", qwen_module).process_vision_info = process_vision_info processor_cls = object() image_text_model_cls = object() causal_model_cls = object() transformers_module = ModuleType("transformers") - cast(Any, transformers_module).AutoProcessor = processor_cls - cast(Any, transformers_module).AutoModelForImageTextToText = image_text_model_cls - cast(Any, transformers_module).AutoModelForCausalLM = causal_model_cls + cast("Any", transformers_module).AutoProcessor = processor_cls + cast("Any", transformers_module).AutoModelForImageTextToText = image_text_model_cls + cast("Any", transformers_module).AutoModelForCausalLM = causal_model_cls monkeypatch.setitem(sys.modules, "torch", ModuleType("torch")) monkeypatch.setitem(sys.modules, "qwen_vl_utils", qwen_module) @@ -2309,8 +2318,8 @@ def test_prepare_dots_ocr_model_dir_downloads_and_patches( patched_paths: list[Path] = [] huggingface_hub_module = ModuleType("huggingface_hub") - cast(Any, huggingface_hub_module).snapshot_download = lambda *, repo_id, local_dir: download_calls.append( - (repo_id, local_dir) + cast("Any", huggingface_hub_module).snapshot_download = lambda *, repo_id, local_dir: ( + download_calls.append((repo_id, local_dir)) ) monkeypatch.setitem(sys.modules, "huggingface_hub", huggingface_hub_module) monkeypatch.setattr(hf_module.Path, "home", lambda: tmp_path) @@ -2356,7 +2365,7 @@ def mem_get_info() -> tuple[int, int]: return free_bytes, 0 torch_module = ModuleType("torch") - cast(Any, torch_module).cuda = _FakeCuda + cast("Any", torch_module).cuda = _FakeCuda monkeypatch.setitem(sys.modules, "torch", torch_module) assert hf_module._default_dots_ocr_1_5_model_kwargs() == expected @@ -2372,10 +2381,11 @@ def is_available() -> bool: @staticmethod def mem_get_info() -> tuple[int, int]: - raise RuntimeError("cudaMemGetInfo failed") + message = "cudaMemGetInfo failed" + raise RuntimeError(message) torch_module = ModuleType("torch") - cast(Any, torch_module).cuda = _FakeCuda + cast("Any", torch_module).cuda = _FakeCuda monkeypatch.setitem(sys.modules, "torch", torch_module) assert hf_module._default_dots_ocr_1_5_model_kwargs() == {"dtype": "auto"} @@ -2426,8 +2436,8 @@ def apply_chat_template( tokenize: bool, ) -> str: chat_calls.append((add_generation_prompt, tokenize)) - user_content = cast(list[dict[str, object]], conversation[0]["content"]) - image = cast(Image.Image, user_content[0]["image"]) + user_content = cast("list[dict[str, object]]", conversation[0]["content"]) + image = cast("Image.Image", user_content[0]["image"]) return f"prompt:{image.width}" def __call__(self, **kwargs: object) -> FakeBatch: @@ -2497,8 +2507,8 @@ def from_pretrained(model_id: str, **kwargs: object) -> FakeModel: monkeypatch.setattr("churro_ocr._internal.prompt_logging.logger", FakeLogger()) def _vision_input_builder(conversation: list[dict[str, object]]) -> tuple[str, str]: - user_content = cast(list[dict[str, object]], conversation[0]["content"]) - image = cast(Image.Image, user_content[0]["image"]) + user_content = cast("list[dict[str, object]]", conversation[0]["content"]) + image = cast("Image.Image", user_content[0]["image"]) return f"image:{image.width}", f"video:{image.width}" backend = HuggingFaceVisionOCRBackend( @@ -2535,10 +2545,10 @@ def _vision_input_builder(conversation: list[dict[str, object]]) -> tuple[str, s "padding": True, } assert captured["device"] == "cuda:0" - fake_pixel_values = cast(Any, captured["pixel_values"]) + fake_pixel_values = cast("Any", captured["pixel_values"]) assert fake_pixel_values.to_calls == ["float16"] assert captured["sum_dim"] == 1 - generate_kwargs = cast(dict[str, object], captured["generate_kwargs"]) + generate_kwargs = cast("dict[str, object]", captured["generate_kwargs"]) assert generate_kwargs["temperature"] == 0.1 assert generate_kwargs["max_new_tokens"] == DEFAULT_OCR_MAX_TOKENS assert generate_kwargs["attention_mask"].__class__.__name__ == ("FakeAttentionMask") @@ -2587,7 +2597,7 @@ def from_pretrained(model_source: str, **kwargs: object) -> object: return object() transformers_module = ModuleType("transformers") - cast(Any, transformers_module).AutoConfig = FakeAutoConfig + cast("Any", transformers_module).AutoConfig = FakeAutoConfig monkeypatch.setitem(sys.modules, "transformers", transformers_module) monkeypatch.setattr( hf_module, diff --git a/tests/test_hf_ocr_integration.py b/tests/test_hf_ocr_integration.py index 865ec12..9de76b5 100644 --- a/tests/test_hf_ocr_integration.py +++ b/tests/test_hf_ocr_integration.py @@ -1,12 +1,16 @@ from __future__ import annotations import os +from typing import TYPE_CHECKING import pytest from churro_ocr.document import DocumentOCRPipeline from churro_ocr.providers import HuggingFaceOptions, OCRBackendSpec, build_ocr_backend +if TYPE_CHECKING: + from pathlib import Path + _LIVE_FLAG = "CHURRO_RUN_LIVE_HF_TESTS" _ALLOW_CPU_FLAG = "CHURRO_ALLOW_CPU_HF_TESTS" _MODEL_ENV = "CHURRO_HF_MODEL_ID" @@ -15,7 +19,10 @@ @pytest.mark.integration -def test_churro_3b_live_hf_ocr_on_minimal_pdf(minimal_pdf_path, test_artifact_dir_path) -> None: +def test_churro_3b_live_hf_ocr_on_minimal_pdf( + minimal_pdf_path: Path, + test_artifact_dir_path: Path, +) -> None: if os.getenv(_LIVE_FLAG) != "1": pytest.skip(f"Set {_LIVE_FLAG}=1 to run live Hugging Face OCR integration tests.") diff --git a/tests/test_page_detection_integration.py b/tests/test_page_detection_integration.py index 140883e..e9de370 100644 --- a/tests/test_page_detection_integration.py +++ b/tests/test_page_detection_integration.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import os from pathlib import Path @@ -92,7 +93,7 @@ def _save_detection_overlay( @pytest.mark.integration @pytest.mark.asyncio -async def test_llm_page_detector_live_vertex_gemini_31_pro(test_artifact_dir_path) -> None: +async def test_llm_page_detector_live_vertex_gemini_31_pro(test_artifact_dir_path: Path) -> None: if os.getenv(_LIVE_FLAG) != "1": pytest.skip(f"Set {_LIVE_FLAG}=1 to run live Vertex page-detection integration tests.") @@ -101,7 +102,7 @@ async def test_llm_page_detector_live_vertex_gemini_31_pro(test_artifact_dir_pat if missing: pytest.skip(f"Missing required Vertex env vars: {', '.join(missing)}") - test_artifact_dir_path.mkdir(parents=True, exist_ok=True) + await asyncio.to_thread(test_artifact_dir_path.mkdir, parents=True, exist_ok=True) image_path = test_artifact_dir_path / "vertex-page-detection.png" width, height = _write_synthetic_page_image(image_path) diff --git a/tests/test_page_detection_provider_helpers.py b/tests/test_page_detection_provider_helpers.py index 1b45bb3..0178401 100644 --- a/tests/test_page_detection_provider_helpers.py +++ b/tests/test_page_detection_provider_helpers.py @@ -68,8 +68,10 @@ def test_parse_text_block_box_json_supports_flat_nested_and_not_found_payloads() nested = _parse_text_block_box_json('{"block": {"left": 100, "top": 200, "right": 300, "bottom": 400}}') missing = _parse_text_block_box_json('{"block_found": false}') - assert flat is not None and flat.page_index == 1 - assert nested is not None and nested.page_index == 1 + assert flat is not None + assert flat.page_index == 1 + assert nested is not None + assert nested.page_index == 1 assert missing is None @@ -222,7 +224,8 @@ def prepare_messages( ) -> list[dict[str, object]]: assert system_prompt is None assert user_prompt is not None - assert images and images[0].size == (20, 60) + assert images + assert images[0].size == (20, 60) return [{"role": "user", "content": [{"type": "text", "text": user_prompt}]}] async def complete_text( @@ -248,7 +251,7 @@ async def complete_text( page_index=1, history_steps=2, round_index=3, - transport=cast(Any, FakeTransport()), + transport=cast("Any", FakeTransport()), ) assert decision == _EdgeReviewDecision(action="expand", amount=50) @@ -276,7 +279,8 @@ def prepare_messages( ) -> list[dict[str, object]]: assert system_prompt is None assert user_prompt is not None - assert images and images[0].size == (60, 20) + assert images + assert images[0].size == (60, 20) return [{"role": "user", "content": [{"type": "text", "text": user_prompt}]}] async def complete_text( @@ -303,7 +307,7 @@ async def complete_text( block_text="Et fuit lux", history_steps=1, round_index=1, - transport=cast(Any, FakeTransport()), + transport=cast("Any", FakeTransport()), ) assert decision == _EdgeReviewDecision(action="shrink", amount=100) @@ -332,7 +336,7 @@ async def _fake_review_single_edge_from_strip(**kwargs: object) -> _EdgeReviewDe history_steps=1, round_index=1, model="example/model", - transport=cast(Any, object()), + transport=cast("Any", object()), ) assert reviewed == current_box @@ -362,7 +366,7 @@ async def _fake_review_single_text_block_edge_from_strip(**kwargs: object) -> _E history_steps=1, round_index=1, model="example/model", - transport=cast(Any, object()), + transport=cast("Any", object()), ) assert reviewed == current_box @@ -391,7 +395,8 @@ async def test_run_review_pipeline_stops_immediately_when_all_pages_are_frozen( async def _review_box(box: _PageBox, history_steps: int, round_index: int) -> _PageBox: del box, history_steps, round_index review_called["value"] = True - raise AssertionError("review_box should not be called for frozen pages") + message = "review_box should not be called for frozen pages" + raise AssertionError(message) result = await _run_review_pipeline( initial_boxes=[initial_box], @@ -414,7 +419,7 @@ async def _review_box(box: _PageBox, history_steps: int, round_index: int) -> _P del history_steps, round_index if box.page_index == 1: raise RuntimeError("boom") - return cast(Any, None) + return cast("Any", None) result = await _run_review_pipeline( initial_boxes=[first_box, second_box], diff --git a/tests/test_providers.py b/tests/test_providers.py index 35fee85..fdb6e48 100644 --- a/tests/test_providers.py +++ b/tests/test_providers.py @@ -4,7 +4,7 @@ import json import sys from types import ModuleType, SimpleNamespace -from typing import Any, cast +from typing import TYPE_CHECKING, Any, cast import pytest from PIL import Image @@ -32,10 +32,8 @@ locate_text_block_bbox_with_llm, resolve_ocr_profile, ) -from churro_ocr.providers.hf import HuggingFaceVisionOCRBackend from churro_ocr.providers.ocr import ( AzureDocumentIntelligenceOCRBackend, - LiteLLMVisionOCRBackend, MinerU25OpenAICompatibleOCRBackend, MistralOCRBackend, OpenAICompatibleOCRBackend, @@ -69,6 +67,13 @@ PADDLEOCR_VL_1_5_OCR_TEMPLATE, ) +if TYPE_CHECKING: + from collections.abc import Awaitable + + from churro_ocr.providers.hf import HuggingFaceVisionOCRBackend + from churro_ocr.providers.ocr import LiteLLMVisionOCRBackend + from tests._types import HasKey, ReadableBody + def _extract_user_text_parts(messages: list[dict[str, Any]]) -> list[str]: user_messages = [message for message in messages if message.get("role") == "user"] @@ -103,7 +108,7 @@ def _fake_prepare_messages( return [{"role": "user", "content": [{"type": "text", "text": "prompt"}]}] async def _fake_complete_text( - self: LiteLLMTransport, + _transport: LiteLLMTransport, *, model: str, messages: list[dict[str, object]], @@ -111,7 +116,7 @@ async def _fake_complete_text( output_json: bool = False, allow_empty: bool = False, ) -> str: - captured["transport"] = self + captured["transport"] = _transport captured["model"] = model captured["messages"] = messages captured["timeout_seconds"] = timeout_seconds @@ -155,7 +160,7 @@ class FakeLogger: def debug(self, message: str, *args: object) -> None: prompt_logs.append(message % args if args else message) - async def _fake_complete_text(self, **_: object) -> str: # noqa: ANN001 + async def _fake_complete_text(_transport: LiteLLMTransport, **_kwargs: object) -> str: return "ok" monkeypatch.setattr( @@ -217,7 +222,7 @@ async def _fake_acompletion(**_: object) -> SimpleNamespace: async def test_litellm_ocr_backend_strips_default_output_tags( monkeypatch: pytest.MonkeyPatch, ) -> None: - async def _fake_complete_text(self, **_: object) -> str: # noqa: ANN001 + async def _fake_complete_text(_transport: LiteLLMTransport, **_kwargs: object) -> str: return f"<{DEFAULT_OCR_OUTPUT_TAG}>\ntranscribed text\n" monkeypatch.setattr( @@ -242,7 +247,7 @@ async def _fake_complete_text(self, **_: object) -> str: # noqa: ANN001 async def test_litellm_ocr_backend_accepts_empty_transport_output( monkeypatch: pytest.MonkeyPatch, ) -> None: - async def _fake_complete_text(self, **_: object) -> str: # noqa: ANN001 + async def _fake_complete_text(_transport: LiteLLMTransport, **_kwargs: object) -> str: return "" monkeypatch.setattr( @@ -267,7 +272,7 @@ async def _fake_complete_text(self, **_: object) -> str: # noqa: ANN001 async def test_openai_compatible_backend_reports_display_model( monkeypatch: pytest.MonkeyPatch, ) -> None: - async def _fake_complete_text(self, **_: object) -> str: # noqa: ANN001 + async def _fake_complete_text(_transport: LiteLLMTransport, **_kwargs: object) -> str: return "openai compatible text" monkeypatch.setattr("churro_ocr._internal.litellm.LiteLLMTransport.complete_text", _fake_complete_text) @@ -304,21 +309,21 @@ async def result(self) -> SimpleNamespace: return SimpleNamespace(content="azure text") class FakeClient: - def __init__(self, *, endpoint: str, credential: Any) -> None: + def __init__(self, *, endpoint: str, credential: object) -> None: calls["client_inits"] += 1 assert endpoint == "https://example.test" - assert credential.key == "secret" + assert cast("HasKey", credential).key == "secret" async def begin_analyze_document( self, *, model_id: str, - body: Any, + body: object, content_type: str, ) -> FakePoller: calls["requests"] += 1 assert model_id == "prebuilt-layout" - assert body.read() == b"image-bytes" + assert cast("ReadableBody", body).read() == b"image-bytes" assert content_type == "application/octet-stream" return FakePoller() @@ -387,21 +392,21 @@ async def result(self) -> SimpleNamespace: return SimpleNamespace(content="azure text") class FakeClient: - def __init__(self, *, endpoint: str, credential: Any) -> None: + def __init__(self, *, endpoint: str, credential: object) -> None: calls["client_inits"] += 1 assert endpoint == "https://example.test" - assert credential.key == "secret" + assert cast("HasKey", credential).key == "secret" async def begin_analyze_document( self, *, model_id: str, - body: Any, + body: object, content_type: str, ) -> FakePoller: calls["requests"] += 1 assert model_id == "prebuilt-layout" - assert body.read() == b"image-bytes" + assert cast("ReadableBody", body).read() == b"image-bytes" assert content_type == "application/octet-stream" if calls["requests"] < 3: raise FakeAzureError(503) @@ -607,7 +612,7 @@ def __init__(self, *, api_key: str) -> None: async def _fake_sleep(delay: float) -> None: sleep_calls.append(delay) - async def _fake_wait_for(awaitable: Any, **kwargs: float) -> Any: + async def _fake_wait_for(awaitable: Awaitable[SimpleNamespace], **kwargs: float) -> SimpleNamespace: calls["wait_for"] += 1 timeout = kwargs["timeout"] assert timeout == 60.0 @@ -847,7 +852,7 @@ def test_build_ocr_backend_uses_mineru2_5_profile_defaults_for_openai_compatible def test_build_ocr_backend_rejects_mineru2_5_for_litellm() -> None: - with pytest.raises(ConfigurationError, match="MinerU2.5 requires the built-in two-step pipeline"): + with pytest.raises(ConfigurationError, match=r"MinerU2\.5 requires the built-in two-step pipeline"): build_ocr_backend( OCRBackendSpec( provider="litellm", @@ -911,7 +916,7 @@ def _fake_prepare_messages_from_conversation( return conversation async def _fake_complete_text( - self: LiteLLMTransport, + _transport: LiteLLMTransport, *, model: str, messages: list[dict[str, object]], @@ -972,7 +977,7 @@ def _fake_prepare_messages_from_conversation( return [{"role": "user", "content": [{"type": "text", "text": "prompt"}]}] async def _fake_complete_text( - self: LiteLLMTransport, + _transport: LiteLLMTransport, *, model: str, messages: list[dict[str, object]], @@ -985,7 +990,7 @@ async def _fake_complete_text( captured["timeout_seconds"] = timeout_seconds captured["output_json"] = output_json captured["allow_empty"] = allow_empty - captured["completion_kwargs"] = dict(self.config.completion_kwargs) + captured["completion_kwargs"] = dict(_transport.config.completion_kwargs) return ( "---\n" "primary_language: en\n" @@ -1072,7 +1077,7 @@ def _fake_prepare_messages_from_conversation( return [{"role": "user", "content": [{"type": "text", "text": "prompt"}]}] async def _fake_complete_text( - self: LiteLLMTransport, + _transport: LiteLLMTransport, *, model: str, messages: list[dict[str, object]], @@ -1085,7 +1090,7 @@ async def _fake_complete_text( captured["timeout_seconds"] = timeout_seconds captured["output_json"] = output_json captured["allow_empty"] = allow_empty - captured["completion_kwargs"] = dict(self.config.completion_kwargs) + captured["completion_kwargs"] = dict(_transport.config.completion_kwargs) return ( f"{INFINITY_PARSER_7B_OCR_PROMPT}\n" "assistant:\n" @@ -1325,7 +1330,7 @@ def _fake_prepare_messages_from_conversation( return [{"role": "user", "content": [{"type": "text", "text": "prompt"}]}] async def _fake_complete_text( - self: LiteLLMTransport, + _transport: LiteLLMTransport, *, model: str, messages: list[dict[str, object]], @@ -1421,7 +1426,7 @@ async def _fake_complete_text( async def test_llm_page_detector_uses_prompt_transport( monkeypatch: pytest.MonkeyPatch, ) -> None: - async def _fake_complete_text(self, **_: object) -> str: # noqa: ANN001 + async def _fake_complete_text(_transport: LiteLLMTransport, **_kwargs: object) -> str: return json.dumps( { "pages": [ @@ -1446,7 +1451,7 @@ async def _fake_complete_text(self, **_: object) -> str: # noqa: ANN001 async def test_llm_page_detector_rejects_malformed_json( monkeypatch: pytest.MonkeyPatch, ) -> None: - async def _fake_complete_text(self, **_: object) -> str: # noqa: ANN001 + async def _fake_complete_text(_transport: LiteLLMTransport, **_kwargs: object) -> str: return '{"pages":"oops"}' monkeypatch.setattr("churro_ocr._internal.litellm.LiteLLMTransport.complete_text", _fake_complete_text) @@ -1504,7 +1509,7 @@ async def test_llm_page_detector_applies_iterative_review( ) prompts: list[str | None] = [] - async def _fake_complete_text(self, **kwargs: object) -> str: # noqa: ANN001 + async def _fake_complete_text(_transport: LiteLLMTransport, **kwargs: object) -> str: messages = cast("list[dict[str, Any]]", kwargs["messages"]) user_text_parts = _extract_user_text_parts(messages) assert len(messages) == 1 @@ -1534,7 +1539,7 @@ async def test_locate_text_block_bbox_with_llm_uses_block_prompt_transport( ) -> None: prompts: list[str | None] = [] - async def _fake_complete_text(self, **kwargs: object) -> str: # noqa: ANN001 + async def _fake_complete_text(_transport: LiteLLMTransport, **kwargs: object) -> str: messages = cast("list[dict[str, Any]]", kwargs["messages"]) user_text_parts = _extract_user_text_parts(messages) assert len(messages) == 1 @@ -1614,7 +1619,7 @@ async def _fake_acompletion(**_: object) -> SimpleNamespace: async def test_locate_text_block_bbox_with_llm_returns_none_when_not_found( monkeypatch: pytest.MonkeyPatch, ) -> None: - async def _fake_complete_text(self, **_: object) -> str: # noqa: ANN001 + async def _fake_complete_text(_transport: LiteLLMTransport, **_kwargs: object) -> str: return json.dumps({"block_found": False, "block": None}) monkeypatch.setattr("churro_ocr._internal.litellm.LiteLLMTransport.complete_text", _fake_complete_text) @@ -1733,7 +1738,7 @@ async def test_locate_text_block_bbox_with_llm_applies_iterative_review( ) prompts: list[str | None] = [] - async def _fake_complete_text(self, **kwargs: object) -> str: # noqa: ANN001 + async def _fake_complete_text(_transport: LiteLLMTransport, **kwargs: object) -> str: messages = cast("list[dict[str, Any]]", kwargs["messages"]) user_text_parts = _extract_user_text_parts(messages) assert len(messages) == 1 @@ -1797,22 +1802,22 @@ async def result(self) -> SimpleNamespace: ) class FakeClient: - def __init__(self, *, endpoint: str, credential: Any) -> None: + def __init__(self, *, endpoint: str, credential: object) -> None: calls["client_inits"] += 1 assert endpoint == "https://example.test" - assert credential.key == "secret" + assert cast("HasKey", credential).key == "secret" async def begin_analyze_document( self, *, model_id: str, - body: Any, + body: object, content_type: str, ) -> FakePoller: calls["requests"] += 1 assert model_id == "prebuilt-layout" assert content_type == "application/octet-stream" - assert body.read() + assert cast("ReadableBody", body).read() return FakePoller() async def close(self) -> None: @@ -1823,9 +1828,9 @@ def __init__(self, key: str) -> None: self.key = key azure_document_module = ModuleType("azure.ai.documentintelligence.aio") - cast(Any, azure_document_module).DocumentIntelligenceClient = FakeClient + cast("Any", azure_document_module).DocumentIntelligenceClient = FakeClient azure_credentials_module = ModuleType("azure.core.credentials") - cast(Any, azure_credentials_module).AzureKeyCredential = FakeAzureKeyCredential + cast("Any", azure_credentials_module).AzureKeyCredential = FakeAzureKeyCredential monkeypatch.setitem(sys.modules, "azure.ai.documentintelligence.aio", azure_document_module) monkeypatch.setitem(sys.modules, "azure.core.credentials", azure_credentials_module) @@ -1863,14 +1868,14 @@ async def result(self) -> SimpleNamespace: return SimpleNamespace(pages=[]) class FakeClient: - def __init__(self, *, endpoint: str, credential: Any) -> None: + def __init__(self, *, endpoint: str, credential: object) -> None: del endpoint, credential async def begin_analyze_document( self, *, model_id: str, - body: Any, + body: object, content_type: str, ) -> FakePoller: del model_id, body, content_type @@ -1884,9 +1889,9 @@ def __init__(self, key: str) -> None: self.key = key azure_document_module = ModuleType("azure.ai.documentintelligence.aio") - cast(Any, azure_document_module).DocumentIntelligenceClient = FakeClient + cast("Any", azure_document_module).DocumentIntelligenceClient = FakeClient azure_credentials_module = ModuleType("azure.core.credentials") - cast(Any, azure_credentials_module).AzureKeyCredential = FakeAzureKeyCredential + cast("Any", azure_credentials_module).AzureKeyCredential = FakeAzureKeyCredential monkeypatch.setitem(sys.modules, "azure.ai.documentintelligence.aio", azure_document_module) monkeypatch.setitem(sys.modules, "azure.core.credentials", azure_credentials_module) @@ -1930,21 +1935,21 @@ async def result(self) -> SimpleNamespace: ) class FakeClient: - def __init__(self, *, endpoint: str, credential: Any) -> None: + def __init__(self, *, endpoint: str, credential: object) -> None: calls["client_inits"] += 1 assert endpoint == "https://example.test" - assert credential.key == "secret" + assert cast("HasKey", credential).key == "secret" async def begin_analyze_document( self, *, model_id: str, - body: Any, + body: object, content_type: str, ) -> FakePoller: calls["requests"] += 1 assert model_id == "prebuilt-layout" - assert body.read() + assert cast("ReadableBody", body).read() assert content_type == "application/octet-stream" if calls["requests"] == 1: raise FakeAzureError(429, headers={"retry-after": "4"}) @@ -1961,9 +1966,9 @@ async def _fake_sleep(delay: float) -> None: sleep_calls.append(delay) azure_document_module = ModuleType("azure.ai.documentintelligence.aio") - cast(Any, azure_document_module).DocumentIntelligenceClient = FakeClient + cast("Any", azure_document_module).DocumentIntelligenceClient = FakeClient azure_credentials_module = ModuleType("azure.core.credentials") - cast(Any, azure_credentials_module).AzureKeyCredential = FakeAzureKeyCredential + cast("Any", azure_credentials_module).AzureKeyCredential = FakeAzureKeyCredential monkeypatch.setitem(sys.modules, "azure.ai.documentintelligence.aio", azure_document_module) monkeypatch.setitem(sys.modules, "azure.core.credentials", azure_credentials_module) monkeypatch.setattr(retry_module, "retry_sleep", _fake_sleep) From 24c76054f9aed3d337616e4bb692f320f7403b3f Mon Sep 17 00:00:00 2001 From: Sina Date: Mon, 13 Apr 2026 19:26:50 +0000 Subject: [PATCH 35/58] refactor(providers): extract shared OCR processing helpers --- src/churro_ocr/providers/_mineru25.py | 58 +---- src/churro_ocr/providers/_ocr_processing.py | 240 ++++++++++++++++++++ src/churro_ocr/providers/specs.py | 239 ++----------------- tests/test_hf_ocr.py | 20 ++ 4 files changed, 277 insertions(+), 280 deletions(-) create mode 100644 src/churro_ocr/providers/_ocr_processing.py diff --git a/src/churro_ocr/providers/_mineru25.py b/src/churro_ocr/providers/_mineru25.py index ead1513..61db45e 100644 --- a/src/churro_ocr/providers/_mineru25.py +++ b/src/churro_ocr/providers/_mineru25.py @@ -15,6 +15,8 @@ from PIL import Image, ImageDraw, ImageFont +from churro_ocr.providers._ocr_processing import strip_leading_chat_scaffold + if TYPE_CHECKING: from collections.abc import Awaitable, Callable @@ -43,29 +45,6 @@ r"(.*?)(?=<\|box_start\|>|$)", flags=re.DOTALL, ) -_CHAT_ROLE_PREFIXES = { - "assistant", - "assistant:", - "user", - "user:", - "system", - "system:", - "", - "", - "", - "<|assistant|>", - "<|assistant|>:", - "<|user|>", - "<|user|>:", - "<|system|>", - "<|system|>:", - "<|assistant|>", - "<|assistant|>:", - "<|user|>", - "<|user|>:", - "<|system|>", - "<|system|>:", -} _TABLE_IMAGE_TOKEN_TEMPLATE = "[{idx}]" _TABLE_IMAGE_TOKEN_LETTERS = "ACDGHKTWXYZ" _TABLE_IMAGE_TOKEN_NUMBERS = "2345678" @@ -649,37 +628,6 @@ def _resize_image_by_need( return image -def _strip_leading_chat_scaffold(text: str, *, prompts: list[str]) -> str: - cleaned = text.strip() - if not cleaned: - return "" - normalized_prompts = [prompt.strip() for prompt in prompts if prompt and prompt.strip()] - for _ in range(8): - previous = cleaned - lowered = cleaned.casefold() - stripped_prompt = False - for prompt in normalized_prompts: - if lowered.startswith(prompt.casefold()): - cleaned = cleaned[len(prompt) :].lstrip() - stripped_prompt = True - break - if stripped_prompt: - continue - lines = cleaned.splitlines() - if not lines: - return "" - first_line = lines[0].strip() - if first_line.casefold() in _CHAT_ROLE_PREFIXES: - cleaned = "\n".join(lines[1:]).lstrip() - continue - if re.fullmatch(r"<\|?(?:assistant|user|system)\|?>", first_line, flags=re.IGNORECASE): - cleaned = "\n".join(lines[1:]).lstrip() - continue - if cleaned == previous: - break - return cleaned.strip() - - def _trim_stop_strings(text: str) -> str: cleaned = text for stop in MINERU2_5_STOP_TOKENS: @@ -981,7 +929,7 @@ def sampling_for(self, step_key: str) -> MinerU25SamplingParams: def clean_response(self, text: str, *, step_key: str) -> str: cleaned = _trim_stop_strings(text) - return _strip_leading_chat_scaffold( + return strip_leading_chat_scaffold( cleaned, prompts=[self.system_prompt, self.prompt_for(step_key), self.prompt_for(step_key).strip()], ) diff --git a/src/churro_ocr/providers/_ocr_processing.py b/src/churro_ocr/providers/_ocr_processing.py new file mode 100644 index 0000000..94f5f45 --- /dev/null +++ b/src/churro_ocr/providers/_ocr_processing.py @@ -0,0 +1,240 @@ +"""Shared OCR preprocessing and postprocessing helpers for built-in profiles.""" + +from __future__ import annotations + +import re +from typing import TYPE_CHECKING + +from PIL import Image + +from churro_ocr._internal.image import ensure_rgb, prepare_ocr_image, resize_image_to_fit +from churro_ocr.prompts import ( + DEFAULT_OCR_OUTPUT_TAG, + parse_chandra_response, + parse_olmocr_response, + strip_ocr_output_tag, + strip_rich_ocr_markup_to_plain_text, +) +from churro_ocr.templates import ( + DEEPSEEK_OCR_2_OCR_PROMPT, + INFINITY_PARSER_7B_OCR_PROMPT, + INFINITY_PARSER_7B_SYSTEM_PROMPT, + LFM2_5_VL_1_6B_OCR_TEMPLATE, + PADDLEOCR_VL_1_5_OCR_PROMPT, +) +from churro_ocr.types import MetadataDict + +if TYPE_CHECKING: + from collections.abc import Sequence + +TextPostprocessorResult = str | tuple[str, MetadataDict] +CHANDRA_MAX_IMAGE_SIZE = (3_072, 2_048) +CHANDRA_MIN_IMAGE_SIZE = (1_792, 28) +CHANDRA_IMAGE_GRID_SIZE = 28 +OLMOCR_TARGET_LONGEST_IMAGE_DIM = 1_288 +_CHAT_ROLE_PREFIXES = { + "assistant", + "assistant:", + "user", + "user:", + "system", + "system:", + "", + "", + "", + "<|assistant|>", + "<|assistant|>:", + "<|user|>", + "<|user|>:", + "<|system|>", + "<|system|>:", + "<|assistant|>", + "<|assistant|>:", + "<|user|>", + "<|user|>:", + "<|system|>", + "<|system|>:", +} +_OUTER_FENCED_CODE_BLOCK_RE = re.compile( + r"^(?P`{3,}|~{3,})(?P[^\n]*)\n(?P.*)\n(?P=fence)$", + flags=re.DOTALL, +) + + +def strip_leading_chat_scaffold(text: str, *, prompts: Sequence[str]) -> str: + """Remove echoed prompts and leading chat role markers from model output.""" + cleaned = text.strip() + if not cleaned: + return "" + + normalized_prompts = tuple(prompt.strip() for prompt in prompts if prompt and prompt.strip()) + for _ in range(8): + previous = cleaned + lowered = cleaned.casefold() + stripped_prompt = False + for prompt in normalized_prompts: + if lowered.startswith(prompt.casefold()): + cleaned = cleaned[len(prompt) :].lstrip() + stripped_prompt = True + break + if stripped_prompt: + continue + + lines = cleaned.splitlines() + if not lines: + return "" + first_line = lines[0].strip() + if first_line.casefold() in _CHAT_ROLE_PREFIXES: + cleaned = "\n".join(lines[1:]).lstrip() + continue + if re.fullmatch(r"<\|?(?:assistant|user|system)\|?>", first_line, flags=re.IGNORECASE): + cleaned = "\n".join(lines[1:]).lstrip() + continue + if cleaned == previous: + break + return cleaned.strip() + + +def strip_outer_fenced_code_block(text: str) -> str: + """Unwrap a single outer fenced code block while preserving its inner content.""" + cleaned = text.strip() + match = _OUTER_FENCED_CODE_BLOCK_RE.fullmatch(cleaned) + if match is None: + return cleaned + return match.group("body").strip() + + +def identity_text_postprocessor(text: str) -> str: + """Return OCR text unchanged. + + :param text: OCR text to return. + :returns: The original ``text`` value. + """ + return text + + +def default_ocr_image_preprocessor(image: Image.Image) -> Image.Image: + """Apply the default OCR image preprocessing. + + :param image: Source page image. + :returns: Preprocessed image ready for OCR. + """ + return prepare_ocr_image(image) + + +def default_ocr_text_postprocessor(text: str) -> str: + """Strip the default OCR output tag wrapper. + + :param text: Raw OCR response text. + :returns: OCR text with the default wrapper removed when present. + """ + return strip_ocr_output_tag(text, output_tag=DEFAULT_OCR_OUTPUT_TAG) + + +def olmocr_image_preprocessor(image: Image.Image) -> Image.Image: + """Resize an image to olmOCR's expected 1288px longest side and normalize to RGB.""" + return ensure_rgb( + resize_image_to_fit( + image, + OLMOCR_TARGET_LONGEST_IMAGE_DIM, + OLMOCR_TARGET_LONGEST_IMAGE_DIM, + ) + ) + + +def olmocr_text_postprocessor(text: str) -> TextPostprocessorResult: + """Extract plain text and metadata from olmOCR YAML/markdown output.""" + return parse_olmocr_response(text) + + +def lfm2_5_vl_text_postprocessor(text: str) -> str: + """Strip Liquid LFM2.5-VL chat scaffold and OCR wrapper tags.""" + prompt = getattr(LFM2_5_VL_1_6B_OCR_TEMPLATE, "user_prompt", None) + cleaned = strip_leading_chat_scaffold(text, prompts=[prompt] if isinstance(prompt, str) else []) + return strip_ocr_output_tag(cleaned, output_tag=DEFAULT_OCR_OUTPUT_TAG) + + +def infinity_parser_7b_text_postprocessor(text: str) -> TextPostprocessorResult: + """Normalize Infinity-Parser markdown output to plain text and preserve raw markdown.""" + cleaned = strip_leading_chat_scaffold( + text, + prompts=[ + INFINITY_PARSER_7B_OCR_PROMPT, + INFINITY_PARSER_7B_SYSTEM_PROMPT, + ], + ) + raw_markdown = strip_outer_fenced_code_block(cleaned) + return strip_rich_ocr_markup_to_plain_text(raw_markdown), { + "raw_markdown": raw_markdown, + } + + +def deepseek_ocr_2_text_postprocessor(text: str) -> str: + """Strip DeepSeek OCR 2 prompt echoes, chat scaffold, and trailing stop tokens.""" + cleaned = text.strip() + stop_token = "<|end▁of▁sentence|>" + while cleaned.endswith(stop_token): + cleaned = cleaned[: -len(stop_token)].rstrip() + cleaned = strip_leading_chat_scaffold( + cleaned, + prompts=[ + f"\n{DEEPSEEK_OCR_2_OCR_PROMPT}", + DEEPSEEK_OCR_2_OCR_PROMPT, + ], + ) + return cleaned.strip() + + +def paddleocr_vl_text_postprocessor(text: str) -> str: + """Strip PaddleOCR-VL prompt echoes and leading chat scaffold from OCR output.""" + return strip_leading_chat_scaffold(text, prompts=[PADDLEOCR_VL_1_5_OCR_PROMPT]) + + +def chandra_image_preprocessor(image: Image.Image) -> Image.Image: + """Resize an image using Chandra OCR 2's pixel-budget and 28px-grid scaling.""" + width, height = image.size + if width <= 0 or height <= 0: + return ensure_rgb(image) + + max_pixels = CHANDRA_MAX_IMAGE_SIZE[0] * CHANDRA_MAX_IMAGE_SIZE[1] + min_pixels = CHANDRA_MIN_IMAGE_SIZE[0] * CHANDRA_MIN_IMAGE_SIZE[1] + current_pixels = width * height + scale = 1.0 + if current_pixels > max_pixels: + scale = (max_pixels / current_pixels) ** 0.5 + elif current_pixels < min_pixels: + scale = (min_pixels / current_pixels) ** 0.5 + + original_aspect_ratio = width / height + width_blocks = max(1, round((width * scale) / CHANDRA_IMAGE_GRID_SIZE)) + height_blocks = max(1, round((height * scale) / CHANDRA_IMAGE_GRID_SIZE)) + + while (width_blocks * height_blocks * CHANDRA_IMAGE_GRID_SIZE**2) > max_pixels: + if width_blocks == 1 and height_blocks == 1: + break + if width_blocks == 1: + height_blocks -= 1 + continue + if height_blocks == 1: + width_blocks -= 1 + continue + + width_loss = abs(((width_blocks - 1) / height_blocks) - original_aspect_ratio) + height_loss = abs((width_blocks / (height_blocks - 1)) - original_aspect_ratio) + if width_loss < height_loss: + width_blocks -= 1 + else: + height_blocks -= 1 + + new_size = ( + width_blocks * CHANDRA_IMAGE_GRID_SIZE, + height_blocks * CHANDRA_IMAGE_GRID_SIZE, + ) + if new_size == (width, height): + return ensure_rgb(image) + return ensure_rgb(image.resize(new_size, resample=Image.Resampling.LANCZOS)) + + +def chandra_text_postprocessor(text: str) -> TextPostprocessorResult: + """Extract plain text and metadata from Chandra OCR 2 HTML-layout output.""" + return parse_chandra_response(text) diff --git a/src/churro_ocr/providers/specs.py b/src/churro_ocr/providers/specs.py index 5a8ffc7..86105fe 100644 --- a/src/churro_ocr/providers/specs.py +++ b/src/churro_ocr/providers/specs.py @@ -2,21 +2,26 @@ from __future__ import annotations -import re -from collections.abc import Callable, Sequence +from collections.abc import Callable from dataclasses import dataclass, field from typing import TYPE_CHECKING, Literal, cast from PIL import Image -from churro_ocr._internal.image import ensure_rgb, prepare_ocr_image, resize_image_to_fit +from churro_ocr._internal.image import ensure_rgb from churro_ocr.errors import ConfigurationError -from churro_ocr.prompts import ( - DEFAULT_OCR_OUTPUT_TAG, - parse_chandra_response, - parse_olmocr_response, - strip_ocr_output_tag, - strip_rich_ocr_markup_to_plain_text, +from churro_ocr.providers._ocr_processing import ( + chandra_image_preprocessor, + chandra_text_postprocessor, + deepseek_ocr_2_text_postprocessor, + default_ocr_image_preprocessor, + default_ocr_text_postprocessor, + identity_text_postprocessor, + infinity_parser_7b_text_postprocessor, + lfm2_5_vl_text_postprocessor, + olmocr_image_preprocessor, + olmocr_text_postprocessor, + paddleocr_vl_text_postprocessor, ) from churro_ocr.templates import ( CHANDRA_OCR_2_MODEL_ID, @@ -24,7 +29,6 @@ CHURRO_3B_MODEL_ID, CHURRO_3B_XML_TEMPLATE, DEEPSEEK_OCR_2_MODEL_ID, - DEEPSEEK_OCR_2_OCR_PROMPT, DEEPSEEK_OCR_2_OCR_TEMPLATE, DEFAULT_OCR_TEMPLATE, DOTS_MOCR_MODEL_ID, @@ -32,9 +36,7 @@ DOTS_OCR_1_5_MODEL_ID, DOTS_OCR_1_5_OCR_TEMPLATE, INFINITY_PARSER_7B_MODEL_ID, - INFINITY_PARSER_7B_OCR_PROMPT, INFINITY_PARSER_7B_OCR_TEMPLATE, - INFINITY_PARSER_7B_SYSTEM_PROMPT, LFM2_5_VL_1_6B_MODEL_ID, LFM2_5_VL_1_6B_OCR_TEMPLATE, MINERU2_5_2509_1_2B_MODEL_ID, @@ -43,7 +45,6 @@ OLMOCR_2_7B_1025_MODEL_ID, OLMOCR_2_7B_1025_OCR_TEMPLATE, PADDLEOCR_VL_1_5_MODEL_ID, - PADDLEOCR_VL_1_5_OCR_PROMPT, PADDLEOCR_VL_1_5_OCR_TEMPLATE, OCRConversation, OCRPromptTemplateLike, @@ -68,10 +69,6 @@ PADDLEOCR_VL_MAX_TOKENS = 4_096 INFINITY_PARSER_7B_MIN_PIXELS = 256 * 28 * 28 INFINITY_PARSER_7B_MAX_PIXELS = 2304 * 28 * 28 -CHANDRA_MAX_IMAGE_SIZE = (3_072, 2_048) -CHANDRA_MIN_IMAGE_SIZE = (1_792, 28) -CHANDRA_IMAGE_GRID_SIZE = 28 -OLMOCR_TARGET_LONGEST_IMAGE_DIM = 1_288 MISTRAL_OCR_MODEL_IDS: tuple[MistralOCRModel, ...] = ( "mistral-ocr-2505", "mistral-ocr-2512", @@ -98,214 +95,6 @@ def validate_mistral_ocr_model( return cast("MistralOCRModel", model) -def identity_text_postprocessor(text: str) -> str: - """Return OCR text unchanged. - - :param text: OCR text to return. - :returns: The original ``text`` value. - """ - return text - - -def default_ocr_image_preprocessor(image: Image.Image) -> Image.Image: - """Apply the default OCR image preprocessing. - - :param image: Source page image. - :returns: Preprocessed image ready for OCR. - """ - return prepare_ocr_image(image) - - -def default_ocr_text_postprocessor(text: str) -> str: - """Strip the default OCR output tag wrapper. - - :param text: Raw OCR response text. - :returns: OCR text with the default wrapper removed when present. - """ - return strip_ocr_output_tag(text, output_tag=DEFAULT_OCR_OUTPUT_TAG) - - -_CHAT_ROLE_PREFIXES = { - "assistant", - "assistant:", - "user", - "user:", - "system", - "system:", - "", - "", - "", - "<|assistant|>", - "<|assistant|>:", - "<|user|>", - "<|user|>:", - "<|system|>", - "<|system|>:", - "<|assistant|>", - "<|assistant|>:", - "<|user|>", - "<|user|>:", - "<|system|>", - "<|system|>:", -} -_OUTER_FENCED_CODE_BLOCK_RE = re.compile( - r"^(?P`{3,}|~{3,})(?P[^\n]*)\n(?P.*)\n(?P=fence)$", - flags=re.DOTALL, -) - - -def _strip_leading_chat_scaffold(text: str, *, prompts: Sequence[str]) -> str: - """Remove echoed prompts and leading chat role markers from model output.""" - cleaned = text.strip() - if not cleaned: - return "" - - normalized_prompts = tuple(prompt.strip() for prompt in prompts if prompt and prompt.strip()) - for _ in range(8): - previous = cleaned - lowered = cleaned.casefold() - stripped_prompt = False - for prompt in normalized_prompts: - if lowered.startswith(prompt.casefold()): - cleaned = cleaned[len(prompt) :].lstrip() - stripped_prompt = True - break - if stripped_prompt: - continue - - lines = cleaned.splitlines() - if not lines: - return "" - first_line = lines[0].strip() - if first_line.casefold() in _CHAT_ROLE_PREFIXES: - cleaned = "\n".join(lines[1:]).lstrip() - continue - if re.fullmatch(r"<\|?(?:assistant|user|system)\|?>", first_line, flags=re.IGNORECASE): - cleaned = "\n".join(lines[1:]).lstrip() - continue - if cleaned == previous: - break - return cleaned.strip() - - -def _strip_outer_fenced_code_block(text: str) -> str: - """Unwrap a single outer fenced code block while preserving its inner content.""" - cleaned = text.strip() - match = _OUTER_FENCED_CODE_BLOCK_RE.fullmatch(cleaned) - if match is None: - return cleaned - return match.group("body").strip() - - -def olmocr_image_preprocessor(image: Image.Image) -> Image.Image: - """Resize an image to olmOCR's expected 1288px longest side and normalize to RGB.""" - return ensure_rgb( - resize_image_to_fit( - image, - OLMOCR_TARGET_LONGEST_IMAGE_DIM, - OLMOCR_TARGET_LONGEST_IMAGE_DIM, - ) - ) - - -def olmocr_text_postprocessor(text: str) -> TextPostprocessorResult: - """Extract plain text and metadata from olmOCR YAML/markdown output.""" - return parse_olmocr_response(text) - - -def lfm2_5_vl_text_postprocessor(text: str) -> str: - """Strip Liquid LFM2.5-VL chat scaffold and OCR wrapper tags.""" - prompt = getattr(LFM2_5_VL_1_6B_OCR_TEMPLATE, "user_prompt", None) - cleaned = _strip_leading_chat_scaffold(text, prompts=[prompt] if isinstance(prompt, str) else []) - return strip_ocr_output_tag(cleaned, output_tag=DEFAULT_OCR_OUTPUT_TAG) - - -def infinity_parser_7b_text_postprocessor(text: str) -> TextPostprocessorResult: - """Normalize Infinity-Parser markdown output to plain text and preserve raw markdown.""" - cleaned = _strip_leading_chat_scaffold( - text, - prompts=[ - INFINITY_PARSER_7B_OCR_PROMPT, - INFINITY_PARSER_7B_SYSTEM_PROMPT, - ], - ) - raw_markdown = _strip_outer_fenced_code_block(cleaned) - return strip_rich_ocr_markup_to_plain_text(raw_markdown), { - "raw_markdown": raw_markdown, - } - - -def deepseek_ocr_2_text_postprocessor(text: str) -> str: - """Strip DeepSeek OCR 2 prompt echoes, chat scaffold, and trailing stop tokens.""" - cleaned = text.strip() - stop_token = "<|end▁of▁sentence|>" - while cleaned.endswith(stop_token): - cleaned = cleaned[: -len(stop_token)].rstrip() - cleaned = _strip_leading_chat_scaffold( - cleaned, - prompts=[ - f"\n{DEEPSEEK_OCR_2_OCR_PROMPT}", - DEEPSEEK_OCR_2_OCR_PROMPT, - ], - ) - return cleaned.strip() - - -def paddleocr_vl_text_postprocessor(text: str) -> str: - """Strip PaddleOCR-VL prompt echoes and leading chat scaffold from OCR output.""" - return _strip_leading_chat_scaffold(text, prompts=[PADDLEOCR_VL_1_5_OCR_PROMPT]) - - -def chandra_image_preprocessor(image: Image.Image) -> Image.Image: - """Resize an image using Chandra OCR 2's pixel-budget and 28px-grid scaling.""" - width, height = image.size - if width <= 0 or height <= 0: - return ensure_rgb(image) - - max_pixels = CHANDRA_MAX_IMAGE_SIZE[0] * CHANDRA_MAX_IMAGE_SIZE[1] - min_pixels = CHANDRA_MIN_IMAGE_SIZE[0] * CHANDRA_MIN_IMAGE_SIZE[1] - current_pixels = width * height - scale = 1.0 - if current_pixels > max_pixels: - scale = (max_pixels / current_pixels) ** 0.5 - elif current_pixels < min_pixels: - scale = (min_pixels / current_pixels) ** 0.5 - - original_aspect_ratio = width / height - width_blocks = max(1, round((width * scale) / CHANDRA_IMAGE_GRID_SIZE)) - height_blocks = max(1, round((height * scale) / CHANDRA_IMAGE_GRID_SIZE)) - - while (width_blocks * height_blocks * CHANDRA_IMAGE_GRID_SIZE**2) > max_pixels: - if width_blocks == 1 and height_blocks == 1: - break - if width_blocks == 1: - height_blocks -= 1 - continue - if height_blocks == 1: - width_blocks -= 1 - continue - - width_loss = abs(((width_blocks - 1) / height_blocks) - original_aspect_ratio) - height_loss = abs((width_blocks / (height_blocks - 1)) - original_aspect_ratio) - if width_loss < height_loss: - width_blocks -= 1 - else: - height_blocks -= 1 - - new_size = ( - width_blocks * CHANDRA_IMAGE_GRID_SIZE, - height_blocks * CHANDRA_IMAGE_GRID_SIZE, - ) - if new_size == (width, height): - return ensure_rgb(image) - return ensure_rgb(image.resize(new_size, resample=Image.Resampling.LANCZOS)) - - -def chandra_text_postprocessor(text: str) -> TextPostprocessorResult: - """Extract plain text and metadata from Chandra OCR 2 HTML-layout output.""" - return parse_chandra_response(text) - - @dataclass(slots=True, frozen=True) class LiteLLMTransportConfig: """Shared transport config for LiteLLM-based multimodal requests. diff --git a/tests/test_hf_ocr.py b/tests/test_hf_ocr.py index e7c2303..9d48eda 100644 --- a/tests/test_hf_ocr.py +++ b/tests/test_hf_ocr.py @@ -210,6 +210,26 @@ def test_parse_and_render_mineru2_5_end_to_end_blocks() -> None: ) +def test_mineru2_5_clean_response_strips_prompt_echo_and_role_scaffold() -> None: + helper = MinerU25PipelineHelper( + prompts={"[default]": MINERU2_5_2509_1_2B_OCR_PROMPT}, + system_prompt=MINERU2_5_2509_1_2B_SYSTEM_PROMPT, + ) + + assert ( + helper.clean_response( + ( + f"{MINERU2_5_2509_1_2B_SYSTEM_PROMPT}\n" + f"{MINERU2_5_2509_1_2B_OCR_PROMPT}\n" + "assistant:\n" + "plain text<|im_end|>" + ), + step_key="[default]", + ) + == "plain text" + ) + + def test_parse_olmocr_response_extracts_plain_text_and_metadata() -> None: text, metadata = parse_olmocr_response( "---\n" From f1ff519240239d707995a29e7e12f9d7b3b8d83d Mon Sep 17 00:00:00 2001 From: Sina Date: Mon, 13 Apr 2026 19:26:55 +0000 Subject: [PATCH 36/58] refactor(page-detection): split provider helper modules --- .../providers/_page_detection_helpers.py | 640 ++++++++++++ .../providers/_page_detection_review.py | 279 ++++++ src/churro_ocr/providers/page_detection.py | 939 +----------------- 3 files changed, 968 insertions(+), 890 deletions(-) create mode 100644 src/churro_ocr/providers/_page_detection_helpers.py create mode 100644 src/churro_ocr/providers/_page_detection_review.py diff --git a/src/churro_ocr/providers/_page_detection_helpers.py b/src/churro_ocr/providers/_page_detection_helpers.py new file mode 100644 index 0000000..62edba6 --- /dev/null +++ b/src/churro_ocr/providers/_page_detection_helpers.py @@ -0,0 +1,640 @@ +"""Shared helpers for page-detection providers.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Literal, Protocol, cast + +from PIL import Image, ImageDraw, ImageOps + +from churro_ocr.errors import ConfigurationError, ProviderError +from churro_ocr.page_detection import PageCandidate + +if TYPE_CHECKING: + from collections.abc import Sequence + + from churro_ocr.types import BoundingBox, Polygon + +_BORDER_FRACTION = 0.05 +_PROCESSED_MAX_DIM = 2500 +_PAGE_DETECTION_BOX_WIDTH = 10 +_TEXT_BLOCK_DETECTION_BOX_WIDTH = 6 +_REVIEW_CROP_MARGIN_FRACTION = 0.12 +_TEXT_BLOCK_REVIEW_CROP_MARGIN_FRACTION = 0.22 +_REVIEW_EDGE_STOP_DEADBAND = 6 +_REVIEW_EDGE_STOP_STABLE_ROUNDS = 2 +_REVIEW_EDGE_STOP_OSCILLATION_MAGNITUDE_RATIO_MIN = 0.5 +_REVIEW_EDGE_STOP_OSCILLATION_MAGNITUDE_RATIO_MAX = 2.0 +_GUIDELINE_COLOR = "#ff3b30" +_SCALE_WITH_BORDER = 1 + (2 * _BORDER_FRACTION) +_NORMALIZED_MIN_COORD = (_BORDER_FRACTION / _SCALE_WITH_BORDER) * 1000 +_NORMALIZED_MAX_COORD = ((1 + _BORDER_FRACTION) / _SCALE_WITH_BORDER) * 1000 +_EDGE_NAMES = ("left", "top", "right", "bottom") + + +class _AzurePageLike(Protocol): + polygon: object + width: object + height: object + page_number: object + unit: object + angle: object + + +class _AzureAnalyzeResultLike(Protocol): + pages: Sequence[_AzurePageLike] | None + + +def _configuration_error(message: str) -> ConfigurationError: + return ConfigurationError(message) + + +def _provider_error(message: str) -> ProviderError: + return ProviderError(message) + + +def _type_error(message: str) -> TypeError: + return TypeError(message) + + +def _value_error(message: str) -> ValueError: + return ValueError(message) + + +def _full_image_candidate(image: Image.Image) -> PageCandidate: + return PageCandidate(bbox=(0.0, 0.0, float(image.width), float(image.height))) + + +def _bbox_from_polygon( + polygon: Polygon, +) -> BoundingBox: + xs = [point[0] for point in polygon] + ys = [point[1] for point in polygon] + return (min(xs), min(ys), max(xs), max(ys)) + + +def _normalize_polygon( + coordinates: Sequence[float] | None, +) -> Polygon: + if not coordinates or len(coordinates) < 6: + return () + pairs = [ + (float(coordinates[index]), float(coordinates[index + 1])) + for index in range(0, len(coordinates) - 1, 2) + ] + if len(pairs) > 1 and pairs[0] == pairs[-1]: + pairs.pop() + return tuple(pairs) + + +def _clamp_normalized(value: float) -> int: + clamped = max(_NORMALIZED_MIN_COORD, min(_NORMALIZED_MAX_COORD, value)) + rounded = round(clamped) + return max(0, min(1000, rounded)) + + +@dataclass(slots=True) +class _PageDetectionTransform: + original_size: tuple[int, int] + border: tuple[int, int] + padded_size: tuple[int, int] + processed_size: tuple[int, int] + scale_x: float + scale_y: float + + def map_box_to_original(self, box: _PageBox) -> BoundingBox: + processed_width, processed_height = self.processed_size + original_width, original_height = self.original_size + border_width, border_height = self.border + + left_processed, top_processed, right_processed, bottom_processed = box.denormalize( + processed_width, + processed_height, + ) + left_padded = left_processed / (self.scale_x or 1.0) + top_padded = top_processed / (self.scale_y or 1.0) + right_padded = right_processed / (self.scale_x or 1.0) + bottom_padded = bottom_processed / (self.scale_y or 1.0) + + left_original = max(0.0, min(original_width, left_padded - border_width)) + top_original = max(0.0, min(original_height, top_padded - border_height)) + right_original = max(0.0, min(original_width, right_padded - border_width)) + bottom_original = max(0.0, min(original_height, bottom_padded - border_height)) + return left_original, top_original, right_original, bottom_original + + +@dataclass(slots=True) +class _PageBox: + page_index: int + ymin: int + xmin: int + ymax: int + xmax: int + + @classmethod + def from_json(cls, payload: dict[str, Any]) -> _PageBox: + if "page_index" not in payload: + message = "Expected 'page_index' key in page-detection response." + raise _value_error(message) + required_keys = {"left", "top", "right", "bottom"} + if not required_keys.issubset(payload): + missing = required_keys - set(payload) + message = ( + f"Page-detection response must include keys {sorted(required_keys)}, " + f"missing {sorted(missing)}." + ) + raise _value_error(message) + return cls( + page_index=int(payload["page_index"]), + ymin=_clamp_normalized(float(payload["top"])), + xmin=_clamp_normalized(float(payload["left"])), + ymax=_clamp_normalized(float(payload["bottom"])), + xmax=_clamp_normalized(float(payload["right"])), + ) + + def denormalize(self, width: int, height: int) -> tuple[int, int, int, int]: + top = max(0, min(height, round(self.ymin * height / 1000))) + left = max(0, min(width, round(self.xmin * width / 1000))) + bottom = max(0, min(height, round(self.ymax * height / 1000))) + right = max(0, min(width, round(self.xmax * width / 1000))) + return left, top, right, bottom + + +EdgeDecisionAction = Literal["expand", "shrink", "no_change"] + + +@dataclass(slots=True, frozen=True) +class _EdgeReviewDecision: + action: EdgeDecisionAction + amount: int + + +@dataclass(slots=True, frozen=True) +class _BoxReviewDecision: + page_index: int + left: _EdgeReviewDecision + top: _EdgeReviewDecision + right: _EdgeReviewDecision + bottom: _EdgeReviewDecision + + +def _add_white_border( + image: Image.Image, + *, + fraction: float = _BORDER_FRACTION, +) -> tuple[Image.Image, int, int]: + if fraction <= 0: + return image, 0, 0 + border_width = max(1, round(image.width * fraction)) + border_height = max(1, round(image.height * fraction)) + expanded = ImageOps.expand( + image, + border=(border_width, border_height, border_width, border_height), + fill="white", + ) + return expanded, border_width, border_height + + +def _resize_image_to_fit(image: Image.Image, *, max_dim: int = _PROCESSED_MAX_DIM) -> Image.Image: + width, height = image.size + longest_side = max(width, height) + if longest_side <= max_dim: + return image + scale = max_dim / longest_side + return image.resize((max(1, round(width * scale)), max(1, round(height * scale)))) + + +def _prepare_detection_image(image: Image.Image) -> tuple[Image.Image, _PageDetectionTransform]: + rgb_image = image.convert("RGB") + bordered, border_width, border_height = _add_white_border(rgb_image) + processed = _resize_image_to_fit(bordered) + transform = _PageDetectionTransform( + original_size=image.size, + border=(border_width, border_height), + padded_size=bordered.size, + processed_size=processed.size, + scale_x=processed.width / bordered.width if bordered.width else 1.0, + scale_y=processed.height / bordered.height if bordered.height else 1.0, + ) + return processed, transform + + +def _strip_code_fence(raw: str) -> str: + text = raw.strip() + if text.startswith("```"): + lines = text.splitlines() + if len(lines) >= 2: + lines = lines[1:] + if lines and lines[-1].startswith("```"): + lines = lines[:-1] + text = "\n".join(lines).strip() + return text + + +def _parse_page_boxes_json(output: str) -> list[_PageBox]: + response_text = _strip_code_fence(output) + try: + payload = json.loads(response_text) + except json.JSONDecodeError as exc: + message = "LLM page detection returned invalid JSON." + raise _provider_error(message) from exc + + if not isinstance(payload, dict): + message = "LLM page detection response must be a JSON object." + raise _provider_error(message) + + pages = payload.get("pages") + if not isinstance(pages, list): + message = "LLM page detection response must include a `pages` list." + raise _provider_error(message) + + boxes: list[_PageBox] = [] + for page_index, page in enumerate(pages): + if not isinstance(page, dict): + message = f"LLM page detection entry {page_index} must be an object." + raise _provider_error(message) + try: + boxes.append(_PageBox.from_json(cast("dict[str, Any]", page))) + except (TypeError, ValueError) as exc: + message = f"LLM page detection entry {page_index} is invalid: {exc}" + raise _provider_error(message) from exc + return sorted(boxes, key=lambda box: box.page_index) + + +def _build_target_box_from_payload(payload: dict[str, Any], *, target_index: int) -> _PageBox: + return _PageBox.from_json( + { + "page_index": target_index, + "left": payload["left"], + "top": payload["top"], + "right": payload["right"], + "bottom": payload["bottom"], + } + ) + + +def _parse_target_box_json( + output: str, + *, + target_key: str, + found_key: str, + error_context: str, +) -> _PageBox | None: + response_text = _strip_code_fence(output) + try: + payload = json.loads(response_text) + except json.JSONDecodeError as exc: + message = f"{error_context} returned invalid JSON." + raise _provider_error(message) from exc + + if not isinstance(payload, dict): + message = f"{error_context} response must be a JSON object." + raise _provider_error(message) + payload_dict = cast("dict[str, Any]", payload) + + if {"left", "top", "right", "bottom"}.issubset(payload_dict): + try: + return _build_target_box_from_payload(payload_dict, target_index=1) + except (TypeError, ValueError) as exc: + message = f"{error_context} bbox is invalid: {exc}" + raise _provider_error(message) from exc + + raw_target = payload_dict.get(target_key) + if raw_target is None: + raw_target = payload_dict.get("bbox") + if isinstance(raw_target, dict): + try: + return _build_target_box_from_payload(cast("dict[str, Any]", raw_target), target_index=1) + except (TypeError, ValueError) as exc: + message = f"{error_context} bbox is invalid: {exc}" + raise _provider_error(message) from exc + if raw_target is not None: + message = f"{error_context} response `{target_key}` must be an object or null." + raise _provider_error(message) + + if ( + payload_dict.get(found_key) is False + or payload_dict.get("found") is False + or (target_key in payload_dict and payload_dict[target_key] is None) + or ("bbox" in payload_dict and payload_dict["bbox"] is None) + ): + return None + + message = ( + f"{error_context} response must include a `{target_key}` object " + f"or explicitly mark `{found_key}` false." + ) + raise _provider_error(message) + + +def _parse_text_block_box_json(output: str) -> _PageBox | None: + return _parse_target_box_json( + output, + target_key="block", + found_key="block_found", + error_context="LLM text-block localization", + ) + + +def _parse_edge_review_decision( + payload: object, + *, + edge_name: str, +) -> _EdgeReviewDecision: + if not isinstance(payload, dict): + message = f"Review edge '{edge_name}' must be an object." + raise _type_error(message) + payload_dict = cast("dict[str, object]", payload) + + raw_action = payload_dict.get("action") + if raw_action is None: + raw_action = payload_dict.get("decision") + if not isinstance(raw_action, str): + message = f"Review edge '{edge_name}' must include string 'action'." + raise _type_error(message) + action = raw_action.strip().lower() + if action not in {"expand", "shrink", "no_change"}: + message = f"Review edge '{edge_name}' action must be one of 'expand', 'shrink', 'no_change'." + raise _value_error(message) + action_literal = cast("EdgeDecisionAction", action) + + try: + raw_amount = payload_dict.get("amount") + amount = 0 if raw_amount is None else round(float(cast("Any", raw_amount))) + except (TypeError, ValueError) as exc: + message = f"Review edge '{edge_name}' amount must be numeric." + raise _value_error(message) from exc + amount = max(0, min(1000, amount)) + if action_literal == "no_change": + amount = 0 + return _EdgeReviewDecision(action=action_literal, amount=amount) + + +def _parse_single_edge_review_decision_json( + output: str, +) -> tuple[int, str, _EdgeReviewDecision]: + try: + payload = json.loads(_strip_code_fence(output)) + except json.JSONDecodeError as exc: + message = f"Failed to decode edge-review response as JSON: {exc}" + raise _value_error(message) from exc + + if not isinstance(payload, dict): + message = "Edge-review response must be a JSON object." + raise _type_error(message) + if "page_index" not in payload: + message = "Edge-review response must include 'page_index'." + raise _value_error(message) + + raw_edge = payload.get("edge") + if not isinstance(raw_edge, str): + message = "Edge-review response must include string 'edge'." + raise _type_error(message) + edge_name = raw_edge.strip().lower() + if edge_name not in _EDGE_NAMES: + message = "Edge-review response 'edge' must be left/top/right/bottom." + raise _value_error(message) + + decision_payload = payload.get("decision") + if not isinstance(decision_payload, dict): + decision_payload = { + "action": payload.get("action"), + "amount": payload.get("amount", 0), + } + + return ( + int(payload["page_index"]), + edge_name, + _parse_edge_review_decision( + decision_payload, + edge_name=edge_name, + ), + ) + + +def _parse_text_block_edge_review_decision_json( + output: str, +) -> tuple[str, _EdgeReviewDecision]: + try: + payload = json.loads(_strip_code_fence(output)) + except json.JSONDecodeError as exc: + message = f"Failed to decode text-block edge-review response as JSON: {exc}" + raise _value_error(message) from exc + + if not isinstance(payload, dict): + message = "Text-block edge-review response must be a JSON object." + raise _type_error(message) + payload_dict = cast("dict[str, object]", payload) + + raw_edge = payload_dict.get("edge") + if not isinstance(raw_edge, str): + message = "Text-block edge-review response must include string 'edge'." + raise _type_error(message) + edge_name = raw_edge.strip().lower() + if edge_name not in _EDGE_NAMES: + message = "Text-block edge-review response 'edge' must be left/top/right/bottom." + raise _value_error(message) + + decision_payload = payload_dict.get("decision") + if not isinstance(decision_payload, dict): + decision_payload = { + "action": payload_dict.get("action"), + "amount": payload_dict.get("amount", 0), + } + return edge_name, _parse_edge_review_decision(decision_payload, edge_name=edge_name) + + +def _boxes_equal(left_boxes: Sequence[_PageBox], right_boxes: Sequence[_PageBox]) -> bool: + if len(left_boxes) != len(right_boxes): + return False + for left_box, right_box in zip(left_boxes, right_boxes, strict=False): + if ( + left_box.page_index != right_box.page_index + or left_box.xmin != right_box.xmin + or left_box.ymin != right_box.ymin + or left_box.xmax != right_box.xmax + or left_box.ymax != right_box.ymax + ): + return False + return True + + +def _bbox_to_polygon( + bbox: BoundingBox, +) -> Polygon: + left, top, right, bottom = bbox + return ((left, top), (right, top), (right, bottom), (left, bottom)) + + +def _normalize_pixel_coord(value: int, size: int) -> int: + if size <= 0: + return 0 + return max(0, min(1000, round(value * 1000 / size))) + + +def _build_box_review_preview( + image: Image.Image, + box: _PageBox, + *, + margin_fraction: float = _REVIEW_CROP_MARGIN_FRACTION, + outline_width: int = _PAGE_DETECTION_BOX_WIDTH, +) -> tuple[Image.Image, tuple[int, int, int, int]]: + width, height = image.size + left, top, right, bottom = box.denormalize(width, height) + + box_width = max(1, right - left) + box_height = max(1, bottom - top) + margin_x = max(outline_width * 2, round(box_width * margin_fraction)) + margin_y = max(outline_width * 2, round(box_height * margin_fraction)) + + crop_left = max(0, left - margin_x) + crop_top = max(0, top - margin_y) + crop_right = min(width, right + margin_x) + crop_bottom = min(height, bottom + margin_y) + + crop = image.crop((crop_left, crop_top, crop_right, crop_bottom)) + preview = crop.copy() + draw = ImageDraw.Draw(preview) + draw.rectangle( + [left - crop_left, top - crop_top, right - crop_left, bottom - crop_top], + outline=_GUIDELINE_COLOR, + width=outline_width, + ) + return preview, (crop_left, crop_top, crop_right, crop_bottom) + + +def _build_edge_strip_review_preview( + image: Image.Image, + box: _PageBox, + edge_name: str, + *, + outline_width: int = _PAGE_DETECTION_BOX_WIDTH, +) -> tuple[Image.Image, tuple[int, int, int, int]]: + width, height = image.size + left, top, right, bottom = box.denormalize(width, height) + box_width = max(1, right - left) + box_height = max(1, bottom - top) + + band_half_x = max(outline_width * 3, round(box_width * 0.18)) + band_half_y = max(outline_width * 3, round(box_height * 0.18)) + orthogonal_pad_x = max(outline_width * 2, round(box_width * 0.06)) + orthogonal_pad_y = max(outline_width * 2, round(box_height * 0.06)) + + if edge_name == "left": + x0 = max(0, left - band_half_x) + x1 = min(width, left + band_half_x) + y0 = max(0, top - orthogonal_pad_y) + y1 = min(height, bottom + orthogonal_pad_y) + elif edge_name == "right": + x0 = max(0, right - band_half_x) + x1 = min(width, right + band_half_x) + y0 = max(0, top - orthogonal_pad_y) + y1 = min(height, bottom + orthogonal_pad_y) + elif edge_name == "top": + x0 = max(0, left - orthogonal_pad_x) + x1 = min(width, right + orthogonal_pad_x) + y0 = max(0, top - band_half_y) + y1 = min(height, top + band_half_y) + elif edge_name == "bottom": + x0 = max(0, left - orthogonal_pad_x) + x1 = min(width, right + orthogonal_pad_x) + y0 = max(0, bottom - band_half_y) + y1 = min(height, bottom + band_half_y) + else: + message = f"Unsupported edge '{edge_name}'. Expected left/top/right/bottom." + raise _value_error(message) + + if x0 >= x1 or y0 >= y1: + message = f"Invalid strip bounds for edge '{edge_name}'." + raise _value_error(message) + return image.crop((x0, y0, x1, y1)), (x0, y0, x1, y1) + + +def _convert_source_box_to_review_crop_box( + box: _PageBox, + crop_bounds: tuple[int, int, int, int], + source_size: tuple[int, int], +) -> _PageBox: + source_width, source_height = source_size + crop_left, crop_top, crop_right, crop_bottom = crop_bounds + crop_width = max(1, crop_right - crop_left) + crop_height = max(1, crop_bottom - crop_top) + left, top, right, bottom = box.denormalize(source_width, source_height) + return _PageBox.from_json( + { + "page_index": box.page_index, + "left": _normalize_pixel_coord(max(0, min(crop_width, left - crop_left)), crop_width), + "top": _normalize_pixel_coord(max(0, min(crop_height, top - crop_top)), crop_height), + "right": _normalize_pixel_coord( + max(0, min(crop_width, right - crop_left)), + crop_width, + ), + "bottom": _normalize_pixel_coord( + max(0, min(crop_height, bottom - crop_top)), + crop_height, + ), + } + ) + + +def _map_review_crop_box_to_source_box( + reviewed_box: _PageBox, + crop_bounds: tuple[int, int, int, int], + source_size: tuple[int, int], + *, + page_index: int, +) -> _PageBox: + source_width, source_height = source_size + crop_left, crop_top, crop_right, crop_bottom = crop_bounds + crop_width = max(1, crop_right - crop_left) + crop_height = max(1, crop_bottom - crop_top) + local_left, local_top, local_right, local_bottom = reviewed_box.denormalize( + crop_width, + crop_height, + ) + return _PageBox.from_json( + { + "page_index": page_index, + "left": _normalize_pixel_coord( + max(0, min(source_width, crop_left + local_left)), + source_width, + ), + "top": _normalize_pixel_coord( + max(0, min(source_height, crop_top + local_top)), + source_height, + ), + "right": _normalize_pixel_coord( + max(0, min(source_width, crop_left + local_right)), + source_width, + ), + "bottom": _normalize_pixel_coord( + max(0, min(source_height, crop_top + local_bottom)), + source_height, + ), + } + ) + + +def _merge_instruction_prompts(*parts: str | None) -> str: + """Merge one or more instruction strings into a single non-empty user prompt.""" + merged_parts = [part.strip() for part in parts if isinstance(part, str) and part.strip()] + if not merged_parts: + message = "Expected at least one non-empty instruction prompt." + raise _value_error(message) + return "\n\n".join(merged_parts) + + +def _normalize_azure_page_polygon( + page: _AzurePageLike, *, image: Image.Image +) -> Polygon: + raw_polygon = getattr(page, "polygon", None) + polygon = _normalize_polygon(raw_polygon) + if not polygon: + return () + + page_width = float(getattr(page, "width", 0.0) or image.width) + page_height = float(getattr(page, "height", 0.0) or image.height) + scale_x = image.width / page_width if page_width else 1.0 + scale_y = image.height / page_height if page_height else 1.0 + return tuple((x * scale_x, y * scale_y) for x, y in polygon) diff --git a/src/churro_ocr/providers/_page_detection_review.py b/src/churro_ocr/providers/_page_detection_review.py new file mode 100644 index 0000000..cba25f0 --- /dev/null +++ b/src/churro_ocr/providers/_page_detection_review.py @@ -0,0 +1,279 @@ +"""Review-state helpers for iterative page-detection refinement.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from churro_ocr._internal.logging import logger +from churro_ocr.providers._page_detection_helpers import ( + _EDGE_NAMES, + _REVIEW_EDGE_STOP_DEADBAND, + _REVIEW_EDGE_STOP_OSCILLATION_MAGNITUDE_RATIO_MAX, + _REVIEW_EDGE_STOP_OSCILLATION_MAGNITUDE_RATIO_MIN, + _REVIEW_EDGE_STOP_STABLE_ROUNDS, + _BoxReviewDecision, + _EdgeReviewDecision, + _PageBox, +) + +if TYPE_CHECKING: + from collections.abc import Sequence + + +def _apply_box_review_decision( + current_box: _PageBox, + decision: _BoxReviewDecision, + *, + expected_page_index: int, +) -> _PageBox: + page_index = expected_page_index + if decision.page_index != expected_page_index: + logger.info( + "Review decision page_index mismatch (expected=%s, got=%s); using expected.", + expected_page_index, + decision.page_index, + ) + + left = _apply_edge_decision_to_coordinate(current_box.xmin, decision.left, is_min_edge=True) + top = _apply_edge_decision_to_coordinate(current_box.ymin, decision.top, is_min_edge=True) + right = _apply_edge_decision_to_coordinate(current_box.xmax, decision.right, is_min_edge=False) + bottom = _apply_edge_decision_to_coordinate( + current_box.ymax, + decision.bottom, + is_min_edge=False, + ) + + min_span = 1 + if left >= right: + center = (left + right) // 2 + left = max(0, center - min_span) + right = min(1000, center + min_span) + if top >= bottom: + center = (top + bottom) // 2 + top = max(0, center - min_span) + bottom = min(1000, center + min_span) + + return _PageBox.from_json( + { + "page_index": page_index, + "left": left, + "top": top, + "right": right, + "bottom": bottom, + } + ) + + +def _no_change_edge_review_decision() -> _EdgeReviewDecision: + return _EdgeReviewDecision(action="no_change", amount=0) + + +def _new_page_review_stop_state() -> dict[str, dict[str, int | bool | None]]: + return { + edge_name: { + "frozen": False, + "stable_rounds": 0, + "last_sign": None, + "last_mag": None, + } + for edge_name in _EDGE_NAMES + } + + +def _page_review_is_fully_frozen(page_state: dict[str, dict[str, int | bool | None]]) -> bool: + return all(bool(page_state[edge_name]["frozen"]) for edge_name in _EDGE_NAMES) + + +def _apply_page_review_stop_condition( + *, + prior_box: _PageBox, + reviewed_box: _PageBox, + page_state: dict[str, dict[str, int | bool | None]], + round_index: int, + subject_name: str = "page", +) -> _PageBox: + prior_coords = _box_to_edge_coords(prior_box) + reviewed_coords = _box_to_edge_coords(reviewed_box) + final_coords = dict(reviewed_coords) + + for edge_name in _EDGE_NAMES: + edge_state = page_state[edge_name] + prior_value = prior_coords[edge_name] + candidate_value = reviewed_coords[edge_name] + delta = candidate_value - prior_value + magnitude = abs(delta) + + if bool(edge_state["frozen"]): + final_coords[edge_name] = prior_value + continue + + if magnitude <= _REVIEW_EDGE_STOP_DEADBAND: + final_coords[edge_name] = prior_value + edge_state["stable_rounds"] = int(edge_state["stable_rounds"] or 0) + 1 + if int(edge_state["stable_rounds"]) >= _REVIEW_EDGE_STOP_STABLE_ROUNDS: + edge_state["frozen"] = True + logger.info( + "Freezing %s %s edge %s after %s stable round(s) (deadband <= %s).", + subject_name, + prior_box.page_index, + edge_name, + edge_state["stable_rounds"], + _REVIEW_EDGE_STOP_DEADBAND, + ) + continue + + edge_state["stable_rounds"] = 0 + sign = 1 if delta > 0 else -1 + previous_sign = edge_state["last_sign"] + previous_magnitude = edge_state["last_mag"] + if ( + isinstance(previous_sign, int) + and previous_sign != 0 + and previous_sign != sign + and isinstance(previous_magnitude, int) + and previous_magnitude > _REVIEW_EDGE_STOP_DEADBAND + and _is_oscillating_magnitude(magnitude, previous_magnitude) + ): + final_coords[edge_name] = _select_more_expansive_oscillation_coordinate( + edge_name=edge_name, + prior_value=prior_value, + candidate_value=candidate_value, + ) + edge_state["frozen"] = True + logger.info( + "Freezing %s %s edge %s on round %s due to oscillation (prev=%s, current=%s, final=%s).", + subject_name, + prior_box.page_index, + edge_name, + round_index, + previous_magnitude, + magnitude, + final_coords[edge_name], + ) + continue + + edge_state["last_sign"] = sign + edge_state["last_mag"] = magnitude + + return _build_page_box_from_edge_coords(prior_box.page_index, final_coords) + + +def _is_oscillating_magnitude(current_magnitude: int, previous_magnitude: int) -> bool: + if current_magnitude <= 0 or previous_magnitude <= 0: + return False + ratio = current_magnitude / previous_magnitude if previous_magnitude else 0.0 + return ( + _REVIEW_EDGE_STOP_OSCILLATION_MAGNITUDE_RATIO_MIN + <= ratio + <= _REVIEW_EDGE_STOP_OSCILLATION_MAGNITUDE_RATIO_MAX + ) + + +def _select_more_expansive_oscillation_coordinate( + *, + edge_name: str, + prior_value: int, + candidate_value: int, +) -> int: + if edge_name in {"left", "top"}: + return min(prior_value, candidate_value) + return max(prior_value, candidate_value) + + +def _box_to_edge_coords(box: _PageBox) -> dict[str, int]: + return { + "left": box.xmin, + "top": box.ymin, + "right": box.xmax, + "bottom": box.ymax, + } + + +def _build_page_box_from_edge_coords(page_index: int, coords: dict[str, int]) -> _PageBox: + left = int(coords["left"]) + top = int(coords["top"]) + right = int(coords["right"]) + bottom = int(coords["bottom"]) + + min_span = 1 + if left >= right: + center = (left + right) // 2 + left = max(0, center - min_span) + right = min(1000, center + min_span) + if top >= bottom: + center = (top + bottom) // 2 + top = max(0, center - min_span) + bottom = min(1000, center + min_span) + + return _PageBox.from_json( + { + "page_index": page_index, + "left": left, + "top": top, + "right": right, + "bottom": bottom, + } + ) + + +def _strip_axis_size_pixels( + strip_bounds: tuple[int, int, int, int], + *, + edge_name: str, +) -> int: + x0, y0, x1, y1 = strip_bounds + return (x1 - x0) if edge_name in {"left", "right"} else (y1 - y0) + + +def _convert_strip_delta_to_local_delta( + strip_delta_normalized: int, + *, + strip_axis_pixels: int, + local_axis_pixels: int, +) -> int: + if strip_delta_normalized <= 0 or strip_axis_pixels <= 0 or local_axis_pixels <= 0: + return 0 + delta_pixels = strip_delta_normalized * strip_axis_pixels / 1000 + local_delta = round(delta_pixels * 1000 / local_axis_pixels) + return max(0, min(1000, local_delta)) + + +def _apply_edge_decision_to_coordinate( + current_value: int, + decision: _EdgeReviewDecision, + *, + is_min_edge: bool, +) -> int: + if decision.action == "no_change" or decision.amount <= 0: + return current_value + if decision.action == "expand": + return current_value - decision.amount if is_min_edge else current_value + decision.amount + if decision.action == "shrink": + return current_value + decision.amount if is_min_edge else current_value - decision.amount + return current_value + + +def _log_box_history( + history_boxes: Sequence[Sequence[_PageBox]], + *, + subject_name: str = "Page", +) -> None: + per_page_history: dict[int, dict[str, list[int]]] = {} + for boxes in history_boxes: + for box in boxes: + page_history = per_page_history.setdefault( + box.page_index, + {"left": [], "top": [], "right": [], "bottom": []}, + ) + page_history["left"].append(box.xmin) + page_history["top"].append(box.ymin) + page_history["right"].append(box.xmax) + page_history["bottom"].append(box.ymax) + if not per_page_history: + return + label_width = max(len(key) for key in ("left", "top", "right", "bottom")) + for page_index in sorted(per_page_history): + logger.info("%s %s coordinate history:", subject_name, page_index) + page_history = per_page_history[page_index] + for key in ("left", "top", "right", "bottom"): + logger.info("%s: %s", key.ljust(label_width), " -> ".join(map(str, page_history[key]))) diff --git a/src/churro_ocr/providers/page_detection.py b/src/churro_ocr/providers/page_detection.py index ec8c000..ac4189c 100644 --- a/src/churro_ocr/providers/page_detection.py +++ b/src/churro_ocr/providers/page_detection.py @@ -3,19 +3,15 @@ from __future__ import annotations import asyncio -import json from dataclasses import dataclass from io import BytesIO -from typing import TYPE_CHECKING, Any, Literal, Protocol, cast - -from PIL import Image, ImageDraw, ImageOps +from typing import TYPE_CHECKING, cast from churro_ocr._internal.install import install_command_hint from churro_ocr._internal.litellm import LiteLLMTransport from churro_ocr._internal.logging import logger from churro_ocr._internal.retry import retry_api_call from churro_ocr._internal.runtime import run_sync -from churro_ocr.errors import ConfigurationError, ProviderError from churro_ocr.page_detection import PageCandidate, PageDetectionBackend from churro_ocr.prompts import DEFAULT_BOUNDARY_DETECTION_PROMPT from churro_ocr.prompts.layout import ( @@ -23,623 +19,60 @@ build_text_block_boundary_review_prompt, build_text_block_localization_prompt, ) +from churro_ocr.providers import _page_detection_helpers as _helpers +from churro_ocr.providers import _page_detection_review as _review from churro_ocr.providers.specs import LiteLLMTransportConfig if TYPE_CHECKING: - from collections.abc import Awaitable, Callable, Sequence - - from churro_ocr.types import BoundingBox, Polygon - -_BORDER_FRACTION = 0.05 -_PROCESSED_MAX_DIM = 2500 -_PAGE_DETECTION_BOX_WIDTH = 10 -_TEXT_BLOCK_DETECTION_BOX_WIDTH = 6 -_REVIEW_CROP_MARGIN_FRACTION = 0.12 -_TEXT_BLOCK_REVIEW_CROP_MARGIN_FRACTION = 0.22 -_REVIEW_EDGE_STOP_DEADBAND = 6 -_REVIEW_EDGE_STOP_STABLE_ROUNDS = 2 -_REVIEW_EDGE_STOP_OSCILLATION_MAGNITUDE_RATIO_MIN = 0.5 -_REVIEW_EDGE_STOP_OSCILLATION_MAGNITUDE_RATIO_MAX = 2.0 -_GUIDELINE_COLOR = "#ff3b30" -_SCALE_WITH_BORDER = 1 + (2 * _BORDER_FRACTION) -_NORMALIZED_MIN_COORD = (_BORDER_FRACTION / _SCALE_WITH_BORDER) * 1000 -_NORMALIZED_MAX_COORD = ((1 + _BORDER_FRACTION) / _SCALE_WITH_BORDER) * 1000 -_EDGE_NAMES = ("left", "top", "right", "bottom") + from collections.abc import Awaitable, Callable + + from PIL import Image + + from churro_ocr.types import BoundingBox + +_AzureAnalyzeResultLike = _helpers._AzureAnalyzeResultLike +_bbox_from_polygon = _helpers._bbox_from_polygon +_bbox_to_polygon = _helpers._bbox_to_polygon +_BoxReviewDecision = _helpers._BoxReviewDecision +_boxes_equal = _helpers._boxes_equal +_build_box_review_preview = _helpers._build_box_review_preview +_build_edge_strip_review_preview = _helpers._build_edge_strip_review_preview +_configuration_error = _helpers._configuration_error +_convert_source_box_to_review_crop_box = _helpers._convert_source_box_to_review_crop_box +_EDGE_NAMES = _helpers._EDGE_NAMES +_EdgeReviewDecision = _helpers._EdgeReviewDecision +_full_image_candidate = _helpers._full_image_candidate +_map_review_crop_box_to_source_box = _helpers._map_review_crop_box_to_source_box +_merge_instruction_prompts = _helpers._merge_instruction_prompts +_normalize_azure_page_polygon = _helpers._normalize_azure_page_polygon +_PageBox = _helpers._PageBox +_PAGE_DETECTION_BOX_WIDTH = _helpers._PAGE_DETECTION_BOX_WIDTH +_PageDetectionTransform = _helpers._PageDetectionTransform +_parse_page_boxes_json = _helpers._parse_page_boxes_json +_parse_single_edge_review_decision_json = _helpers._parse_single_edge_review_decision_json +_parse_text_block_box_json = _helpers._parse_text_block_box_json +_parse_text_block_edge_review_decision_json = _helpers._parse_text_block_edge_review_decision_json +_prepare_detection_image = _helpers._prepare_detection_image +_provider_error = _helpers._provider_error +_strip_code_fence = _helpers._strip_code_fence +_TEXT_BLOCK_DETECTION_BOX_WIDTH = _helpers._TEXT_BLOCK_DETECTION_BOX_WIDTH +_TEXT_BLOCK_REVIEW_CROP_MARGIN_FRACTION = _helpers._TEXT_BLOCK_REVIEW_CROP_MARGIN_FRACTION +_type_error = _helpers._type_error +_value_error = _helpers._value_error +_apply_box_review_decision = _review._apply_box_review_decision +_apply_edge_decision_to_coordinate = _review._apply_edge_decision_to_coordinate +_apply_page_review_stop_condition = _review._apply_page_review_stop_condition +_convert_strip_delta_to_local_delta = _review._convert_strip_delta_to_local_delta +_is_oscillating_magnitude = _review._is_oscillating_magnitude +_log_box_history = _review._log_box_history +_new_page_review_stop_state = _review._new_page_review_stop_state +_no_change_edge_review_decision = _review._no_change_edge_review_decision +_page_review_is_fully_frozen = _review._page_review_is_fully_frozen +_select_more_expansive_oscillation_coordinate = _review._select_more_expansive_oscillation_coordinate +_strip_axis_size_pixels = _review._strip_axis_size_pixels LiteLLMTransportLike = LiteLLMTransportConfig | LiteLLMTransport | None -class _AzurePageLike(Protocol): - polygon: object - width: object - height: object - page_number: object - unit: object - angle: object - - -class _AzureAnalyzeResultLike(Protocol): - pages: Sequence[_AzurePageLike] | None - - -def _configuration_error(message: str) -> ConfigurationError: - return ConfigurationError(message) - - -def _provider_error(message: str) -> ProviderError: - return ProviderError(message) - - -def _type_error(message: str) -> TypeError: - return TypeError(message) - - -def _value_error(message: str) -> ValueError: - return ValueError(message) - - -def _full_image_candidate(image: Image.Image) -> PageCandidate: - return PageCandidate(bbox=(0.0, 0.0, float(image.width), float(image.height))) - - -def _bbox_from_polygon( - polygon: Polygon, -) -> BoundingBox: - xs = [point[0] for point in polygon] - ys = [point[1] for point in polygon] - return (min(xs), min(ys), max(xs), max(ys)) - - -def _normalize_polygon( - coordinates: Sequence[float] | None, -) -> Polygon: - if not coordinates or len(coordinates) < 6: - return () - pairs = [ - (float(coordinates[index]), float(coordinates[index + 1])) - for index in range(0, len(coordinates) - 1, 2) - ] - if len(pairs) > 1 and pairs[0] == pairs[-1]: - pairs.pop() - return tuple(pairs) - - -def _clamp_normalized(value: float) -> int: - clamped = max(_NORMALIZED_MIN_COORD, min(_NORMALIZED_MAX_COORD, value)) - rounded = round(clamped) - return max(0, min(1000, rounded)) - - -@dataclass(slots=True) -class _PageDetectionTransform: - original_size: tuple[int, int] - border: tuple[int, int] - padded_size: tuple[int, int] - processed_size: tuple[int, int] - scale_x: float - scale_y: float - - def map_box_to_original(self, box: _PageBox) -> BoundingBox: - processed_width, processed_height = self.processed_size - original_width, original_height = self.original_size - border_width, border_height = self.border - - left_processed, top_processed, right_processed, bottom_processed = box.denormalize( - processed_width, - processed_height, - ) - left_padded = left_processed / (self.scale_x or 1.0) - top_padded = top_processed / (self.scale_y or 1.0) - right_padded = right_processed / (self.scale_x or 1.0) - bottom_padded = bottom_processed / (self.scale_y or 1.0) - - left_original = max(0.0, min(original_width, left_padded - border_width)) - top_original = max(0.0, min(original_height, top_padded - border_height)) - right_original = max(0.0, min(original_width, right_padded - border_width)) - bottom_original = max(0.0, min(original_height, bottom_padded - border_height)) - return left_original, top_original, right_original, bottom_original - - -@dataclass(slots=True) -class _PageBox: - page_index: int - ymin: int - xmin: int - ymax: int - xmax: int - - @classmethod - def from_json(cls, payload: dict[str, Any]) -> _PageBox: - if "page_index" not in payload: - message = "Expected 'page_index' key in page-detection response." - raise _value_error(message) - required_keys = {"left", "top", "right", "bottom"} - if not required_keys.issubset(payload): - missing = required_keys - set(payload) - message = ( - f"Page-detection response must include keys {sorted(required_keys)}, " - f"missing {sorted(missing)}." - ) - raise _value_error(message) - return cls( - page_index=int(payload["page_index"]), - ymin=_clamp_normalized(float(payload["top"])), - xmin=_clamp_normalized(float(payload["left"])), - ymax=_clamp_normalized(float(payload["bottom"])), - xmax=_clamp_normalized(float(payload["right"])), - ) - - def denormalize(self, width: int, height: int) -> tuple[int, int, int, int]: - top = max(0, min(height, round(self.ymin * height / 1000))) - left = max(0, min(width, round(self.xmin * width / 1000))) - bottom = max(0, min(height, round(self.ymax * height / 1000))) - right = max(0, min(width, round(self.xmax * width / 1000))) - return left, top, right, bottom - - -EdgeDecisionAction = Literal["expand", "shrink", "no_change"] - - -@dataclass(slots=True, frozen=True) -class _EdgeReviewDecision: - action: EdgeDecisionAction - amount: int - - -@dataclass(slots=True, frozen=True) -class _BoxReviewDecision: - page_index: int - left: _EdgeReviewDecision - top: _EdgeReviewDecision - right: _EdgeReviewDecision - bottom: _EdgeReviewDecision - - -def _add_white_border( - image: Image.Image, - *, - fraction: float = _BORDER_FRACTION, -) -> tuple[Image.Image, int, int]: - if fraction <= 0: - return image, 0, 0 - border_width = max(1, round(image.width * fraction)) - border_height = max(1, round(image.height * fraction)) - expanded = ImageOps.expand( - image, - border=(border_width, border_height, border_width, border_height), - fill="white", - ) - return expanded, border_width, border_height - - -def _resize_image_to_fit(image: Image.Image, *, max_dim: int = _PROCESSED_MAX_DIM) -> Image.Image: - width, height = image.size - longest_side = max(width, height) - if longest_side <= max_dim: - return image - scale = max_dim / longest_side - return image.resize((max(1, round(width * scale)), max(1, round(height * scale)))) - - -def _prepare_detection_image(image: Image.Image) -> tuple[Image.Image, _PageDetectionTransform]: - rgb_image = image.convert("RGB") - bordered, border_width, border_height = _add_white_border(rgb_image) - processed = _resize_image_to_fit(bordered) - transform = _PageDetectionTransform( - original_size=image.size, - border=(border_width, border_height), - padded_size=bordered.size, - processed_size=processed.size, - scale_x=processed.width / bordered.width if bordered.width else 1.0, - scale_y=processed.height / bordered.height if bordered.height else 1.0, - ) - return processed, transform - - -def _strip_code_fence(raw: str) -> str: - text = raw.strip() - if text.startswith("```"): - lines = text.splitlines() - if len(lines) >= 2: - lines = lines[1:] - if lines and lines[-1].startswith("```"): - lines = lines[:-1] - text = "\n".join(lines).strip() - return text - - -def _parse_page_boxes_json(output: str) -> list[_PageBox]: - response_text = _strip_code_fence(output) - try: - payload = json.loads(response_text) - except json.JSONDecodeError as exc: - message = "LLM page detection returned invalid JSON." - raise _provider_error(message) from exc - - if not isinstance(payload, dict): - message = "LLM page detection response must be a JSON object." - raise _provider_error(message) - - pages = payload.get("pages") - if not isinstance(pages, list): - message = "LLM page detection response must include a `pages` list." - raise _provider_error(message) - - boxes: list[_PageBox] = [] - for page_index, page in enumerate(pages): - if not isinstance(page, dict): - message = f"LLM page detection entry {page_index} must be an object." - raise _provider_error(message) - try: - boxes.append(_PageBox.from_json(cast("dict[str, Any]", page))) - except (TypeError, ValueError) as exc: - message = f"LLM page detection entry {page_index} is invalid: {exc}" - raise _provider_error(message) from exc - return sorted(boxes, key=lambda box: box.page_index) - - -def _build_target_box_from_payload(payload: dict[str, Any], *, target_index: int) -> _PageBox: - return _PageBox.from_json( - { - "page_index": target_index, - "left": payload["left"], - "top": payload["top"], - "right": payload["right"], - "bottom": payload["bottom"], - } - ) - - -def _parse_target_box_json( - output: str, - *, - target_key: str, - found_key: str, - error_context: str, -) -> _PageBox | None: - response_text = _strip_code_fence(output) - try: - payload = json.loads(response_text) - except json.JSONDecodeError as exc: - message = f"{error_context} returned invalid JSON." - raise _provider_error(message) from exc - - if not isinstance(payload, dict): - message = f"{error_context} response must be a JSON object." - raise _provider_error(message) - payload_dict = cast("dict[str, Any]", payload) - - if {"left", "top", "right", "bottom"}.issubset(payload_dict): - try: - return _build_target_box_from_payload(payload_dict, target_index=1) - except (TypeError, ValueError) as exc: - message = f"{error_context} bbox is invalid: {exc}" - raise _provider_error(message) from exc - - raw_target = payload_dict.get(target_key) - if raw_target is None: - raw_target = payload_dict.get("bbox") - if isinstance(raw_target, dict): - try: - return _build_target_box_from_payload(cast("dict[str, Any]", raw_target), target_index=1) - except (TypeError, ValueError) as exc: - message = f"{error_context} bbox is invalid: {exc}" - raise _provider_error(message) from exc - if raw_target is not None: - message = f"{error_context} response `{target_key}` must be an object or null." - raise _provider_error(message) - - if ( - payload_dict.get(found_key) is False - or payload_dict.get("found") is False - or (target_key in payload_dict and payload_dict[target_key] is None) - or ("bbox" in payload_dict and payload_dict["bbox"] is None) - ): - return None - - message = ( - f"{error_context} response must include a `{target_key}` object " - f"or explicitly mark `{found_key}` false." - ) - raise _provider_error(message) - - -def _parse_text_block_box_json(output: str) -> _PageBox | None: - return _parse_target_box_json( - output, - target_key="block", - found_key="block_found", - error_context="LLM text-block localization", - ) - - -def _parse_edge_review_decision( - payload: object, - *, - edge_name: str, -) -> _EdgeReviewDecision: - if not isinstance(payload, dict): - message = f"Review edge '{edge_name}' must be an object." - raise _type_error(message) - payload_dict = cast("dict[str, object]", payload) - - raw_action = payload_dict.get("action") - if raw_action is None: - raw_action = payload_dict.get("decision") - if not isinstance(raw_action, str): - message = f"Review edge '{edge_name}' must include string 'action'." - raise _type_error(message) - action = raw_action.strip().lower() - if action not in {"expand", "shrink", "no_change"}: - message = f"Review edge '{edge_name}' action must be one of 'expand', 'shrink', 'no_change'." - raise _value_error(message) - action_literal = cast("EdgeDecisionAction", action) - - try: - raw_amount = payload_dict.get("amount") - amount = 0 if raw_amount is None else round(float(cast("Any", raw_amount))) - except (TypeError, ValueError) as exc: - message = f"Review edge '{edge_name}' amount must be numeric." - raise _value_error(message) from exc - amount = max(0, min(1000, amount)) - if action_literal == "no_change": - amount = 0 - return _EdgeReviewDecision(action=action_literal, amount=amount) - - -def _parse_single_edge_review_decision_json( - output: str, -) -> tuple[int, str, _EdgeReviewDecision]: - try: - payload = json.loads(_strip_code_fence(output)) - except json.JSONDecodeError as exc: - message = f"Failed to decode edge-review response as JSON: {exc}" - raise _value_error(message) from exc - - if not isinstance(payload, dict): - message = "Edge-review response must be a JSON object." - raise _type_error(message) - if "page_index" not in payload: - message = "Edge-review response must include 'page_index'." - raise _value_error(message) - - raw_edge = payload.get("edge") - if not isinstance(raw_edge, str): - message = "Edge-review response must include string 'edge'." - raise _type_error(message) - edge_name = raw_edge.strip().lower() - if edge_name not in _EDGE_NAMES: - message = "Edge-review response 'edge' must be left/top/right/bottom." - raise _value_error(message) - - decision_payload = payload.get("decision") - if not isinstance(decision_payload, dict): - decision_payload = { - "action": payload.get("action"), - "amount": payload.get("amount", 0), - } - - return ( - int(payload["page_index"]), - edge_name, - _parse_edge_review_decision( - decision_payload, - edge_name=edge_name, - ), - ) - - -def _parse_text_block_edge_review_decision_json( - output: str, -) -> tuple[str, _EdgeReviewDecision]: - try: - payload = json.loads(_strip_code_fence(output)) - except json.JSONDecodeError as exc: - message = f"Failed to decode text-block edge-review response as JSON: {exc}" - raise _value_error(message) from exc - - if not isinstance(payload, dict): - message = "Text-block edge-review response must be a JSON object." - raise _type_error(message) - payload_dict = cast("dict[str, object]", payload) - - raw_edge = payload_dict.get("edge") - if not isinstance(raw_edge, str): - message = "Text-block edge-review response must include string 'edge'." - raise _type_error(message) - edge_name = raw_edge.strip().lower() - if edge_name not in _EDGE_NAMES: - message = "Text-block edge-review response 'edge' must be left/top/right/bottom." - raise _value_error(message) - - decision_payload = payload_dict.get("decision") - if not isinstance(decision_payload, dict): - decision_payload = { - "action": payload_dict.get("action"), - "amount": payload_dict.get("amount", 0), - } - return edge_name, _parse_edge_review_decision(decision_payload, edge_name=edge_name) - - -def _boxes_equal(left_boxes: Sequence[_PageBox], right_boxes: Sequence[_PageBox]) -> bool: - if len(left_boxes) != len(right_boxes): - return False - for left_box, right_box in zip(left_boxes, right_boxes, strict=False): - if ( - left_box.page_index != right_box.page_index - or left_box.xmin != right_box.xmin - or left_box.ymin != right_box.ymin - or left_box.xmax != right_box.xmax - or left_box.ymax != right_box.ymax - ): - return False - return True - - -def _bbox_to_polygon( - bbox: BoundingBox, -) -> Polygon: - left, top, right, bottom = bbox - return ((left, top), (right, top), (right, bottom), (left, bottom)) - - -def _normalize_pixel_coord(value: int, size: int) -> int: - if size <= 0: - return 0 - return max(0, min(1000, round(value * 1000 / size))) - - -def _build_box_review_preview( - image: Image.Image, - box: _PageBox, - *, - margin_fraction: float = _REVIEW_CROP_MARGIN_FRACTION, - outline_width: int = _PAGE_DETECTION_BOX_WIDTH, -) -> tuple[Image.Image, tuple[int, int, int, int]]: - width, height = image.size - left, top, right, bottom = box.denormalize(width, height) - - box_width = max(1, right - left) - box_height = max(1, bottom - top) - margin_x = max(outline_width * 2, round(box_width * margin_fraction)) - margin_y = max(outline_width * 2, round(box_height * margin_fraction)) - - crop_left = max(0, left - margin_x) - crop_top = max(0, top - margin_y) - crop_right = min(width, right + margin_x) - crop_bottom = min(height, bottom + margin_y) - - crop = image.crop((crop_left, crop_top, crop_right, crop_bottom)) - preview = crop.copy() - draw = ImageDraw.Draw(preview) - draw.rectangle( - [left - crop_left, top - crop_top, right - crop_left, bottom - crop_top], - outline=_GUIDELINE_COLOR, - width=outline_width, - ) - return preview, (crop_left, crop_top, crop_right, crop_bottom) - - -def _build_edge_strip_review_preview( - image: Image.Image, - box: _PageBox, - edge_name: str, - *, - outline_width: int = _PAGE_DETECTION_BOX_WIDTH, -) -> tuple[Image.Image, tuple[int, int, int, int]]: - width, height = image.size - left, top, right, bottom = box.denormalize(width, height) - box_width = max(1, right - left) - box_height = max(1, bottom - top) - - band_half_x = max(outline_width * 3, round(box_width * 0.18)) - band_half_y = max(outline_width * 3, round(box_height * 0.18)) - orthogonal_pad_x = max(outline_width * 2, round(box_width * 0.06)) - orthogonal_pad_y = max(outline_width * 2, round(box_height * 0.06)) - - if edge_name == "left": - x0 = max(0, left - band_half_x) - x1 = min(width, left + band_half_x) - y0 = max(0, top - orthogonal_pad_y) - y1 = min(height, bottom + orthogonal_pad_y) - elif edge_name == "right": - x0 = max(0, right - band_half_x) - x1 = min(width, right + band_half_x) - y0 = max(0, top - orthogonal_pad_y) - y1 = min(height, bottom + orthogonal_pad_y) - elif edge_name == "top": - x0 = max(0, left - orthogonal_pad_x) - x1 = min(width, right + orthogonal_pad_x) - y0 = max(0, top - band_half_y) - y1 = min(height, top + band_half_y) - elif edge_name == "bottom": - x0 = max(0, left - orthogonal_pad_x) - x1 = min(width, right + orthogonal_pad_x) - y0 = max(0, bottom - band_half_y) - y1 = min(height, bottom + band_half_y) - else: - message = f"Unsupported edge '{edge_name}'. Expected left/top/right/bottom." - raise _value_error(message) - - if x0 >= x1 or y0 >= y1: - message = f"Invalid strip bounds for edge '{edge_name}'." - raise _value_error(message) - return image.crop((x0, y0, x1, y1)), (x0, y0, x1, y1) - - -def _convert_source_box_to_review_crop_box( - box: _PageBox, - crop_bounds: tuple[int, int, int, int], - source_size: tuple[int, int], -) -> _PageBox: - source_width, source_height = source_size - crop_left, crop_top, crop_right, crop_bottom = crop_bounds - crop_width = max(1, crop_right - crop_left) - crop_height = max(1, crop_bottom - crop_top) - left, top, right, bottom = box.denormalize(source_width, source_height) - return _PageBox.from_json( - { - "page_index": box.page_index, - "left": _normalize_pixel_coord(max(0, min(crop_width, left - crop_left)), crop_width), - "top": _normalize_pixel_coord(max(0, min(crop_height, top - crop_top)), crop_height), - "right": _normalize_pixel_coord( - max(0, min(crop_width, right - crop_left)), - crop_width, - ), - "bottom": _normalize_pixel_coord( - max(0, min(crop_height, bottom - crop_top)), - crop_height, - ), - } - ) - - -def _map_review_crop_box_to_source_box( - reviewed_box: _PageBox, - crop_bounds: tuple[int, int, int, int], - source_size: tuple[int, int], - *, - page_index: int, -) -> _PageBox: - source_width, source_height = source_size - crop_left, crop_top, crop_right, crop_bottom = crop_bounds - crop_width = max(1, crop_right - crop_left) - crop_height = max(1, crop_bottom - crop_top) - local_left, local_top, local_right, local_bottom = reviewed_box.denormalize( - crop_width, - crop_height, - ) - return _PageBox.from_json( - { - "page_index": page_index, - "left": _normalize_pixel_coord( - max(0, min(source_width, crop_left + local_left)), - source_width, - ), - "top": _normalize_pixel_coord( - max(0, min(source_height, crop_top + local_top)), - source_height, - ), - "right": _normalize_pixel_coord( - max(0, min(source_width, crop_left + local_right)), - source_width, - ), - "bottom": _normalize_pixel_coord( - max(0, min(source_height, crop_top + local_bottom)), - source_height, - ), - } - ) - - -def _merge_instruction_prompts(*parts: str | None) -> str: - """Merge one or more instruction strings into a single non-empty user prompt.""" - merged_parts = [part.strip() for part in parts if isinstance(part, str) and part.strip()] - if not merged_parts: - message = "Expected at least one non-empty instruction prompt." - raise _value_error(message) - return "\n\n".join(merged_parts) - - async def _complete_page_boxes( *, model: str, @@ -1314,280 +747,6 @@ async def _analyze_document() -> _AzureAnalyzeResultLike: return candidates or [_full_image_candidate(image)] -def _normalize_azure_page_polygon( - page: _AzurePageLike, *, image: Image.Image -) -> Polygon: - raw_polygon = getattr(page, "polygon", None) - polygon = _normalize_polygon(raw_polygon) - if not polygon: - return () - - page_width = float(getattr(page, "width", 0.0) or image.width) - page_height = float(getattr(page, "height", 0.0) or image.height) - scale_x = image.width / page_width if page_width else 1.0 - scale_y = image.height / page_height if page_height else 1.0 - return tuple((x * scale_x, y * scale_y) for x, y in polygon) - - -def _apply_box_review_decision( - current_box: _PageBox, - decision: _BoxReviewDecision, - *, - expected_page_index: int, -) -> _PageBox: - page_index = expected_page_index - if decision.page_index != expected_page_index: - logger.info( - "Review decision page_index mismatch (expected=%s, got=%s); using expected.", - expected_page_index, - decision.page_index, - ) - - left = _apply_edge_decision_to_coordinate(current_box.xmin, decision.left, is_min_edge=True) - top = _apply_edge_decision_to_coordinate(current_box.ymin, decision.top, is_min_edge=True) - right = _apply_edge_decision_to_coordinate(current_box.xmax, decision.right, is_min_edge=False) - bottom = _apply_edge_decision_to_coordinate( - current_box.ymax, - decision.bottom, - is_min_edge=False, - ) - - min_span = 1 - if left >= right: - center = (left + right) // 2 - left = max(0, center - min_span) - right = min(1000, center + min_span) - if top >= bottom: - center = (top + bottom) // 2 - top = max(0, center - min_span) - bottom = min(1000, center + min_span) - - return _PageBox.from_json( - { - "page_index": page_index, - "left": left, - "top": top, - "right": right, - "bottom": bottom, - } - ) - - -def _no_change_edge_review_decision() -> _EdgeReviewDecision: - return _EdgeReviewDecision(action="no_change", amount=0) - - -def _new_page_review_stop_state() -> dict[str, dict[str, int | bool | None]]: - return { - edge_name: { - "frozen": False, - "stable_rounds": 0, - "last_sign": None, - "last_mag": None, - } - for edge_name in _EDGE_NAMES - } - - -def _page_review_is_fully_frozen(page_state: dict[str, dict[str, int | bool | None]]) -> bool: - return all(bool(page_state[edge_name]["frozen"]) for edge_name in _EDGE_NAMES) - - -def _apply_page_review_stop_condition( - *, - prior_box: _PageBox, - reviewed_box: _PageBox, - page_state: dict[str, dict[str, int | bool | None]], - round_index: int, - subject_name: str = "page", -) -> _PageBox: - prior_coords = _box_to_edge_coords(prior_box) - reviewed_coords = _box_to_edge_coords(reviewed_box) - final_coords = dict(reviewed_coords) - - for edge_name in _EDGE_NAMES: - edge_state = page_state[edge_name] - prior_value = prior_coords[edge_name] - candidate_value = reviewed_coords[edge_name] - delta = candidate_value - prior_value - magnitude = abs(delta) - - if bool(edge_state["frozen"]): - final_coords[edge_name] = prior_value - continue - - if magnitude <= _REVIEW_EDGE_STOP_DEADBAND: - final_coords[edge_name] = prior_value - edge_state["stable_rounds"] = int(edge_state["stable_rounds"] or 0) + 1 - if int(edge_state["stable_rounds"]) >= _REVIEW_EDGE_STOP_STABLE_ROUNDS: - edge_state["frozen"] = True - logger.info( - "Freezing %s %s edge %s after %s stable round(s) (deadband <= %s).", - subject_name, - prior_box.page_index, - edge_name, - edge_state["stable_rounds"], - _REVIEW_EDGE_STOP_DEADBAND, - ) - continue - - edge_state["stable_rounds"] = 0 - sign = 1 if delta > 0 else -1 - previous_sign = edge_state["last_sign"] - previous_magnitude = edge_state["last_mag"] - if ( - isinstance(previous_sign, int) - and previous_sign != 0 - and previous_sign != sign - and isinstance(previous_magnitude, int) - and previous_magnitude > _REVIEW_EDGE_STOP_DEADBAND - and _is_oscillating_magnitude(magnitude, previous_magnitude) - ): - final_coords[edge_name] = _select_more_expansive_oscillation_coordinate( - edge_name=edge_name, - prior_value=prior_value, - candidate_value=candidate_value, - ) - edge_state["frozen"] = True - logger.info( - "Freezing %s %s edge %s on round %s due to oscillation (prev=%s, current=%s, final=%s).", - subject_name, - prior_box.page_index, - edge_name, - round_index, - previous_magnitude, - magnitude, - final_coords[edge_name], - ) - continue - - edge_state["last_sign"] = sign - edge_state["last_mag"] = magnitude - - return _build_page_box_from_edge_coords(prior_box.page_index, final_coords) - - -def _is_oscillating_magnitude(current_magnitude: int, previous_magnitude: int) -> bool: - if current_magnitude <= 0 or previous_magnitude <= 0: - return False - ratio = current_magnitude / previous_magnitude if previous_magnitude else 0.0 - return ( - _REVIEW_EDGE_STOP_OSCILLATION_MAGNITUDE_RATIO_MIN - <= ratio - <= _REVIEW_EDGE_STOP_OSCILLATION_MAGNITUDE_RATIO_MAX - ) - - -def _select_more_expansive_oscillation_coordinate( - *, - edge_name: str, - prior_value: int, - candidate_value: int, -) -> int: - if edge_name in {"left", "top"}: - return min(prior_value, candidate_value) - return max(prior_value, candidate_value) - - -def _box_to_edge_coords(box: _PageBox) -> dict[str, int]: - return { - "left": box.xmin, - "top": box.ymin, - "right": box.xmax, - "bottom": box.ymax, - } - - -def _build_page_box_from_edge_coords(page_index: int, coords: dict[str, int]) -> _PageBox: - left = int(coords["left"]) - top = int(coords["top"]) - right = int(coords["right"]) - bottom = int(coords["bottom"]) - - min_span = 1 - if left >= right: - center = (left + right) // 2 - left = max(0, center - min_span) - right = min(1000, center + min_span) - if top >= bottom: - center = (top + bottom) // 2 - top = max(0, center - min_span) - bottom = min(1000, center + min_span) - - return _PageBox.from_json( - { - "page_index": page_index, - "left": left, - "top": top, - "right": right, - "bottom": bottom, - } - ) - - -def _strip_axis_size_pixels( - strip_bounds: tuple[int, int, int, int], - *, - edge_name: str, -) -> int: - x0, y0, x1, y1 = strip_bounds - return (x1 - x0) if edge_name in {"left", "right"} else (y1 - y0) - - -def _convert_strip_delta_to_local_delta( - strip_delta_normalized: int, - *, - strip_axis_pixels: int, - local_axis_pixels: int, -) -> int: - if strip_delta_normalized <= 0 or strip_axis_pixels <= 0 or local_axis_pixels <= 0: - return 0 - delta_pixels = strip_delta_normalized * strip_axis_pixels / 1000 - local_delta = round(delta_pixels * 1000 / local_axis_pixels) - return max(0, min(1000, local_delta)) - - -def _apply_edge_decision_to_coordinate( - current_value: int, - decision: _EdgeReviewDecision, - *, - is_min_edge: bool, -) -> int: - if decision.action == "no_change" or decision.amount <= 0: - return current_value - if decision.action == "expand": - return current_value - decision.amount if is_min_edge else current_value + decision.amount - if decision.action == "shrink": - return current_value + decision.amount if is_min_edge else current_value - decision.amount - return current_value - - -def _log_box_history( - history_boxes: Sequence[Sequence[_PageBox]], - *, - subject_name: str = "Page", -) -> None: - per_page_history: dict[int, dict[str, list[int]]] = {} - for boxes in history_boxes: - for box in boxes: - page_history = per_page_history.setdefault( - box.page_index, - {"left": [], "top": [], "right": [], "bottom": []}, - ) - page_history["left"].append(box.xmin) - page_history["top"].append(box.ymin) - page_history["right"].append(box.xmax) - page_history["bottom"].append(box.ymax) - if not per_page_history: - return - label_width = max(len(key) for key in ("left", "top", "right", "bottom")) - for page_index in sorted(per_page_history): - logger.info("%s %s coordinate history:", subject_name, page_index) - page_history = per_page_history[page_index] - for key in ("left", "top", "right", "bottom"): - logger.info("%s: %s", key.ljust(label_width), " -> ".join(map(str, page_history[key]))) - - __all__ = [ "AzurePageDetector", "LLMPageDetector", From a1f431713d186fd14dbc613adacf82d806435d1e Mon Sep 17 00:00:00 2001 From: Sina Date: Mon, 13 Apr 2026 19:27:01 +0000 Subject: [PATCH 37/58] refactor(hf): split backend helper modules --- src/churro_ocr/providers/_hf_dots.py | 178 ++++++ src/churro_ocr/providers/_hf_helpers.py | 211 +++++++ src/churro_ocr/providers/_hf_mineru.py | 254 +++++++++ src/churro_ocr/providers/_hf_runtime.py | 203 +++++++ src/churro_ocr/providers/hf.py | 708 ++++-------------------- 5 files changed, 951 insertions(+), 603 deletions(-) create mode 100644 src/churro_ocr/providers/_hf_dots.py create mode 100644 src/churro_ocr/providers/_hf_helpers.py create mode 100644 src/churro_ocr/providers/_hf_mineru.py create mode 100644 src/churro_ocr/providers/_hf_runtime.py diff --git a/src/churro_ocr/providers/_hf_dots.py b/src/churro_ocr/providers/_hf_dots.py new file mode 100644 index 0000000..14d0dfa --- /dev/null +++ b/src/churro_ocr/providers/_hf_dots.py @@ -0,0 +1,178 @@ +"""Dots OCR helpers for Hugging Face OCR backends.""" + +from __future__ import annotations + +from types import MethodType +from typing import TYPE_CHECKING, Any, cast + +if TYPE_CHECKING: + from collections.abc import Callable + from pathlib import Path + +_DOTS_OCR_1_5_LOCAL_DIRNAME = "DotsOCR_1_5" +_DOTS_FLASH_ATTN_IMPORT = "from flash_attn import flash_attn_varlen_func" +_DOTS_FLASH_ATTN_FALLBACK = """try: + from flash_attn import flash_attn_varlen_func +except ImportError: + flash_attn_varlen_func = None +""" +_DOTS_FORCE_BFLOAT16_LINE = " hidden_states = hidden_states.bfloat16()" +_DOTS_WEIGHT_DTYPE_LINE = ( + " hidden_states = hidden_states.to(self.patch_embed.patchifier.proj.weight.dtype)" +) + + +def _patch_dots_ocr_vision_module(model_dir: Path) -> None: + vision_module_path = model_dir / "modeling_dots_vision.py" + vision_module = vision_module_path.read_text() + if _DOTS_FLASH_ATTN_IMPORT not in vision_module and _DOTS_FLASH_ATTN_FALLBACK in vision_module: + return + vision_lines = vision_module.splitlines() + import_index = next( + (index for index, line in enumerate(vision_lines) if _DOTS_FLASH_ATTN_IMPORT in line), + None, + ) + if import_index is None: + return + + block_tokens = {"", "try:", "except ImportError:", "flash_attn_varlen_func = None"} + block_start = import_index + while block_start > 0 and vision_lines[block_start - 1].strip() in block_tokens: + block_start -= 1 + + block_end = import_index + 1 + while block_end < len(vision_lines) and vision_lines[block_end].strip() in block_tokens: + block_end += 1 + + patched_lines = ( + vision_lines[:block_start] + + _DOTS_FLASH_ATTN_FALLBACK.rstrip("\n").splitlines() + + vision_lines[block_end:] + ) + patched_vision_module = "\n".join(patched_lines) + "\n" + if _DOTS_FORCE_BFLOAT16_LINE in patched_vision_module: + patched_vision_module = patched_vision_module.replace( + _DOTS_FORCE_BFLOAT16_LINE, + _DOTS_WEIGHT_DTYPE_LINE, + ) + vision_module_path.write_text(patched_vision_module) + + +def _prepare_dots_ocr_model_dir( + model_id: str, + *, + home_dir: Path, + patch_vision_module: Callable[[Path], None], + configuration_error: Callable[[str], Exception], + extra_install_hint: str, +) -> str: + try: + from huggingface_hub import snapshot_download + except ImportError as exc: # pragma: no cover - transitively provided by transformers + message = f"Hugging Face OCR requires the `hf` runtime. {extra_install_hint}" + raise configuration_error(message) from exc + + model_dir = ( + home_dir + / ".cache" + / "churro-ocr" + / "hf" + / _DOTS_OCR_1_5_LOCAL_DIRNAME + / model_id.replace("/", "__").replace(".", "_") + ) + snapshot_download(repo_id=model_id, local_dir=model_dir) + patch_vision_module(model_dir) + return str(model_dir) + + +def _resolve_base_prepare_inputs_for_generation( + model: object, + prepare_inputs_for_generation: Callable[..., object], +) -> Callable[..., object] | None: + original_prepare_inputs = getattr(prepare_inputs_for_generation, "__func__", None) + base_prepare_inputs_for_generation = prepare_inputs_for_generation + if original_prepare_inputs is not None: + for candidate in type(model).__mro__[1:]: + candidate_prepare_inputs = candidate.__dict__.get("prepare_inputs_for_generation") + if candidate_prepare_inputs is None or candidate_prepare_inputs is original_prepare_inputs: + continue + base_prepare_inputs_for_generation = cast("Any", candidate_prepare_inputs).__get__( + model, + type(model), + ) + break + return base_prepare_inputs_for_generation if callable(base_prepare_inputs_for_generation) else None + + +def _first_cache_position(cache_position: object) -> int | None: + if cache_position is None: + return None + try: + return int(cast("Any", cache_position)[0]) + except (IndexError, TypeError, ValueError): + return None + + +def _patch_dots_ocr_prepare_inputs_for_generation(model: object) -> None: + prepare_inputs_for_generation = getattr(model, "prepare_inputs_for_generation", None) + if not callable(prepare_inputs_for_generation): + return + if getattr(model, "_churro_dots_prepare_inputs_patched", False): + return + + base_prepare_inputs_for_generation = _resolve_base_prepare_inputs_for_generation( + model, + prepare_inputs_for_generation, + ) + if base_prepare_inputs_for_generation is None: + return + + def _patched_prepare_inputs_for_generation( + _self: object, + input_ids: object, + *, + pixel_values: object = None, + cache_position: object = None, + **kwargs: object, + ) -> dict[str, object]: + model_inputs = cast( + "dict[str, object]", + base_prepare_inputs_for_generation( + input_ids, + cache_position=cache_position, + **kwargs, + ), + ) + if _first_cache_position(cache_position) in {None, 0}: + model_inputs["pixel_values"] = pixel_values + return model_inputs + + model_any = cast("Any", model) + model_any.prepare_inputs_for_generation = MethodType(_patched_prepare_inputs_for_generation, model) + model_any._churro_dots_prepare_inputs_patched = True + + +def _default_dots_ocr_1_5_model_kwargs( + *, + load_torch_module: Callable[[], object], +) -> dict[str, object]: + model_kwargs: dict[str, object] = {"dtype": "auto"} + try: + torch = load_torch_module() + except ImportError: # pragma: no cover - torch is installed separately for local HF use + return model_kwargs + + if not cast("Any", torch).cuda.is_available(): + return model_kwargs + + try: + free_bytes, _ = cast("Any", torch).cuda.mem_get_info() + except RuntimeError: + return model_kwargs + free_gib = max(1, int(free_bytes / (1024**3)) - 1) + if free_gib < 8: + return {"dtype": "float32"} + + model_kwargs["device_map"] = "auto" + model_kwargs["max_memory"] = {0: f"{free_gib}GiB", "cpu": "128GiB"} + return model_kwargs diff --git a/src/churro_ocr/providers/_hf_helpers.py b/src/churro_ocr/providers/_hf_helpers.py new file mode 100644 index 0000000..ffc1b22 --- /dev/null +++ b/src/churro_ocr/providers/_hf_helpers.py @@ -0,0 +1,211 @@ +"""Generic decoding, prompt, and generation helpers for Hugging Face OCR backends.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, cast + +from churro_ocr.providers._hf_runtime import ( + _configuration_error, + _HFProcessorDecoder, + _load_torch_module, +) + +if TYPE_CHECKING: + from collections.abc import Mapping + + from churro_ocr.templates import OCRConversation + from churro_ocr.types import OCRConversationContentItem + + +def _default_chandra_ocr_2_model_kwargs() -> dict[str, object]: + model_kwargs: dict[str, object] = { + "device_map": "auto", + "dtype": "auto", + } + try: + torch = _load_torch_module() + except ImportError: # pragma: no cover - torch is installed separately for local HF use + return model_kwargs + + if torch.cuda.is_available(): + model_kwargs["dtype"] = torch.bfloat16 + return model_kwargs + + +def _default_mineru25_model_kwargs() -> dict[str, object]: + model_kwargs: dict[str, object] = {"device_map": "auto"} + dtype_key = "dtype" + transformers_version: str + try: + from transformers import __version__ as imported_transformers_version + + transformers_version = str(imported_transformers_version) + except ImportError: # pragma: no cover - transformers is installed via the hf runtime + transformers_version = "" + + version_parts = transformers_version.split(".") + if len(version_parts) >= 2: + try: + major = int(version_parts[0]) + minor = int(version_parts[1]) + except ValueError: + major = 0 + minor = 0 + if major < 4 or (major == 4 and minor < 56): + dtype_key = "torch_dtype" + model_kwargs[dtype_key] = "auto" + return model_kwargs + + +def _conversation_content_items(message: object) -> list[OCRConversationContentItem]: + return cast("list[OCRConversationContentItem]", cast("Mapping[str, object]", message)["content"]) + + +def _message_text_lines(content_items: list[OCRConversationContentItem]) -> list[str]: + return [ + cast("str", item["text"]).strip() + for item in content_items + if item.get("type") == "text" and isinstance(item.get("text"), str) + ] + + +def _message_text(content_items: list[OCRConversationContentItem]) -> str: + return "\n".join(_message_text_lines(content_items)).strip() + + +def _has_image_content(content_items: list[OCRConversationContentItem]) -> bool: + return any(item.get("type") == "image" for item in content_items) + + +def _deepseek_ocr_2_prompt_from_conversation(conversation: OCRConversation) -> str: + prompt_lines: list[str] = [] + has_image = False + for message in conversation: + role = message.get("role") + content_items = _conversation_content_items(message) + if role == "system": + system_text = _message_text(content_items) + if system_text: + message = "DeepSeek-OCR-2 does not support system prompts in the HF backend." + raise _configuration_error(message) + continue + if role != "user": + continue + has_image = has_image or _has_image_content(content_items) + prompt_lines.extend(text for text in _message_text_lines(content_items) if text) + prompt_text = "\n".join(prompt_lines).strip() + if not prompt_text: + message = "DeepSeek-OCR-2 requires a non-empty OCR prompt." + raise _configuration_error(message) + if has_image: + return f"\n{prompt_text}" + return prompt_text + + +def _move_batch_to_model(batch: dict[str, object], model: object) -> dict[str, object]: + model_device = getattr(model, "device", None) + if hasattr(batch, "to") and model_device is not None: + batch = cast("dict[str, object]", cast("Any", batch).to(model_device)) + model_dtype = getattr(model, "dtype", None) + if model_dtype is not None: + for key, value in batch.items(): + if hasattr(value, "dtype") and getattr(value.dtype, "is_floating_point", False): + batch[key] = cast("Any", value).to(dtype=model_dtype) + return batch + + +def _decode_completion_texts( + processor: object, + batch: Mapping[str, object], + generated_ids: object, +) -> list[str]: + return _decode_completion_texts_with_options( + processor, + batch, + generated_ids, + skip_special_tokens=True, + ) + + +def _completion_ids_from_generated_ids(batch: Mapping[str, object], generated_ids: object) -> object: + attention_mask = batch.get("attention_mask") + if attention_mask is not None and hasattr(attention_mask, "sum"): + prompt_lengths = cast("Any", attention_mask).sum(dim=1).tolist() + return [ + output_ids[int(prompt_length) :] + for prompt_length, output_ids in zip(prompt_lengths, cast("Any", generated_ids), strict=True) + ] + prompt_length = cast("Any", batch["input_ids"]).shape[1] + return cast("Any", generated_ids)[:, prompt_length:] + + +def _decode_completion_texts_with_options( + processor: object, + batch: Mapping[str, object], + generated_ids: object, + *, + skip_special_tokens: bool, +) -> list[str]: + completion_ids = _completion_ids_from_generated_ids(batch, generated_ids) + return cast("_HFProcessorDecoder", processor).batch_decode( + completion_ids, + skip_special_tokens=skip_special_tokens, + clean_up_tokenization_spaces=False, + ) + + +def _resolve_model_max_length(model: object) -> int | None: + config = getattr(model, "config", None) + max_length = getattr(config, "max_position_embeddings", None) + if isinstance(max_length, int): + return max_length + text_config = getattr(config, "text_config", None) + text_max_length = getattr(text_config, "max_position_embeddings", None) + if isinstance(text_max_length, int): + return text_max_length + return None + + +_MINERU25_STEP_ALIASES = { + "[layout]": "layout", + "table": "table", + "equation": "equation", + "image": "image", + "chart": "chart", +} +_MINERU25_SAMPLING_FIELD_NAMES = ( + "temperature", + "top_p", + "top_k", + "presence_penalty", + "frequency_penalty", + "repetition_penalty", + "no_repeat_ngram_size", + "max_new_tokens", +) +_MINERU25_SCOPED_PREFIXES = ( + "layout_", + "table_", + "equation_", + "image_", + "chart_", + "default_", +) + + +def _paddleocr_vl_processor_kwargs(*, processor: object, padding: bool) -> dict[str, object]: + processor_kwargs: dict[str, object] = { + "text_kwargs": { + "padding": padding, + "return_mm_token_type_ids": True, + } + } + image_processor = getattr(processor, "image_processor", None) + images_kwargs: dict[str, int] = {} + for key in ("min_pixels", "max_pixels"): + value = getattr(image_processor, key, None) + if isinstance(value, int): + images_kwargs[key] = value + if images_kwargs: + processor_kwargs["images_kwargs"] = images_kwargs + return processor_kwargs diff --git a/src/churro_ocr/providers/_hf_mineru.py b/src/churro_ocr/providers/_hf_mineru.py new file mode 100644 index 0000000..488a18f --- /dev/null +++ b/src/churro_ocr/providers/_hf_mineru.py @@ -0,0 +1,254 @@ +"""MinerU2.5 helpers for Hugging Face OCR backends.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from churro_ocr.providers._hf_helpers import ( + _MINERU25_SAMPLING_FIELD_NAMES, + _MINERU25_SCOPED_PREFIXES, + _MINERU25_STEP_ALIASES, + _resolve_model_max_length, +) +from churro_ocr.providers._mineru25 import ( + MinerU25PipelineHelper, + MinerU25SamplingParams, + replace_sampling_param, +) +from churro_ocr.providers._shared import normalize_media_inputs +from churro_ocr.templates import ( + MINERU2_5_2509_1_2B_FORMULA_PROMPT, + MINERU2_5_2509_1_2B_IMAGE_ANALYSIS_PROMPT, + MINERU2_5_2509_1_2B_LAYOUT_PROMPT, + MINERU2_5_2509_1_2B_OCR_PROMPT, + MINERU2_5_2509_1_2B_SYSTEM_PROMPT, + MINERU2_5_2509_1_2B_TABLE_PROMPT, + OCRPromptTemplateLike, +) + +if TYPE_CHECKING: + from collections.abc import Callable, Mapping + + +@dataclass(slots=True, frozen=True) +class _MinerU25StepContext: + runtime: object + processor: object + model: object + batch_size: int + + +@dataclass(slots=True, frozen=True) +class _MinerU25Templates: + default_template: OCRPromptTemplateLike + layout_template: OCRPromptTemplateLike + table_template: OCRPromptTemplateLike + formula_template: OCRPromptTemplateLike + image_analysis_template: OCRPromptTemplateLike + + +def _default_mineru25_helper() -> MinerU25PipelineHelper: + return MinerU25PipelineHelper( + prompts={ + "[default]": MINERU2_5_2509_1_2B_OCR_PROMPT, + "[layout]": MINERU2_5_2509_1_2B_LAYOUT_PROMPT, + "table": MINERU2_5_2509_1_2B_TABLE_PROMPT, + "equation": MINERU2_5_2509_1_2B_FORMULA_PROMPT, + "image": MINERU2_5_2509_1_2B_IMAGE_ANALYSIS_PROMPT, + "chart": MINERU2_5_2509_1_2B_IMAGE_ANALYSIS_PROMPT, + }, + system_prompt=MINERU2_5_2509_1_2B_SYSTEM_PROMPT, + ) + + +def _template_for_step( + step_key: str, + templates: _MinerU25Templates, +) -> OCRPromptTemplateLike: + if step_key == "[layout]": + return templates.layout_template + if step_key == "table": + return templates.table_template + if step_key == "equation": + return templates.formula_template + if step_key in {"image", "chart"}: + return templates.image_analysis_template + return templates.default_template + + +def _resolve_rendered_prompt( + rendered: object, + *, + provider_error: Callable[[str], Exception], +) -> str: + if isinstance(rendered, tuple): + if not rendered: + message = "MinerU2.5 returned an empty chat template render." + raise provider_error(message) + rendered = rendered[0] + if not isinstance(rendered, str): + message = "MinerU2.5 chat template did not render text." + raise provider_error(message) + return rendered + + +def _resolve_sampling_override( + generation_kwargs: Mapping[str, object], + *, + effective_step: str, + field_name: str, +) -> float | int | None: + override_value: float | int | None = None + global_value = generation_kwargs.get(field_name) + if isinstance(global_value, (int, float)): + override_value = global_value + step_value = generation_kwargs.get(f"{effective_step}_{field_name}") + if isinstance(step_value, (int, float)): + override_value = step_value + return override_value + + +def _resolve_step_sampling( + *, + helper: MinerU25PipelineHelper, + generation_kwargs: Mapping[str, object], + step_key: str, +) -> MinerU25SamplingParams: + effective_step = _MINERU25_STEP_ALIASES.get(step_key, "default") + sampling = helper.sampling_for(step_key) + changes = { + field_name: override_value + for field_name in _MINERU25_SAMPLING_FIELD_NAMES + if ( + override_value := _resolve_sampling_override( + generation_kwargs, + effective_step=effective_step, + field_name=field_name, + ) + ) + is not None + } + return replace_sampling_param(sampling, **changes) if changes else sampling + + +def _scoped_generation_keys() -> frozenset[str]: + return frozenset( + (f"{prefix}{field_name}" if prefix else field_name) + for prefix in ("", *_MINERU25_SCOPED_PREFIXES) + for field_name in _MINERU25_SAMPLING_FIELD_NAMES + ) + + +_MINERU25_GENERATION_OVERRIDE_KEYS = _scoped_generation_keys() + + +def _should_sample(sampling: MinerU25SamplingParams) -> bool: + return ((sampling.temperature or 0.0) > 0.0) and ((sampling.top_k or 1) > 1) + + +def _set_generation_kwarg( + generation_kwargs: dict[str, object], + key: str, + value: object, + *, + enabled: bool = True, +) -> None: + if enabled and value is not None: + generation_kwargs[key] = value + + +def _resolve_generation_length( + *, + sampling: MinerU25SamplingParams, + generation_kwargs: Mapping[str, object], + model: object, +) -> tuple[str, int] | None: + if sampling.max_new_tokens is not None: + return ("max_new_tokens", sampling.max_new_tokens) + max_length = generation_kwargs.get("max_length", _resolve_model_max_length(model)) + if isinstance(max_length, str): + max_length = int(max_length) + if isinstance(max_length, int): + return ("max_length", max_length) + return None + + +def _extra_generation_kwargs(generation_kwargs: Mapping[str, object]) -> dict[str, object]: + extra_kwargs = dict(generation_kwargs) + extra_kwargs.pop("max_length", None) + for override_key in _MINERU25_GENERATION_OVERRIDE_KEYS: + extra_kwargs.pop(override_key, None) + return extra_kwargs + + +def _resolve_generation_kwargs( + *, + helper: MinerU25PipelineHelper, + generation_kwargs: Mapping[str, object], + step_key: str, + model: object, +) -> dict[str, object]: + sampling = _resolve_step_sampling( + helper=helper, + generation_kwargs=generation_kwargs, + step_key=step_key, + ) + do_sample = _should_sample(sampling) + resolved_generation_kwargs: dict[str, object] = {"do_sample": do_sample} + _set_generation_kwarg( + resolved_generation_kwargs, + "temperature", + sampling.temperature, + enabled=do_sample, + ) + _set_generation_kwarg( + resolved_generation_kwargs, + "top_p", + sampling.top_p, + enabled=do_sample, + ) + _set_generation_kwarg( + resolved_generation_kwargs, + "top_k", + sampling.top_k, + enabled=do_sample, + ) + _set_generation_kwarg( + resolved_generation_kwargs, + "repetition_penalty", + sampling.repetition_penalty, + ) + _set_generation_kwarg( + resolved_generation_kwargs, + "no_repeat_ngram_size", + sampling.no_repeat_ngram_size, + ) + generation_length = _resolve_generation_length( + sampling=sampling, + generation_kwargs=generation_kwargs, + model=model, + ) + if generation_length is not None: + key, value = generation_length + resolved_generation_kwargs[key] = value + resolved_generation_kwargs.update(_extra_generation_kwargs(generation_kwargs)) + return resolved_generation_kwargs + + +def _build_step_batch_kwargs( + *, + rendered_prompt: str, + image_inputs: object, + video_inputs: object, +) -> dict[str, object]: + batch_kwargs: dict[str, object] = { + "text": [rendered_prompt], + "images": normalize_media_inputs(image_inputs), + "return_tensors": "pt", + "padding": True, + } + normalized_video_inputs = normalize_media_inputs(video_inputs) + if normalized_video_inputs is not None: + batch_kwargs["videos"] = normalized_video_inputs + return batch_kwargs diff --git a/src/churro_ocr/providers/_hf_runtime.py b/src/churro_ocr/providers/_hf_runtime.py new file mode 100644 index 0000000..2c10bad --- /dev/null +++ b/src/churro_ocr/providers/_hf_runtime.py @@ -0,0 +1,203 @@ +"""Runtime loading and generic protocol helpers for Hugging Face OCR backends.""" + +from __future__ import annotations + +from dataclasses import dataclass +from importlib import import_module +from typing import TYPE_CHECKING, Any, Protocol, cast + +from churro_ocr._internal.install import install_command_hint +from churro_ocr.errors import ConfigurationError, ProviderError + +if TYPE_CHECKING: + from collections.abc import Callable + +_HF_EXTRA_INSTALL_HINT = install_command_hint("hf") +_HF_TORCH_INSTALL_HINT = ( + f"Hugging Face OCR requires a separately installed PyTorch runtime. {_HF_EXTRA_INSTALL_HINT}" +) + + +def _configuration_error(message: str) -> ConfigurationError: + return ConfigurationError(message) + + +def _provider_error(message: str) -> ProviderError: + return ProviderError(message) + + +@dataclass(slots=True) +class _HFRuntime: + processor_cls: Any + model_cls: Any + process_vision_info: Any + + +class _HFProcessorCallable(Protocol): + def __call__(self, **kwargs: object) -> object: ... + + +class _HFProcessorDecoder(Protocol): + def batch_decode( + self, + token_ids: object, + *, + skip_special_tokens: bool, + clean_up_tokenization_spaces: bool, + ) -> list[str]: ... + + +class _HFChatTemplateProcessor(Protocol): + def apply_chat_template( + self, + conversations: object, + **kwargs: object, + ) -> dict[str, object]: ... + + +class _HFGenerativeModel(Protocol): + def generate(self, **kwargs: object) -> object: ... + + +class _TorchCudaNamespace(Protocol): + def is_available(self) -> bool: ... + + def mem_get_info(self) -> tuple[int, int]: ... + + +class _TorchModuleLike(Protocol): + cuda: _TorchCudaNamespace + bfloat16: object + + +def _ensure_hf_torch_runtime() -> None: + _ensure_hf_torch_runtime_with_import(import_module) + + +def _ensure_hf_torch_runtime_with_import(import_module_fn: Callable[[str], object]) -> None: + try: + import_module_fn("torch") + except ImportError as exc: # pragma: no cover - optional extra path + raise ConfigurationError(_HF_TORCH_INSTALL_HINT) from exc + + +def _load_torch_module() -> _TorchModuleLike: + return _load_torch_module_with_import(import_module) + + +def _load_torch_module_with_import(import_module_fn: Callable[[str], object]) -> _TorchModuleLike: + return cast("_TorchModuleLike", import_module_fn("torch")) + + +def _call_processor(processor: object, **kwargs: object) -> dict[str, object]: + return cast("dict[str, object]", cast("_HFProcessorCallable", processor)(**kwargs)) + + +def _generate_with_model(model: object, **kwargs: object) -> object: + return cast("_HFGenerativeModel", model).generate(**kwargs) + + +def _apply_chat_template( + processor: object, + conversations: object, + **kwargs: object, +) -> dict[str, object]: + return cast("_HFChatTemplateProcessor", processor).apply_chat_template(conversations, **kwargs) + + +def _load_hf_runtime() -> _HFRuntime: + return _load_hf_runtime_with_import(import_module) + + +def _load_hf_runtime_with_import(import_module_fn: Callable[[str], object]) -> _HFRuntime: + _ensure_hf_torch_runtime_with_import(import_module_fn) + try: + from qwen_vl_utils import process_vision_info + from transformers import AutoModelForImageTextToText, AutoProcessor + except ImportError as exc: # pragma: no cover - optional extra path + message = f"Hugging Face OCR requires the `hf` runtime. {_HF_EXTRA_INSTALL_HINT}" + raise _configuration_error(message) from exc + + return _HFRuntime( + processor_cls=AutoProcessor, + model_cls=AutoModelForImageTextToText, + process_vision_info=process_vision_info, + ) + + +def _load_hf_causal_runtime() -> _HFRuntime: + return _load_hf_causal_runtime_with_import(import_module) + + +def _load_hf_causal_runtime_with_import(import_module_fn: Callable[[str], object]) -> _HFRuntime: + _ensure_hf_torch_runtime_with_import(import_module_fn) + try: + from qwen_vl_utils import process_vision_info + from transformers import AutoModelForCausalLM, AutoProcessor + except ImportError as exc: # pragma: no cover - optional extra path + message = f"Hugging Face OCR requires the `hf` runtime. {_HF_EXTRA_INSTALL_HINT}" + raise _configuration_error(message) from exc + + return _HFRuntime( + processor_cls=AutoProcessor, + model_cls=AutoModelForCausalLM, + process_vision_info=process_vision_info, + ) + + +def _load_hf_auto_model_runtime() -> _HFRuntime: + return _load_hf_auto_model_runtime_with_import(import_module) + + +def _load_hf_auto_model_runtime_with_import(import_module_fn: Callable[[str], object]) -> _HFRuntime: + _ensure_hf_torch_runtime_with_import(import_module_fn) + try: + from transformers import AutoModel, AutoTokenizer + except ImportError as exc: # pragma: no cover - optional extra path + message = f"Hugging Face OCR requires the `hf` runtime. {_HF_EXTRA_INSTALL_HINT}" + raise _configuration_error(message) from exc + + return _HFRuntime( + processor_cls=AutoTokenizer, + model_cls=AutoModel, + process_vision_info=None, + ) + + +def _load_hf_auto_processor_model_runtime() -> _HFRuntime: + return _load_hf_auto_processor_model_runtime_with_import(import_module) + + +def _load_hf_auto_processor_model_runtime_with_import( + import_module_fn: Callable[[str], object], +) -> _HFRuntime: + _ensure_hf_torch_runtime_with_import(import_module_fn) + try: + from transformers import AutoModel, AutoProcessor + except ImportError as exc: # pragma: no cover - optional extra path + message = f"Hugging Face OCR requires the `hf` runtime. {_HF_EXTRA_INSTALL_HINT}" + raise _configuration_error(message) from exc + + return _HFRuntime( + processor_cls=AutoProcessor, + model_cls=AutoModel, + process_vision_info=None, + ) + + +def _ensure_deepseek_ocr_2_cuda_runtime() -> _TorchModuleLike: + return _ensure_deepseek_ocr_2_cuda_runtime_with_import(import_module) + + +def _ensure_deepseek_ocr_2_cuda_runtime_with_import( + import_module_fn: Callable[[str], object], +) -> _TorchModuleLike: + _ensure_hf_torch_runtime_with_import(import_module_fn) + torch = _load_torch_module_with_import(import_module_fn) + if not torch.cuda.is_available(): + message = ( + "DeepSeek-OCR-2 HF backend requires a CUDA-capable PyTorch runtime because " + "the upstream `infer(...)` implementation moves inputs to CUDA." + ) + raise _configuration_error(message) + return torch diff --git a/src/churro_ocr/providers/hf.py b/src/churro_ocr/providers/hf.py index f154563..cc36b46 100644 --- a/src/churro_ocr/providers/hf.py +++ b/src/churro_ocr/providers/hf.py @@ -8,20 +8,16 @@ from importlib import import_module from pathlib import Path from tempfile import TemporaryDirectory -from types import MethodType -from typing import TYPE_CHECKING, Any, Protocol, cast +from typing import TYPE_CHECKING, Any, cast from churro_ocr._internal.image import ensure_rgb -from churro_ocr._internal.install import install_command_hint from churro_ocr._internal.prompt_logging import log_prompt_payload_once -from churro_ocr.errors import ConfigurationError, ProviderError from churro_ocr.ocr import OCRBackend, OCRResult from churro_ocr.page_detection import DocumentPage -from churro_ocr.providers._mineru25 import ( - MinerU25PipelineHelper, - MinerU25SamplingParams, - replace_sampling_param, -) +from churro_ocr.providers import _hf_dots as _dots +from churro_ocr.providers import _hf_helpers as _helpers +from churro_ocr.providers import _hf_mineru as _mineru +from churro_ocr.providers import _hf_runtime as _runtime from churro_ocr.providers._shared import ( build_ocr_result, normalize_media_inputs, @@ -49,17 +45,11 @@ DOTS_OCR_1_5_OCR_TEMPLATE, LFM2_5_VL_1_6B_MODEL_ID, LFM2_5_VL_1_6B_OCR_TEMPLATE, - MINERU2_5_2509_1_2B_FORMULA_PROMPT, MINERU2_5_2509_1_2B_FORMULA_TEMPLATE, - MINERU2_5_2509_1_2B_IMAGE_ANALYSIS_PROMPT, MINERU2_5_2509_1_2B_IMAGE_ANALYSIS_TEMPLATE, - MINERU2_5_2509_1_2B_LAYOUT_PROMPT, MINERU2_5_2509_1_2B_LAYOUT_TEMPLATE, MINERU2_5_2509_1_2B_MODEL_ID, - MINERU2_5_2509_1_2B_OCR_PROMPT, MINERU2_5_2509_1_2B_OCR_TEMPLATE, - MINERU2_5_2509_1_2B_SYSTEM_PROMPT, - MINERU2_5_2509_1_2B_TABLE_PROMPT, MINERU2_5_2509_1_2B_TABLE_TEMPLATE, PADDLEOCR_VL_1_5_MODEL_ID, PADDLEOCR_VL_1_5_OCR_TEMPLATE, @@ -69,504 +59,78 @@ ) if TYPE_CHECKING: - from collections.abc import Mapping - from PIL import Image - from churro_ocr.types import OCRConversationContentItem - -_HF_EXTRA_INSTALL_HINT = install_command_hint("hf") -_HF_TORCH_INSTALL_HINT = ( - f"Hugging Face OCR requires a separately installed PyTorch runtime. {_HF_EXTRA_INSTALL_HINT}" -) - - -def _configuration_error(message: str) -> ConfigurationError: - return ConfigurationError(message) - - -def _provider_error(message: str) -> ProviderError: - return ProviderError(message) - - -@dataclass(slots=True) -class _HFRuntime: - processor_cls: Any - model_cls: Any - process_vision_info: Any - - -class _HFProcessorCallable(Protocol): - def __call__(self, **kwargs: object) -> object: ... - - -class _HFProcessorDecoder(Protocol): - def batch_decode( - self, - token_ids: object, - *, - skip_special_tokens: bool, - clean_up_tokenization_spaces: bool, - ) -> list[str]: ... - - -class _HFChatTemplateProcessor(Protocol): - def apply_chat_template( - self, - conversations: object, - **kwargs: object, - ) -> dict[str, object]: ... - - -class _HFGenerativeModel(Protocol): - def generate(self, **kwargs: object) -> object: ... - - -class _TorchCudaNamespace(Protocol): - def is_available(self) -> bool: ... - - def mem_get_info(self) -> tuple[int, int]: ... - - -class _TorchModuleLike(Protocol): - cuda: _TorchCudaNamespace - bfloat16: object - + from churro_ocr.providers._mineru25 import MinerU25PipelineHelper -def _ensure_hf_torch_runtime() -> None: - try: - import_module("torch") - except ImportError as exc: # pragma: no cover - optional extra path - raise ConfigurationError(_HF_TORCH_INSTALL_HINT) from exc +_HF_EXTRA_INSTALL_HINT = _runtime._HF_EXTRA_INSTALL_HINT +_HFRuntime = _runtime._HFRuntime +_apply_chat_template = _runtime._apply_chat_template +_call_processor = _runtime._call_processor +_configuration_error = _runtime._configuration_error +_default_chandra_ocr_2_model_kwargs = _helpers._default_chandra_ocr_2_model_kwargs +_default_mineru25_model_kwargs = _helpers._default_mineru25_model_kwargs +_decode_completion_texts = _helpers._decode_completion_texts +_decode_completion_texts_with_options = _helpers._decode_completion_texts_with_options +_deepseek_ocr_2_prompt_from_conversation = _helpers._deepseek_ocr_2_prompt_from_conversation +_generate_with_model = _runtime._generate_with_model +_move_batch_to_model = _helpers._move_batch_to_model +_paddleocr_vl_processor_kwargs = _helpers._paddleocr_vl_processor_kwargs +_provider_error = _runtime._provider_error +_resolve_model_max_length = _helpers._resolve_model_max_length -def _load_torch_module() -> _TorchModuleLike: - return cast("_TorchModuleLike", import_module("torch")) - - -def _call_processor(processor: object, **kwargs: object) -> dict[str, object]: - return cast("dict[str, object]", cast("_HFProcessorCallable", processor)(**kwargs)) - - -def _generate_with_model(model: object, **kwargs: object) -> object: - return cast("_HFGenerativeModel", model).generate(**kwargs) - - -def _apply_chat_template( - processor: object, - conversations: object, - **kwargs: object, -) -> dict[str, object]: - return cast("_HFChatTemplateProcessor", processor).apply_chat_template(conversations, **kwargs) +def _load_torch_module() -> _runtime._TorchModuleLike: + return _runtime._load_torch_module_with_import(import_module) def _load_hf_runtime() -> _HFRuntime: - _ensure_hf_torch_runtime() - try: - from qwen_vl_utils import process_vision_info - from transformers import AutoModelForImageTextToText, AutoProcessor - except ImportError as exc: # pragma: no cover - optional extra path - message = f"Hugging Face OCR requires the `hf` runtime. {_HF_EXTRA_INSTALL_HINT}" - raise _configuration_error(message) from exc - - return _HFRuntime( - processor_cls=AutoProcessor, - model_cls=AutoModelForImageTextToText, - process_vision_info=process_vision_info, - ) + return _runtime._load_hf_runtime_with_import(import_module) def _load_hf_causal_runtime() -> _HFRuntime: - _ensure_hf_torch_runtime() - try: - from qwen_vl_utils import process_vision_info - from transformers import AutoModelForCausalLM, AutoProcessor - except ImportError as exc: # pragma: no cover - optional extra path - message = f"Hugging Face OCR requires the `hf` runtime. {_HF_EXTRA_INSTALL_HINT}" - raise _configuration_error(message) from exc - - return _HFRuntime( - processor_cls=AutoProcessor, - model_cls=AutoModelForCausalLM, - process_vision_info=process_vision_info, - ) + return _runtime._load_hf_causal_runtime_with_import(import_module) def _load_hf_auto_model_runtime() -> _HFRuntime: - _ensure_hf_torch_runtime() - try: - from transformers import AutoModel, AutoTokenizer - except ImportError as exc: # pragma: no cover - optional extra path - message = f"Hugging Face OCR requires the `hf` runtime. {_HF_EXTRA_INSTALL_HINT}" - raise _configuration_error(message) from exc - - return _HFRuntime( - processor_cls=AutoTokenizer, - model_cls=AutoModel, - process_vision_info=None, - ) + return _runtime._load_hf_auto_model_runtime_with_import(import_module) def _load_hf_auto_processor_model_runtime() -> _HFRuntime: - _ensure_hf_torch_runtime() - try: - from transformers import AutoModel, AutoProcessor - except ImportError as exc: # pragma: no cover - optional extra path - message = f"Hugging Face OCR requires the `hf` runtime. {_HF_EXTRA_INSTALL_HINT}" - raise _configuration_error(message) from exc - - return _HFRuntime( - processor_cls=AutoProcessor, - model_cls=AutoModel, - process_vision_info=None, - ) + return _runtime._load_hf_auto_processor_model_runtime_with_import(import_module) -def _ensure_deepseek_ocr_2_cuda_runtime() -> _TorchModuleLike: - _ensure_hf_torch_runtime() - torch = _load_torch_module() - if not torch.cuda.is_available(): - message = ( - "DeepSeek-OCR-2 HF backend requires a CUDA-capable PyTorch runtime because " - "the upstream `infer(...)` implementation moves inputs to CUDA." - ) - raise _configuration_error(message) - return torch - - -_DOTS_OCR_1_5_LOCAL_DIRNAME = "DotsOCR_1_5" -_DOTS_FLASH_ATTN_IMPORT = "from flash_attn import flash_attn_varlen_func" -_DOTS_FLASH_ATTN_FALLBACK = """try: - from flash_attn import flash_attn_varlen_func -except ImportError: - flash_attn_varlen_func = None -""" -_DOTS_FORCE_BFLOAT16_LINE = " hidden_states = hidden_states.bfloat16()" -_DOTS_WEIGHT_DTYPE_LINE = ( - " hidden_states = hidden_states.to(self.patch_embed.patchifier.proj.weight.dtype)" -) +def _ensure_deepseek_ocr_2_cuda_runtime() -> _runtime._TorchModuleLike: + return _runtime._ensure_deepseek_ocr_2_cuda_runtime_with_import(import_module) + + +_DOTS_OCR_1_5_LOCAL_DIRNAME = _dots._DOTS_OCR_1_5_LOCAL_DIRNAME +_DOTS_FLASH_ATTN_IMPORT = _dots._DOTS_FLASH_ATTN_IMPORT +_DOTS_FLASH_ATTN_FALLBACK = _dots._DOTS_FLASH_ATTN_FALLBACK +_DOTS_FORCE_BFLOAT16_LINE = _dots._DOTS_FORCE_BFLOAT16_LINE +_DOTS_WEIGHT_DTYPE_LINE = _dots._DOTS_WEIGHT_DTYPE_LINE def _patch_dots_ocr_vision_module(model_dir: Path) -> None: - vision_module_path = model_dir / "modeling_dots_vision.py" - vision_module = vision_module_path.read_text() - if _DOTS_FLASH_ATTN_IMPORT not in vision_module and _DOTS_FLASH_ATTN_FALLBACK in vision_module: - return - vision_lines = vision_module.splitlines() - import_index = next( - (index for index, line in enumerate(vision_lines) if _DOTS_FLASH_ATTN_IMPORT in line), - None, - ) - if import_index is None: - return - - block_tokens = {"", "try:", "except ImportError:", "flash_attn_varlen_func = None"} - block_start = import_index - while block_start > 0 and vision_lines[block_start - 1].strip() in block_tokens: - block_start -= 1 - - block_end = import_index + 1 - while block_end < len(vision_lines) and vision_lines[block_end].strip() in block_tokens: - block_end += 1 - - patched_lines = ( - vision_lines[:block_start] - + _DOTS_FLASH_ATTN_FALLBACK.rstrip("\n").splitlines() - + vision_lines[block_end:] - ) - patched_vision_module = "\n".join(patched_lines) + "\n" - if _DOTS_FORCE_BFLOAT16_LINE in patched_vision_module: - patched_vision_module = patched_vision_module.replace( - _DOTS_FORCE_BFLOAT16_LINE, - _DOTS_WEIGHT_DTYPE_LINE, - ) - vision_module_path.write_text(patched_vision_module) + _dots._patch_dots_ocr_vision_module(model_dir) def _prepare_dots_ocr_model_dir(model_id: str) -> str: - try: - from huggingface_hub import snapshot_download - except ImportError as exc: # pragma: no cover - transitively provided by transformers - message = f"Hugging Face OCR requires the `hf` runtime. {_HF_EXTRA_INSTALL_HINT}" - raise _configuration_error(message) from exc - - model_dir = ( - Path.home() - / ".cache" - / "churro-ocr" - / "hf" - / _DOTS_OCR_1_5_LOCAL_DIRNAME - / model_id.replace("/", "__").replace(".", "_") + return _dots._prepare_dots_ocr_model_dir( + model_id, + home_dir=Path.home(), + patch_vision_module=_patch_dots_ocr_vision_module, + configuration_error=_configuration_error, + extra_install_hint=_HF_EXTRA_INSTALL_HINT, ) - snapshot_download(repo_id=model_id, local_dir=model_dir) - _patch_dots_ocr_vision_module(model_dir) - return str(model_dir) def _patch_dots_ocr_prepare_inputs_for_generation(model: object) -> None: - prepare_inputs_for_generation = getattr(model, "prepare_inputs_for_generation", None) - if not callable(prepare_inputs_for_generation): - return - if getattr(model, "_churro_dots_prepare_inputs_patched", False): - return - - original_prepare_inputs = getattr(prepare_inputs_for_generation, "__func__", None) - base_prepare_inputs_for_generation = prepare_inputs_for_generation - if original_prepare_inputs is not None: - for candidate in type(model).__mro__[1:]: - candidate_prepare_inputs = candidate.__dict__.get("prepare_inputs_for_generation") - if candidate_prepare_inputs is None or candidate_prepare_inputs is original_prepare_inputs: - continue - base_prepare_inputs_for_generation = cast("Any", candidate_prepare_inputs).__get__( - model, - type(model), - ) - break - if not callable(base_prepare_inputs_for_generation): - return - - def _patched_prepare_inputs_for_generation( - _self: object, - input_ids: object, - past_key_values: object = None, - inputs_embeds: object = None, - pixel_values: object = None, - attention_mask: object = None, - cache_position: object = None, - num_logits_to_keep: object = None, - **kwargs: object, - ) -> object: - model_inputs = base_prepare_inputs_for_generation( - input_ids, - past_key_values=past_key_values, - inputs_embeds=inputs_embeds, - attention_mask=attention_mask, - cache_position=cache_position, - num_logits_to_keep=num_logits_to_keep, - **kwargs, - ) - - first_cache_position: int | None = None - if cache_position is not None: - try: - first_cache_position = int(cast("Any", cache_position)[0]) - except (IndexError, TypeError, ValueError): - first_cache_position = None - if first_cache_position in (None, 0): - model_inputs["pixel_values"] = pixel_values - - return model_inputs - - model_any = cast("Any", model) - model_any.prepare_inputs_for_generation = MethodType(_patched_prepare_inputs_for_generation, model) - model_any._churro_dots_prepare_inputs_patched = True + _dots._patch_dots_ocr_prepare_inputs_for_generation(model) def _default_dots_ocr_1_5_model_kwargs() -> dict[str, object]: - model_kwargs: dict[str, object] = {"dtype": "auto"} - try: - torch = _load_torch_module() - except ImportError: # pragma: no cover - torch is installed separately for local HF use - return model_kwargs - - if not torch.cuda.is_available(): - return model_kwargs - - try: - free_bytes, _ = torch.cuda.mem_get_info() - except RuntimeError: - return model_kwargs - free_gib = max(1, int(free_bytes / (1024**3)) - 1) - if free_gib < 8: - return {"dtype": "float32"} - - model_kwargs["device_map"] = "auto" - model_kwargs["max_memory"] = {0: f"{free_gib}GiB", "cpu": "128GiB"} - return model_kwargs - - -def _default_chandra_ocr_2_model_kwargs() -> dict[str, object]: - model_kwargs: dict[str, object] = { - "device_map": "auto", - "dtype": "auto", - } - try: - torch = _load_torch_module() - except ImportError: # pragma: no cover - torch is installed separately for local HF use - return model_kwargs - - if torch.cuda.is_available(): - model_kwargs["dtype"] = torch.bfloat16 - return model_kwargs - - -def _default_mineru25_model_kwargs() -> dict[str, object]: - model_kwargs: dict[str, object] = {"device_map": "auto"} - dtype_key = "dtype" - transformers_version: str - try: - from transformers import __version__ as imported_transformers_version - - transformers_version = str(imported_transformers_version) - except ImportError: # pragma: no cover - transformers is installed via the hf runtime - transformers_version = "" - - version_parts = transformers_version.split(".") - if len(version_parts) >= 2: - try: - major = int(version_parts[0]) - minor = int(version_parts[1]) - except ValueError: - major = 0 - minor = 0 - if major < 4 or (major == 4 and minor < 56): - dtype_key = "torch_dtype" - model_kwargs[dtype_key] = "auto" - return model_kwargs - - -def _deepseek_ocr_2_prompt_from_conversation(conversation: OCRConversation) -> str: - prompt_lines: list[str] = [] - has_image = False - for message in conversation: - if message.get("role") == "system": - content_items = cast("list[OCRConversationContentItem]", message["content"]) - system_text = "\n".join( - cast("str", item["text"]).strip() - for item in content_items - if item.get("type") == "text" and isinstance(item.get("text"), str) - ).strip() - if system_text: - message = "DeepSeek-OCR-2 does not support system prompts in the HF backend." - raise _configuration_error(message) - continue - if message.get("role") != "user": - continue - content_items = cast("list[OCRConversationContentItem]", message["content"]) - for item in content_items: - if item.get("type") == "image": - has_image = True - continue - if item.get("type") == "text" and isinstance(item.get("text"), str): - text = cast("str", item["text"]).strip() - if text: - prompt_lines.append(text) - prompt_text = "\n".join(prompt_lines).strip() - if not prompt_text: - message = "DeepSeek-OCR-2 requires a non-empty OCR prompt." - raise _configuration_error(message) - if has_image: - return f"\n{prompt_text}" - return prompt_text - - -def _move_batch_to_model(batch: dict[str, object], model: object) -> dict[str, object]: - model_device = getattr(model, "device", None) - if hasattr(batch, "to") and model_device is not None: - batch = cast("dict[str, object]", cast("Any", batch).to(model_device)) - model_dtype = getattr(model, "dtype", None) - if model_dtype is not None: - for key, value in batch.items(): - if hasattr(value, "dtype") and getattr(value.dtype, "is_floating_point", False): - batch[key] = cast("Any", value).to(dtype=model_dtype) - return batch - - -def _decode_completion_texts( - processor: object, - batch: Mapping[str, object], - generated_ids: object, -) -> list[str]: - return _decode_completion_texts_with_options( - processor, - batch, - generated_ids, - skip_special_tokens=True, - ) - - -def _completion_ids_from_generated_ids(batch: Mapping[str, object], generated_ids: object) -> object: - attention_mask = batch.get("attention_mask") - if attention_mask is not None and hasattr(attention_mask, "sum"): - prompt_lengths = cast("Any", attention_mask).sum(dim=1).tolist() - return [ - output_ids[int(prompt_length) :] - for prompt_length, output_ids in zip(prompt_lengths, cast("Any", generated_ids), strict=True) - ] - prompt_length = cast("Any", batch["input_ids"]).shape[1] - return cast("Any", generated_ids)[:, prompt_length:] - - -def _decode_completion_texts_with_options( - processor: object, - batch: Mapping[str, object], - generated_ids: object, - *, - skip_special_tokens: bool, -) -> list[str]: - completion_ids = _completion_ids_from_generated_ids(batch, generated_ids) - return cast("_HFProcessorDecoder", processor).batch_decode( - completion_ids, - skip_special_tokens=skip_special_tokens, - clean_up_tokenization_spaces=False, - ) - - -def _resolve_model_max_length(model: object) -> int | None: - config = getattr(model, "config", None) - max_length = getattr(config, "max_position_embeddings", None) - if isinstance(max_length, int): - return max_length - text_config = getattr(config, "text_config", None) - text_max_length = getattr(text_config, "max_position_embeddings", None) - if isinstance(text_max_length, int): - return text_max_length - return None - - -_MINERU25_STEP_ALIASES = { - "[layout]": "layout", - "table": "table", - "equation": "equation", - "image": "image", - "chart": "chart", -} -_MINERU25_SAMPLING_FIELD_NAMES = ( - "temperature", - "top_p", - "top_k", - "presence_penalty", - "frequency_penalty", - "repetition_penalty", - "no_repeat_ngram_size", - "max_new_tokens", -) -_MINERU25_SCOPED_PREFIXES = ( - "layout_", - "table_", - "equation_", - "image_", - "chart_", - "default_", -) - - -def _paddleocr_vl_processor_kwargs(*, processor: object, padding: bool) -> dict[str, object]: - processor_kwargs: dict[str, object] = { - "text_kwargs": { - "padding": padding, - "return_mm_token_type_ids": True, - } - } - image_processor = getattr(processor, "image_processor", None) - images_kwargs: dict[str, int] = {} - for key in ("min_pixels", "max_pixels"): - value = getattr(image_processor, key, None) - if isinstance(value, int): - images_kwargs[key] = value - if images_kwargs: - processor_kwargs["images_kwargs"] = images_kwargs - return processor_kwargs + return _dots._default_dots_ocr_1_5_model_kwargs(load_torch_module=_load_torch_module) @dataclass(slots=True) @@ -1160,21 +724,6 @@ class DotsMOCROCRBackend(DotsOCR15OCRBackend): template: OCRPromptTemplateLike = DOTS_MOCR_OCR_TEMPLATE model_name: str | None = "dots.mocr" - -def _default_mineru25_helper() -> MinerU25PipelineHelper: - return MinerU25PipelineHelper( - prompts={ - "[default]": MINERU2_5_2509_1_2B_OCR_PROMPT, - "[layout]": MINERU2_5_2509_1_2B_LAYOUT_PROMPT, - "table": MINERU2_5_2509_1_2B_TABLE_PROMPT, - "equation": MINERU2_5_2509_1_2B_FORMULA_PROMPT, - "image": MINERU2_5_2509_1_2B_IMAGE_ANALYSIS_PROMPT, - "chart": MINERU2_5_2509_1_2B_IMAGE_ANALYSIS_PROMPT, - }, - system_prompt=MINERU2_5_2509_1_2B_SYSTEM_PROMPT, - ) - - @dataclass(slots=True) class MinerU25OCRBackend(HuggingFaceVisionOCRBackend): """Preset OCR backend for ``opendatalab/MinerU2.5-2509-1.2B``.""" @@ -1187,7 +736,11 @@ class MinerU25OCRBackend(HuggingFaceVisionOCRBackend): image_analysis_template: OCRPromptTemplateLike = MINERU2_5_2509_1_2B_IMAGE_ANALYSIS_TEMPLATE model_name: str | None = "MinerU2.5-2509-1.2B" image_preprocessor: ImagePreprocessor = ensure_rgb - _helper: MinerU25PipelineHelper = field(default_factory=_default_mineru25_helper, init=False, repr=False) + _helper: MinerU25PipelineHelper = field( + default_factory=_mineru._default_mineru25_helper, + init=False, + repr=False, + ) def __post_init__(self) -> None: """Preserve user-supplied MinerU2.5 generation overrides without generic defaults.""" @@ -1213,119 +766,62 @@ def _get_model(self, runtime: _HFRuntime) -> object: eval_method() return self._model - def _template_for_step(self, step_key: str) -> OCRPromptTemplateLike: - if step_key == "[layout]": - return self.layout_template - if step_key == "table": - return self.table_template - if step_key == "equation": - return self.formula_template - if step_key in {"image", "chart"}: - return self.image_analysis_template - return self.template - - def _resolve_rendered_prompt(self, rendered: object) -> str: - if isinstance(rendered, tuple): - if not rendered: - message = "MinerU2.5 returned an empty chat template render." - raise _provider_error(message) - rendered = rendered[0] - if not isinstance(rendered, str): - message = "MinerU2.5 chat template did not render text." - raise _provider_error(message) - return rendered - - def _resolve_step_sampling(self, step_key: str) -> MinerU25SamplingParams: - effective_step = _MINERU25_STEP_ALIASES.get(step_key, "default") - sampling = self._helper.sampling_for(step_key) - changes: dict[str, float | int | None] = {} - for field_name in _MINERU25_SAMPLING_FIELD_NAMES: - global_value = self.generation_kwargs.get(field_name) - if isinstance(global_value, (int, float)): - changes[field_name] = global_value - step_value = self.generation_kwargs.get(f"{effective_step}_{field_name}") - if isinstance(step_value, (int, float)): - changes[field_name] = step_value - return replace_sampling_param(sampling, **changes) if changes else sampling - - def _resolve_generation_kwargs(self, *, step_key: str, model: object) -> dict[str, object]: - sampling = self._resolve_step_sampling(step_key) - do_sample = ((sampling.temperature or 0.0) > 0.0) and ((sampling.top_k or 1) > 1) - - generation_kwargs: dict[str, object] = { - "do_sample": do_sample, - } - if do_sample and sampling.temperature is not None: - generation_kwargs["temperature"] = sampling.temperature - if do_sample and sampling.top_p is not None: - generation_kwargs["top_p"] = sampling.top_p - if do_sample and sampling.top_k is not None: - generation_kwargs["top_k"] = sampling.top_k - if sampling.repetition_penalty is not None: - generation_kwargs["repetition_penalty"] = sampling.repetition_penalty - if sampling.no_repeat_ngram_size is not None: - generation_kwargs["no_repeat_ngram_size"] = sampling.no_repeat_ngram_size - if sampling.max_new_tokens is not None: - generation_kwargs["max_new_tokens"] = sampling.max_new_tokens - else: - max_length = self.generation_kwargs.get( - "max_length", - _resolve_model_max_length(model), - ) - if isinstance(max_length, str): - max_length = int(max_length) - if isinstance(max_length, int): - generation_kwargs["max_length"] = max_length - - extra_kwargs = dict(self.generation_kwargs) - extra_kwargs.pop("max_length", None) - for prefix in ("", *_MINERU25_SCOPED_PREFIXES): - for field_name in _MINERU25_SAMPLING_FIELD_NAMES: - extra_kwargs.pop(f"{prefix}{field_name}" if prefix else field_name, None) - generation_kwargs.update(extra_kwargs) - return generation_kwargs - def _infer_step( self, *, - runtime: _HFRuntime, - processor: object, - model: object, - image: Image.Image, - step_key: str, - batch_size: int, - ) -> str: + context: _mineru._MinerU25StepContext, + image: Image.Image, + step_key: str, +) -> str: rendered, conversation = render_ocr_prompt( - processor, - self._template_for_step(step_key), + context.processor, + _mineru._template_for_step( + step_key, + _mineru._MinerU25Templates( + default_template=self.template, + layout_template=self.layout_template, + table_template=self.table_template, + formula_template=self.formula_template, + image_analysis_template=self.image_analysis_template, + ), + ), DocumentPage.from_image(image), add_generation_prompt=True, ) - rendered_prompt = self._resolve_rendered_prompt(rendered) + rendered_prompt = _mineru._resolve_rendered_prompt( + rendered, + provider_error=_provider_error, + ) self._log_prompt_payload( rendered_prompt=rendered_prompt, conversation=conversation, - batch_size=batch_size, + batch_size=context.batch_size, ) - image_inputs, video_inputs = self._build_vision_inputs(runtime, conversation) - batch_kwargs: dict[str, object] = { - "text": [rendered_prompt], - "images": normalize_media_inputs(image_inputs), - "return_tensors": "pt", - "padding": True, - } - normalized_video_inputs = normalize_media_inputs(video_inputs) - if normalized_video_inputs is not None: - batch_kwargs["videos"] = normalized_video_inputs - batch = _call_processor(processor, **batch_kwargs) - batch = _move_batch_to_model(batch, model) + image_inputs, video_inputs = self._build_vision_inputs( + cast("_HFRuntime", context.runtime), + conversation, + ) + batch = _call_processor( + context.processor, + **_mineru._build_step_batch_kwargs( + rendered_prompt=rendered_prompt, + image_inputs=image_inputs, + video_inputs=video_inputs, + ), + ) + batch = _move_batch_to_model(batch, context.model) generated_ids = _generate_with_model( - model, + context.model, **self._generation_inputs(batch), - **self._resolve_generation_kwargs(step_key=step_key, model=model), + **_mineru._resolve_generation_kwargs( + helper=self._helper, + generation_kwargs=self.generation_kwargs, + step_key=step_key, + model=context.model, + ), ) text = _decode_completion_texts_with_options( - processor, + context.processor, batch, generated_ids, skip_special_tokens=False, @@ -1359,16 +855,19 @@ def _ocr_sync(self, page: DocumentPage) -> OCRResult: runtime = self._load_runtime() processor = self._get_processor(runtime) model = self._get_model(runtime) + step_context = _mineru._MinerU25StepContext( + runtime=runtime, + processor=processor, + model=model, + batch_size=1, + ) markdown, blocks, metrics = self._helper.run_two_step( prepared_page.image, infer_step=lambda image, step_key, _sampling: self._infer_step( - runtime=runtime, - processor=processor, - model=model, + context=step_context, image=image, step_key=step_key, - batch_size=1, ), ) return self._build_result( @@ -1385,6 +884,12 @@ def _ocr_batch_sync(self, pages: list[DocumentPage]) -> list[OCRResult]: processor = self._get_processor(runtime) model = self._get_model(runtime) batch_size = len(pages) + step_context = _mineru._MinerU25StepContext( + runtime=runtime, + processor=processor, + model=model, + batch_size=batch_size, + ) results: list[OCRResult] = [] for page in pages: prepared_page = preprocess_backend_page( @@ -1394,12 +899,9 @@ def _ocr_batch_sync(self, pages: list[DocumentPage]) -> list[OCRResult]: markdown, blocks, metrics = self._helper.run_two_step( prepared_page.image, infer_step=lambda image, step_key, _sampling: self._infer_step( - runtime=runtime, - processor=processor, - model=model, + context=step_context, image=image, step_key=step_key, - batch_size=batch_size, ), ) results.append( From b2b471a7bd7f34eebb680c3e8713571433c6d064 Mon Sep 17 00:00:00 2001 From: Sina Date: Mon, 13 Apr 2026 19:45:31 +0000 Subject: [PATCH 38/58] chore(dependencies): update ruff and ty versions in dev-tooling --- pixi.lock | 26 +++++++++++----------- pyproject.toml | 4 ++-- ruff.toml | 2 +- src/churro_ocr/providers/page_detection.py | 6 ++--- 4 files changed, 19 insertions(+), 19 deletions(-) diff --git a/pixi.lock b/pixi.lock index 3beddc1..ea306b3 100644 --- a/pixi.lock +++ b/pixi.lock @@ -182,7 +182,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/04/54/6f679c435d28e0a568d8e8a7c0a93a09010818634c3c3907fc98d8983770/roman_numerals-4.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/27/2c/daca29684cbe9fd4bc711f8246da3c10adca1ccc4d24436b17572eb2590e/roman_numerals_py-4.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/ff/6b/a1548ac378a78332a4c3dcf4a134c2475a36d2a22ddfa272acd574140b50/ruff-0.15.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/bc/bb/7ddb00a83760ff4a83c4e2fc231fd63937cc7317c10c82f583302e0f6586/ruff-0.15.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/a0/60/429e9b1cb3fc651937727befe258ea24122d9663e4d5709a48c9cbfceecb/safetensors-0.7.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl @@ -207,7 +207,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e7/28/35f7411ff80a3640c1f4fc907dcbb6a65061ebb82f66950e38bfc9f7f740/transformers-5.5.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3a/7a/882d99539b19b1490cac5d77c67338d126e4122c8276bf640e411650c830/twine-6.2.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/89/29/8ac0281fc44c3297f0e58699ebf993c13621e32a0fab1025439d3ea8a2f1/ty-0.0.28-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/79/12/0fb0857e9a62cb11586e9a712103877bbf717f5fb570d16634408cfdefee/ty-0.0.29-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl @@ -376,7 +376,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/04/54/6f679c435d28e0a568d8e8a7c0a93a09010818634c3c3907fc98d8983770/roman_numerals-4.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/27/2c/daca29684cbe9fd4bc711f8246da3c10adca1ccc4d24436b17572eb2590e/roman_numerals_py-4.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/ff/6b/a1548ac378a78332a4c3dcf4a134c2475a36d2a22ddfa272acd574140b50/ruff-0.15.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/bc/bb/7ddb00a83760ff4a83c4e2fc231fd63937cc7317c10c82f583302e0f6586/ruff-0.15.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/a0/60/429e9b1cb3fc651937727befe258ea24122d9663e4d5709a48c9cbfceecb/safetensors-0.7.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl @@ -401,7 +401,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e7/28/35f7411ff80a3640c1f4fc907dcbb6a65061ebb82f66950e38bfc9f7f740/transformers-5.5.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3a/7a/882d99539b19b1490cac5d77c67338d126e4122c8276bf640e411650c830/twine-6.2.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/89/29/8ac0281fc44c3297f0e58699ebf993c13621e32a0fab1025439d3ea8a2f1/ty-0.0.28-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/79/12/0fb0857e9a62cb11586e9a712103877bbf717f5fb570d16634408cfdefee/ty-0.0.29-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl @@ -540,7 +540,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/04/54/6f679c435d28e0a568d8e8a7c0a93a09010818634c3c3907fc98d8983770/roman_numerals-4.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/27/2c/daca29684cbe9fd4bc711f8246da3c10adca1ccc4d24436b17572eb2590e/roman_numerals_py-4.1.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/ff/6b/a1548ac378a78332a4c3dcf4a134c2475a36d2a22ddfa272acd574140b50/ruff-0.15.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/bc/bb/7ddb00a83760ff4a83c4e2fc231fd63937cc7317c10c82f583302e0f6586/ruff-0.15.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/a0/60/429e9b1cb3fc651937727befe258ea24122d9663e4d5709a48c9cbfceecb/safetensors-0.7.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl @@ -563,7 +563,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e7/28/35f7411ff80a3640c1f4fc907dcbb6a65061ebb82f66950e38bfc9f7f740/transformers-5.5.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3a/7a/882d99539b19b1490cac5d77c67338d126e4122c8276bf640e411650c830/twine-6.2.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/89/29/8ac0281fc44c3297f0e58699ebf993c13621e32a0fab1025439d3ea8a2f1/ty-0.0.28-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/79/12/0fb0857e9a62cb11586e9a712103877bbf717f5fb570d16634408cfdefee/ty-0.0.29-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl @@ -779,7 +779,7 @@ packages: - pypi: ./ name: churro-ocr version: 0.2.0 - sha256: 4aab922d00e91acd3427b8ddbb59341f7f5232ccc72fbc120a3754e7e05d0dbc + sha256: 4aee805c55ac9eadaec8248affc3a728150ad248721bcfa301928bf5c2fd44e3 requires_dist: - loguru>=0.7.2,<1 - pillow>=10.4.0,<12 @@ -3041,10 +3041,10 @@ packages: version: 0.30.0 sha256: 47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/ff/6b/a1548ac378a78332a4c3dcf4a134c2475a36d2a22ddfa272acd574140b50/ruff-0.15.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl +- pypi: https://files.pythonhosted.org/packages/bc/bb/7ddb00a83760ff4a83c4e2fc231fd63937cc7317c10c82f583302e0f6586/ruff-0.15.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl name: ruff - version: 0.15.9 - sha256: 2b0c7c341f68adb01c488c3b7d4b49aa8ea97409eae6462d860a79cf55f431b6 + version: 0.15.10 + sha256: 51cb8cc943e891ba99989dd92d61e29b1d231e14811db9be6440ecf25d5c1609 requires_python: '>=3.7' - pypi: https://files.pythonhosted.org/packages/a0/60/429e9b1cb3fc651937727befe258ea24122d9663e4d5709a48c9cbfceecb/safetensors-0.7.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl name: safetensors @@ -3640,10 +3640,10 @@ packages: - id - keyring>=21.2.0 ; extra == 'keyring' requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/89/29/8ac0281fc44c3297f0e58699ebf993c13621e32a0fab1025439d3ea8a2f1/ty-0.0.28-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl +- pypi: https://files.pythonhosted.org/packages/79/12/0fb0857e9a62cb11586e9a712103877bbf717f5fb570d16634408cfdefee/ty-0.0.29-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl name: ty - version: 0.0.28 - sha256: f2849d6d212af78175430e8cc51a962a53851458182eb44a981b0e3981163177 + version: 0.0.29 + sha256: 2ce0a7a0e96bc7b42518cd3a1a6a6298ef64ff40ca4614355c1aa807059b5c6f requires_python: '>=3.8' - pypi: https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl name: typer diff --git a/pyproject.toml b/pyproject.toml index 3ef6d83..c0ffca8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -82,9 +82,9 @@ dev-test = [ dev-tooling = [ "build", "pre-commit>=4,<5", - "ruff==0.15.9", + "ruff==0.15.10", "twine", - "ty>=0.0.28,<0.0.29", + "ty>=0.0.29,<0.0.30", ] dev = [ { include-group = "dev-docs" }, diff --git a/ruff.toml b/ruff.toml index 009acf9..2e51f8d 100644 --- a/ruff.toml +++ b/ruff.toml @@ -1,5 +1,5 @@ line-length = 110 -required-version = "==0.15.9" +required-version = "==0.15.10" [lint] select = [ diff --git a/src/churro_ocr/providers/page_detection.py b/src/churro_ocr/providers/page_detection.py index ac4189c..025d239 100644 --- a/src/churro_ocr/providers/page_detection.py +++ b/src/churro_ocr/providers/page_detection.py @@ -285,7 +285,7 @@ async def _review_page_box( edge_decisions: dict[str, _EdgeReviewDecision] = {} for edge_name, result in zip(_EDGE_NAMES, edge_results, strict=False): - if isinstance(result, Exception): + if isinstance(result, BaseException): logger.info( "Edge-strip review failed for round %s, page %s, edge %s; using no_change: %s", round_index, @@ -367,7 +367,7 @@ async def _review_text_block_box( edge_decisions: dict[str, _EdgeReviewDecision] = {} for edge_name, result in zip(_EDGE_NAMES, edge_results, strict=False): - if isinstance(result, Exception): + if isinstance(result, BaseException): logger.info( "Text-block edge-strip review failed for round %s, edge %s; using no_change: %s", round_index, @@ -446,7 +446,7 @@ async def _run_review_pipeline( continue result = results_by_page.get(prior_box.page_index) - if isinstance(result, Exception): + if isinstance(result, BaseException): logger.info( "Review round %s %s %s failed, keeping prior box: %s", round_index + 1, From 01a09ac1b6e2f86f218e58e77714fcf13f622d64 Mon Sep 17 00:00:00 2001 From: Sina Date: Mon, 13 Apr 2026 19:46:08 +0000 Subject: [PATCH 39/58] docs: improve README --- README.md | 52 +++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 37 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index b5e6e99..6d3cdef 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,49 @@ -# CHURRO logo CHURRO +# CHURRO logo Churro -[![Model](https://img.shields.io/badge/Model-CHURRO%203B-8A4FFF)](https://huggingface.co/stanford-oval/churro-3B) -[![Dataset](https://img.shields.io/badge/Dataset-CHURRO--DS-0A7BBB)](https://huggingface.co/datasets/stanford-oval/churro-dataset) -[![Paper](https://img.shields.io/badge/Paper-arXiv-B31B1B)](https://arxiv.org/abs/2509.19768) -[![Docs](https://img.shields.io/badge/Docs-Documentation-8B451F)](https://stanford-oval.github.io/Churro/) -[![Leaderboard](https://img.shields.io/badge/Leaderboard-Benchmark%20Snapshot-6B7280)](https://stanford-oval.github.io/Churro/leaderboard.html) -[![GitHub Stars](https://img.shields.io/github/stars/stanford-oval/churro?style=social)](https://github.com/stanford-oval/churro/stargazers) +
+ +Churro is the fastest way to turn hard-to-read historical scans into reliable text. It gives researchers, libraries, archives, and product teams a unified OCR toolkit for handwritten and printed sources, combining high accuracy, low operating cost, and a clean Python API and CLI workflow. -CHURRO is an OCR toolkit for historical document transcription, built to make handwritten and printed sources readable at high accuracy and lower cost. +## Supported OCR Models and Backends -It works with all major OCR providers and vision-language models, and provides first-party support for the CHURRO 3B model and CHURRO-DS dataset. +Churro includes built-in profiles, templates, and post-processing for many OCR models and integrations, including: +- Hosted vision-language models, including Gemini, GPT, Claude, and more, through LiteLLM integration +- OpenAI-compatible servers, including vLLM, Ollama, TGI, and more +- Azure Document Intelligence +- Mistral OCR +- `Chandra OCR` +- `DeepSeek OCR` +- `Dots OCR` +- `MinerU` +- `Infinity Parser` +- `PaddleOCR VL` +- `LFM VL` -- CHURRO 3B exceeds the accuracy of Gemini 2.5 Pro at 15.5x lower cost. -- CHURRO-DS contains ~100K pages from 155 historical collections spanning 22 centuries and 46 language clusters. +## Churro Model and Dataset + +We also provide first-party support for a purpose-trained model and dataset for historical OCR: + +- Churro 3B VLM exceeds the accuracy of Gemini 2.5 Pro at 15.5x lower cost. +- Churro-DS dataset contains ~100K pages from 155 historical collections spanning 22 centuries and 46 language clusters.

- Cost vs Performance comparison showing CHURRO's accuracy advantage at significantly lower cost + Cost vs Performance comparison showing Churro's accuracy advantage at significantly lower cost
- Cost vs. accuracy: CHURRO (3B) achieves higher accuracy than much larger commercial and open-weight VLMs while being substantially cheaper. + Cost vs. accuracy: Churro (3B) achieves higher accuracy than much larger commercial and open-weight VLMs while being substantially cheaper.

-## Quick Try +## Quick Start Python 3.12+ and `uv` are required. @@ -48,6 +70,6 @@ If you use CHURRO or CHURRO-DS, please cite: ## License +- Code: Apache 2.0 - Model weights: Qwen research license - Dataset: research use only because of the underlying source licenses -- Code: Apache 2.0 From e2fba9eee67c8462ab745050482fa6e10ec9c2ab Mon Sep 17 00:00:00 2001 From: Sina Date: Mon, 13 Apr 2026 19:46:34 +0000 Subject: [PATCH 40/58] refactor(core): simplify complexity hotspots --- src/churro_ocr/_internal/litellm.py | 110 +++++++++++------ src/churro_ocr/providers/_mineru25.py | 169 ++++++++++++++++---------- 2 files changed, 178 insertions(+), 101 deletions(-) diff --git a/src/churro_ocr/_internal/litellm.py b/src/churro_ocr/_internal/litellm.py index ba53d07..d1168b8 100644 --- a/src/churro_ocr/_internal/litellm.py +++ b/src/churro_ocr/_internal/litellm.py @@ -41,6 +41,68 @@ def _timeout_error(message: str) -> TimeoutError: return TimeoutError(message) +def _set_optional_request_kwarg( + kwargs: dict[str, object], + *, + key: str, + value: object, +) -> None: + if value: + kwargs[key] = value + + +def _build_completion_request_kwargs( + *, + config: LiteLLMTransportConfig, + model: str, + messages: list[dict[str, Any]], + timeout_seconds: float, + output_json: bool, +) -> dict[str, object]: + kwargs: dict[str, object] = { + "model": model, + "messages": messages, + "timeout": float(timeout_seconds), + } + _set_optional_request_kwarg(kwargs, key="api_base", value=config.api_base) + _set_optional_request_kwarg(kwargs, key="api_key", value=config.api_key) + _set_optional_request_kwarg(kwargs, key="api_version", value=config.api_version) + if output_json: + kwargs["response_format"] = {"type": "json_object"} + if config.completion_kwargs: + kwargs.update(config.completion_kwargs) + return kwargs + + +def _remaining_completion_timeout( + *, + deadline: float, + total_timeout_seconds: float, +) -> float: + remaining_timeout_seconds = max(0.0, deadline - monotonic()) + if remaining_timeout_seconds <= 0: + message = f"LiteLLM request exceeded the total timeout of {total_timeout_seconds} seconds." + raise _timeout_error(message) + return remaining_timeout_seconds + + +def _coerce_completion_text( + answer: object, + *, + model: str, + allow_empty: bool, +) -> str: + if isinstance(answer, str): + if answer.strip(): + return answer + if allow_empty: + return "" + if answer is None and allow_empty: + return "" + message = f"LiteLLM returned empty output for model '{model}'." + raise _provider_error(message) + + def _ensure_initialized() -> None: global _INITIALIZED if _INITIALIZED: @@ -244,36 +306,26 @@ async def complete_text( _ensure_initialized() from litellm import acompletion - kwargs: dict[str, object] = { - "model": model, - "messages": messages, - } - if self._config.api_base: - kwargs["api_base"] = self._config.api_base - if self._config.api_key: - kwargs["api_key"] = self._config.api_key - if self._config.api_version: - kwargs["api_version"] = self._config.api_version - if output_json: - kwargs["response_format"] = {"type": "json_object"} - if self._config.completion_kwargs: - kwargs.update(self._config.completion_kwargs) request_timeout_seconds = float(timeout_seconds) + kwargs = _build_completion_request_kwargs( + config=self._config, + model=model, + messages=messages, + timeout_seconds=request_timeout_seconds, + output_json=output_json, + ) # Keep LiteLLM's provider timeout stable so its client caches can be reused # across attempts and requests. The outer wait_for still enforces the # shrinking wall-clock deadline for this overall operation. - kwargs["timeout"] = request_timeout_seconds deadline = monotonic() + request_timeout_seconds async def _run_completion() -> object: - attempt_kwargs = dict(kwargs) - remaining_timeout_seconds = max(0.0, deadline - monotonic()) - if remaining_timeout_seconds <= 0: - message = f"LiteLLM request exceeded the total timeout of {timeout_seconds} seconds." - raise _timeout_error(message) return await asyncio.wait_for( - acompletion(**attempt_kwargs), - timeout=remaining_timeout_seconds, + acompletion(**dict(kwargs)), + timeout=_remaining_completion_timeout( + deadline=deadline, + total_timeout_seconds=request_timeout_seconds, + ), ) try: @@ -287,20 +339,8 @@ async def _run_completion() -> object: message = f"LiteLLM request failed for model '{model}': {exc}" raise _provider_error(message) from exc self._record_response_cost(model=model, response=response) - answer = cast("Any", response).choices[0].message.content - if isinstance(answer, str): - if answer.strip(): - return answer - if allow_empty: - return "" - elif answer is None and allow_empty: - return "" - if not isinstance(answer, str): - message = f"LiteLLM returned empty output for model '{model}'." - raise _provider_error(message) - message = f"LiteLLM returned empty output for model '{model}'." - raise _provider_error(message) + return _coerce_completion_text(answer, model=model, allow_empty=allow_empty) def _resolved_image_detail(self) -> str | None: return "high" if self._config.image_detail is None else self._config.image_detail diff --git a/src/churro_ocr/providers/_mineru25.py b/src/churro_ocr/providers/_mineru25.py index 61db45e..b631a47 100644 --- a/src/churro_ocr/providers/_mineru25.py +++ b/src/churro_ocr/providers/_mineru25.py @@ -659,62 +659,90 @@ def _count_span_down(rows: list[list[str]], row_idx: int, col_idx: int, span_tok return span -def convert_mineru2_5_otsl_to_html(otsl_content: str) -> str: - """Convert a MinerU2.5 OTSL table prediction to HTML.""" - if otsl_content.startswith(""): - return otsl_content - tokens, mixed_texts = _extract_otsl_tokens_and_text(otsl_content) - rows = [ +def _group_otsl_rows(tokens: list[str]) -> list[list[str]]: + return [ list(group) for is_newline, group in itertools.groupby(tokens, lambda item: item == _OTSL_NL) if not is_newline ] - if not rows: - return otsl_content.strip() + +def _pad_otsl_rows(rows: list[list[str]]) -> tuple[list[list[str]], int]: max_cols = max(len(row) for row in rows) - for row in rows: - while len(row) < max_cols: - row.append(_OTSL_ECEL) + padded_rows = [row + ([_OTSL_ECEL] * (max_cols - len(row))) for row in rows] + return padded_rows, max_cols - normalized_texts: list[str] = [] + +def _normalize_otsl_parts(rows: list[list[str]], mixed_texts: list[str]) -> list[str]: + normalized_parts: list[str] = [] text_idx = 0 for row in rows: for token in row: - normalized_texts.append(token) + normalized_parts.append(token) if text_idx < len(mixed_texts) and mixed_texts[text_idx] == token: text_idx += 1 if text_idx < len(mixed_texts) and mixed_texts[text_idx] not in _OTSL_TOKENS: - normalized_texts.append(mixed_texts[text_idx]) + normalized_parts.append(mixed_texts[text_idx]) text_idx += 1 - normalized_texts.append(_OTSL_NL) + normalized_parts.append(_OTSL_NL) if text_idx < len(mixed_texts) and mixed_texts[text_idx] == _OTSL_NL: text_idx += 1 + return normalized_parts + + +def _cell_text_and_offset(parts: list[str], index: int) -> tuple[str, int]: + next_index = index + 1 + if next_index < len(parts) and parts[next_index] not in _OTSL_TOKENS: + return parts[next_index].strip(), 2 + return "", 1 + + +def _next_otsl_right_token(parts: list[str], *, index: int, next_offset: int) -> str: + next_index = index + next_offset + return parts[next_index] if next_index < len(parts) else "" + + +def _next_otsl_down_token(rows: list[list[str]], *, row_idx: int, col_idx: int) -> str: + if row_idx + 1 >= len(rows) or col_idx >= len(rows[row_idx + 1]): + return "" + return rows[row_idx + 1][col_idx] + +def _otsl_cell_spans( + rows: list[list[str]], + parts: list[str], + *, + row_idx: int, + col_idx: int, + index: int, + next_offset: int, +) -> tuple[int, int]: + row_span = 1 + col_span = 1 + next_right = _next_otsl_right_token(parts, index=index, next_offset=next_offset) + if next_right in {_OTSL_LCEL, _OTSL_XCEL}: + col_span += _count_span_right(rows, row_idx, col_idx + 1, {_OTSL_LCEL, _OTSL_XCEL}) + next_down = _next_otsl_down_token(rows, row_idx=row_idx, col_idx=col_idx) + if next_down in {_OTSL_UCEL, _OTSL_XCEL}: + row_span += _count_span_down(rows, row_idx + 1, col_idx, {_OTSL_UCEL, _OTSL_XCEL}) + return row_span, col_span + + +def _collect_otsl_cells(rows: list[list[str]], parts: list[str]) -> list[_TableCell]: cells: list[_TableCell] = [] row_idx = 0 col_idx = 0 - for index, part in enumerate(normalized_texts): + for index, part in enumerate(parts): if part in {_OTSL_FCEL, _OTSL_ECEL}: - row_span = 1 - col_span = 1 - next_offset = 1 - cell_text = "" - if index + 1 < len(normalized_texts) and normalized_texts[index + 1] not in _OTSL_TOKENS: - cell_text = normalized_texts[index + 1].strip() - next_offset = 2 - next_right = ( - normalized_texts[index + next_offset] if index + next_offset < len(normalized_texts) else "" - ) - next_down = ( - rows[row_idx + 1][col_idx] - if row_idx + 1 < len(rows) and col_idx < len(rows[row_idx + 1]) - else "" + cell_text, next_offset = _cell_text_and_offset(parts, index) + row_span, col_span = _otsl_cell_spans( + rows, + parts, + row_idx=row_idx, + col_idx=col_idx, + index=index, + next_offset=next_offset, ) - if next_right in {_OTSL_LCEL, _OTSL_XCEL}: - col_span += _count_span_right(rows, row_idx, col_idx + 1, {_OTSL_LCEL, _OTSL_XCEL}) - if next_down in {_OTSL_UCEL, _OTSL_XCEL}: - row_span += _count_span_down(rows, row_idx + 1, col_idx, {_OTSL_UCEL, _OTSL_XCEL}) cells.append( _TableCell( text=cell_text, @@ -729,12 +757,16 @@ def convert_mineru2_5_otsl_to_html(otsl_content: str) -> str: if part == _OTSL_NL: row_idx += 1 col_idx = 0 + return cells + +def _render_otsl_html(rows: list[list[str]], *, max_cols: int, cells: list[_TableCell]) -> str: + cells_by_position = {(cell.start_row, cell.start_col): cell for cell in cells} html_parts = [""] for row in range(len(rows)): html_parts.append("") for col in range(max_cols): - cell = next((item for item in cells if item.start_row == row and item.start_col == col), None) + cell = cells_by_position.get((row, col)) if cell is None: continue attrs: list[str] = [] @@ -748,6 +780,20 @@ def convert_mineru2_5_otsl_to_html(otsl_content: str) -> str: return "".join(html_parts) +def convert_mineru2_5_otsl_to_html(otsl_content: str) -> str: + """Convert a MinerU2.5 OTSL table prediction to HTML.""" + if otsl_content.startswith(""): + return otsl_content + tokens, mixed_texts = _extract_otsl_tokens_and_text(otsl_content) + rows = _group_otsl_rows(tokens) + if not rows: + return otsl_content.strip() + rows, max_cols = _pad_otsl_rows(rows) + normalized_parts = _normalize_otsl_parts(rows, mixed_texts) + cells = _collect_otsl_cells(rows, normalized_parts) + return _render_otsl_html(rows, max_cols=max_cols, cells=cells) + + def wrap_mineru2_5_equation(content: str) -> str: """Wrap a MinerU2.5 formula prediction as display math.""" cleaned = content.strip() @@ -887,45 +933,36 @@ def json2md(blocks: list[MinerU25ContentBlock]) -> str: return "\n\n".join(content_list).strip() +@dataclass(slots=True) class MinerU25PipelineHelper: """Shared MinerU2.5 layout, extraction, and markdown postprocessing helper.""" - def __init__( - self, - *, - prompts: dict[str, str], - system_prompt: str, - sampling_params: dict[str, MinerU25SamplingParams] | None = None, - layout_image_size: tuple[int, int] = MINERU2_5_LAYOUT_IMAGE_SIZE, - min_image_edge: int = MINERU2_5_MIN_IMAGE_EDGE, - max_image_edge_ratio: float = MINERU2_5_MAX_IMAGE_EDGE_RATIO, - simple_post_process: bool = False, - handle_equation_block: bool = True, - abandon_list: bool = False, - abandon_paratext: bool = False, - image_analysis: bool = False, - enable_table_formula_eq_wrap: bool = False, - ) -> None: - self.prompts = dict(prompts) - self.system_prompt = system_prompt - self.sampling_params = dict(DEFAULT_MINERU2_5_SAMPLING_PARAMS) - if sampling_params is not None: - self.sampling_params.update(sampling_params) - self.layout_image_size = layout_image_size - self.min_image_edge = min_image_edge - self.max_image_edge_ratio = max_image_edge_ratio - self.simple_post_process = simple_post_process - self.handle_equation_block = handle_equation_block - self.abandon_list = abandon_list - self.abandon_paratext = abandon_paratext - self.image_analysis = image_analysis - self.enable_table_formula_eq_wrap = enable_table_formula_eq_wrap + prompts: dict[str, str] + system_prompt: str + sampling_params: dict[str, MinerU25SamplingParams] | None = None + layout_image_size: tuple[int, int] = MINERU2_5_LAYOUT_IMAGE_SIZE + min_image_edge: int = MINERU2_5_MIN_IMAGE_EDGE + max_image_edge_ratio: float = MINERU2_5_MAX_IMAGE_EDGE_RATIO + simple_post_process: bool = False + handle_equation_block: bool = True + abandon_list: bool = False + abandon_paratext: bool = False + image_analysis: bool = False + enable_table_formula_eq_wrap: bool = False + + def __post_init__(self) -> None: + self.prompts = dict(self.prompts) + merged_sampling_params = dict(DEFAULT_MINERU2_5_SAMPLING_PARAMS) + if self.sampling_params is not None: + merged_sampling_params.update(self.sampling_params) + self.sampling_params = merged_sampling_params def prompt_for(self, step_key: str) -> str: return self.prompts.get(step_key) or self.prompts["[default]"] def sampling_for(self, step_key: str) -> MinerU25SamplingParams: - return self.sampling_params.get(step_key) or self.sampling_params["[default]"] + sampling_params = self.sampling_params or DEFAULT_MINERU2_5_SAMPLING_PARAMS + return sampling_params.get(step_key) or sampling_params["[default]"] def clean_response(self, text: str, *, step_key: str) -> str: cleaned = _trim_stop_strings(text) From b808cbe5e683adc2bb0120a9158d886e6390797a Mon Sep 17 00:00:00 2001 From: Sina Date: Mon, 13 Apr 2026 19:46:38 +0000 Subject: [PATCH 41/58] chore(ruff): enable complexity checks --- ruff.toml | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/ruff.toml b/ruff.toml index 2e51f8d..dca2948 100644 --- a/ruff.toml +++ b/ruff.toml @@ -29,13 +29,38 @@ select = [ "RUF046", # Ruff-specific: detect unnecessary cast/collection conversion patterns "TC", # flake8-type-checking ] +extend-select = [ + "C901", # mccabe: cyclomatic complexity + "PLR0911", # pylint: too many return statements + "PLR0912", # pylint: too many branches + "PLR0913", # pylint: too many arguments + "PLR0915", # pylint: too many statements +] [format] docstring-code-format = true +[lint.mccabe] +max-complexity = 13 + +[lint.pylint] +max-returns = 8 # maximum return statements allowed in one function/method +max-branches = 15 # maximum branches allowed in one function/method +max-args = 11 # maximum parameters allowed in one function/method definition +max-statements = 60 # maximum statements allowed in one function/method body + [lint.pydocstyle] convention = "google" [lint.per-file-ignores] "src/churro_ocr/cli.py" = ["B008"] -"tests/**/*.py" = ["D100", "D101", "D102", "D103", "D104", "D105", "D106", "D107"] +"tests/**/*.py" = [ + "D100", # undocumented public module + "D101", # undocumented public class + "D102", # undocumented public method + "D103", # undocumented public function + "D104", # undocumented public package + "D105", # undocumented magic method + "D106", # undocumented public nested class + "D107", # undocumented public __init__ +] From 988480c8cb495fc209ed318731801c15170d501b Mon Sep 17 00:00:00 2001 From: Sina Date: Mon, 13 Apr 2026 20:22:01 +0000 Subject: [PATCH 42/58] docs: improve README layout --- README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 6d3cdef..81695cc 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,14 @@ +
+ # CHURRO logo Churro +
+

🤗 Model🗂️ Dataset📄 Paper -
+

📚 Docs🏆 Leaderboard From e338576b1233409232d471c2d644a9c8256e9984 Mon Sep 17 00:00:00 2001 From: Sina Date: Mon, 13 Apr 2026 22:49:46 +0000 Subject: [PATCH 43/58] feat(providers): add GLM-OCR support --- docs/guides/providers.md | 4 +- src/churro_ocr/providers/_ocr_processing.py | 46 +++++ src/churro_ocr/providers/builder.py | 3 + src/churro_ocr/providers/hf.py | 143 +++++++++++++- src/churro_ocr/providers/specs.py | 34 ++++ src/churro_ocr/templates/__init__.py | 6 + src/churro_ocr/templates/presets.py | 9 + tests/test_cli.py | 22 +++ tests/test_hf_ocr.py | 195 ++++++++++++++++++++ tests/test_tooling_benchmark.py | 52 +++++- 10 files changed, 509 insertions(+), 5 deletions(-) diff --git a/docs/guides/providers.md b/docs/guides/providers.md index e99a7b5..2cde803 100644 --- a/docs/guides/providers.md +++ b/docs/guides/providers.md @@ -59,7 +59,9 @@ backend = build_ocr_backend( ) ``` -Built-in model-specific profiles are resolved automatically for known models such as `stanford-oval/churro-3B`, `datalab-to/chandra-ocr-2`, `deepseek-ai/DeepSeek-OCR-2`, `kristaller486/dots.ocr-1.5`, `rednote-hilab/dots.mocr`, `infly/Infinity-Parser-7B`, `opendatalab/MinerU2.5-2509-1.2B`, `PaddlePaddle/PaddleOCR-VL-1.5`, `LiquidAI/LFM2.5-VL-1.6B`, and the supported `olmOCR` checkpoints. +Built-in model-specific profiles are resolved automatically for known models such as `stanford-oval/churro-3B`, `datalab-to/chandra-ocr-2`, `deepseek-ai/DeepSeek-OCR-2`, `zai-org/GLM-OCR`, `kristaller486/dots.ocr-1.5`, `rednote-hilab/dots.mocr`, `infly/Infinity-Parser-7B`, `opendatalab/MinerU2.5-2509-1.2B`, `PaddlePaddle/PaddleOCR-VL-1.5`, `LiquidAI/LFM2.5-VL-1.6B`, and the supported `olmOCR` checkpoints. + +For `zai-org/GLM-OCR`, the built-in `hf` and `openai-compatible` backends both use the model's documented `Text Recognition:` prompt so local Transformers inference and served vLLM inference stay aligned. For `infly/Infinity-Parser-7B`, the built-in `hf` and `openai-compatible` backends use the documented markdown-conversion prompt and treat the response as markdown or embedded HTML. The OCR result preserves the raw markdown in metadata, and repo-local benchmark evaluation normalizes that markdown or HTML back to plain text before metrics are computed. diff --git a/src/churro_ocr/providers/_ocr_processing.py b/src/churro_ocr/providers/_ocr_processing.py index 94f5f45..44d7e47 100644 --- a/src/churro_ocr/providers/_ocr_processing.py +++ b/src/churro_ocr/providers/_ocr_processing.py @@ -2,6 +2,7 @@ from __future__ import annotations +import math import re from typing import TYPE_CHECKING @@ -17,6 +18,7 @@ ) from churro_ocr.templates import ( DEEPSEEK_OCR_2_OCR_PROMPT, + GLM_OCR_OCR_PROMPT, INFINITY_PARSER_7B_OCR_PROMPT, INFINITY_PARSER_7B_SYSTEM_PROMPT, LFM2_5_VL_1_6B_OCR_TEMPLATE, @@ -31,6 +33,12 @@ CHANDRA_MAX_IMAGE_SIZE = (3_072, 2_048) CHANDRA_MIN_IMAGE_SIZE = (1_792, 28) CHANDRA_IMAGE_GRID_SIZE = 28 +GLM_OCR_IMAGE_GRID_SIZE = 28 +GLM_OCR_TEMPORAL_PATCH_SIZE = 2 +GLM_OCR_VLLM_MAX_IMAGE_ITEM_LENGTH = 6_084 +GLM_OCR_VLLM_MAX_PIXELS = ( + GLM_OCR_IMAGE_GRID_SIZE**2 * GLM_OCR_TEMPORAL_PATCH_SIZE * GLM_OCR_VLLM_MAX_IMAGE_ITEM_LENGTH +) OLMOCR_TARGET_LONGEST_IMAGE_DIM = 1_288 _CHAT_ROLE_PREFIXES = { "assistant", @@ -185,6 +193,44 @@ def deepseek_ocr_2_text_postprocessor(text: str) -> str: return cleaned.strip() +def glm_ocr_text_postprocessor(text: str) -> str: + """Strip GLM-OCR prompt echoes, chat scaffold, and trailing special tokens.""" + cleaned = strip_leading_chat_scaffold(text, prompts=[GLM_OCR_OCR_PROMPT]) + for _ in range(8): + previous = cleaned + for token in ("<|endoftext|>", "<|assistant|>", "<|user|>", "<|system|>"): + if cleaned.endswith(token): + cleaned = cleaned[: -len(token)].rstrip() + break + if cleaned == previous: + break + return cleaned.strip() + + +def glm_ocr_image_preprocessor(image: Image.Image) -> Image.Image: + """Resize GLM-OCR inputs to stay within vLLM's encoder image-item budget.""" + prepared = prepare_ocr_image(image) + width, height = prepared.size + if width < GLM_OCR_IMAGE_GRID_SIZE or height < GLM_OCR_IMAGE_GRID_SIZE: + return prepared + + rounded_width = round(width / GLM_OCR_IMAGE_GRID_SIZE) * GLM_OCR_IMAGE_GRID_SIZE + rounded_height = round(height / GLM_OCR_IMAGE_GRID_SIZE) * GLM_OCR_IMAGE_GRID_SIZE + if GLM_OCR_TEMPORAL_PATCH_SIZE * rounded_width * rounded_height <= GLM_OCR_VLLM_MAX_PIXELS: + return prepared + + scale = math.sqrt((GLM_OCR_TEMPORAL_PATCH_SIZE * width * height) / GLM_OCR_VLLM_MAX_PIXELS) + target_width = max( + GLM_OCR_IMAGE_GRID_SIZE, + math.floor(width / scale / GLM_OCR_IMAGE_GRID_SIZE) * GLM_OCR_IMAGE_GRID_SIZE, + ) + target_height = max( + GLM_OCR_IMAGE_GRID_SIZE, + math.floor(height / scale / GLM_OCR_IMAGE_GRID_SIZE) * GLM_OCR_IMAGE_GRID_SIZE, + ) + return prepared.resize((target_width, target_height), resample=Image.Resampling.LANCZOS) + + def paddleocr_vl_text_postprocessor(text: str) -> str: """Strip PaddleOCR-VL prompt echoes and leading chat scaffold from OCR output.""" return strip_leading_chat_scaffold(text, prompts=[PADDLEOCR_VL_1_5_OCR_PROMPT]) diff --git a/src/churro_ocr/providers/builder.py b/src/churro_ocr/providers/builder.py index 016eb0c..f42e8dc 100644 --- a/src/churro_ocr/providers/builder.py +++ b/src/churro_ocr/providers/builder.py @@ -11,6 +11,7 @@ DeepSeekOCR2OCRBackend, DotsMOCROCRBackend, DotsOCR15OCRBackend, + GlmOCROCRBackend, HuggingFaceVisionOCRBackend, LFM25VLOCRBackend, MinerU25OCRBackend, @@ -196,6 +197,8 @@ def _build_huggingface_backend(spec: OCRBackendSpec, profile: OCRModelProfile) - if options.backend_variant == "dots-mocr": backend_cls = DotsMOCROCRBackend model_kwargs = _merge_mapping(_default_dots_ocr_1_5_model_kwargs(), model_kwargs) + elif options.backend_variant == "glm-ocr": + backend_cls = GlmOCROCRBackend elif options.backend_variant == "deepseek-ocr-2": backend_cls = DeepSeekOCR2OCRBackend elif options.backend_variant == "chandra-ocr-2": diff --git a/src/churro_ocr/providers/hf.py b/src/churro_ocr/providers/hf.py index cc36b46..7877fe5 100644 --- a/src/churro_ocr/providers/hf.py +++ b/src/churro_ocr/providers/hf.py @@ -43,6 +43,8 @@ DOTS_MOCR_OCR_TEMPLATE, DOTS_OCR_1_5_MODEL_ID, DOTS_OCR_1_5_OCR_TEMPLATE, + GLM_OCR_MODEL_ID, + GLM_OCR_OCR_TEMPLATE, LFM2_5_VL_1_6B_MODEL_ID, LFM2_5_VL_1_6B_OCR_TEMPLATE, MINERU2_5_2509_1_2B_FORMULA_TEMPLATE, @@ -537,6 +539,139 @@ class Churro3BOCRBackend(HuggingFaceVisionOCRBackend): model_name: str | None = "churro-3B" +@dataclass(slots=True) +class GlmOCROCRBackend(HuggingFaceVisionOCRBackend): + """Preset OCR backend for ``zai-org/GLM-OCR``.""" + + model_id: str = GLM_OCR_MODEL_ID + template: OCRPromptTemplateLike = GLM_OCR_OCR_TEMPLATE + model_name: str | None = "GLM-OCR" + generation_kwargs: dict[str, object] = field( + default_factory=lambda: {"max_new_tokens": 8_192, "do_sample": False} + ) + + def _get_processor(self, runtime: _HFRuntime) -> object: + processor = HuggingFaceVisionOCRBackend._get_processor(self, runtime) + tokenizer = getattr(processor, "tokenizer", None) + if tokenizer is not None and getattr(tokenizer, "padding_side", None) != "left": + tokenizer.padding_side = "left" + return processor + + def _get_model(self, runtime: _HFRuntime) -> object: + model = HuggingFaceVisionOCRBackend._get_model(self, runtime) + eval_method = getattr(model, "eval", None) + if callable(eval_method): + eval_method() + return model + + def _build_glm_batch( + self, + processor: object, + conversations: OCRConversation | list[OCRConversation], + *, + padding: bool, + ) -> dict[str, object]: + processor_apply = getattr(processor, "apply_chat_template", None) + if not callable(processor_apply): + message = "GLM-OCR requires `processor.apply_chat_template(...)` support." + raise _configuration_error(message) + return _apply_chat_template( + processor, + conversations, + add_generation_prompt=True, + tokenize=True, + return_dict=True, + return_tensors="pt", + padding=padding, + ) + + def _generation_inputs(self, batch: object) -> dict[str, object]: + generation_inputs = HuggingFaceVisionOCRBackend._generation_inputs(self, batch) + generation_inputs.pop("token_type_ids", None) + return generation_inputs + + def _ocr_sync(self, page: DocumentPage) -> OCRResult: + prepared_page = preprocess_backend_page( + page, + image_preprocessor=self.image_preprocessor, + ) + runtime = self._load_runtime() + processor = self._get_processor(runtime) + model = self._get_model(runtime) + + rendered, conversation = render_ocr_prompt( + processor, + self.template, + prepared_page, + add_generation_prompt=True, + ) + self._log_prompt_payload( + rendered_prompt=rendered, + conversation=conversation, + batch_size=1, + ) + batch = self._build_glm_batch(processor, conversation, padding=False) + batch = _move_batch_to_model(batch, model) + generated_ids = _generate_with_model( + model, + **self._generation_inputs(batch), + **self.generation_kwargs, + ) + text = _decode_completion_texts(processor, batch, generated_ids)[0] + return build_ocr_result( + text, + provider_name=self.provider_name, + model_name=self.model_name or self.model_id, + text_postprocessor=self.text_postprocessor, + ) + + def _ocr_batch_sync(self, pages: list[DocumentPage]) -> list[OCRResult]: + if not pages: + return [] + + runtime = self._load_runtime() + processor = self._get_processor(runtime) + model = self._get_model(runtime) + conversations: list[OCRConversation] = [] + + for page in pages: + prepared_page = preprocess_backend_page( + page, + image_preprocessor=self.image_preprocessor, + ) + rendered, conversation = render_ocr_prompt( + processor, + self.template, + prepared_page, + add_generation_prompt=True, + ) + conversations.append(conversation) + if not self._has_logged_prompt: + self._log_prompt_payload( + rendered_prompt=rendered, + conversation=conversation, + batch_size=len(pages), + ) + + batch = self._build_glm_batch(processor, conversations, padding=True) + batch = _move_batch_to_model(batch, model) + generated_ids = _generate_with_model( + model, + **self._generation_inputs(batch), + **self.generation_kwargs, + ) + texts = _decode_completion_texts(processor, batch, generated_ids) + return [ + build_ocr_result( + text, + provider_name=self.provider_name, + model_name=self.model_name or self.model_id, + text_postprocessor=self.text_postprocessor, + ) + for text in texts + ] + + @dataclass(slots=True) class DeepSeekOCR2OCRBackend(HuggingFaceVisionOCRBackend): """Preset OCR backend for ``deepseek-ai/DeepSeek-OCR-2``.""" @@ -724,6 +859,7 @@ class DotsMOCROCRBackend(DotsOCR15OCRBackend): template: OCRPromptTemplateLike = DOTS_MOCR_OCR_TEMPLATE model_name: str | None = "dots.mocr" + @dataclass(slots=True) class MinerU25OCRBackend(HuggingFaceVisionOCRBackend): """Preset OCR backend for ``opendatalab/MinerU2.5-2509-1.2B``.""" @@ -770,9 +906,9 @@ def _infer_step( self, *, context: _mineru._MinerU25StepContext, - image: Image.Image, - step_key: str, -) -> str: + image: Image.Image, + step_key: str, + ) -> str: rendered, conversation = render_ocr_prompt( context.processor, _mineru._template_for_step( @@ -1174,6 +1310,7 @@ def _ocr_batch_sync(self, pages: list[DocumentPage]) -> list[OCRResult]: "DeepSeekOCR2OCRBackend", "DotsMOCROCRBackend", "DotsOCR15OCRBackend", + "GlmOCROCRBackend", "HuggingFaceVisionOCRBackend", "LFM25VLOCRBackend", "MinerU25OCRBackend", diff --git a/src/churro_ocr/providers/specs.py b/src/churro_ocr/providers/specs.py index 86105fe..894e789 100644 --- a/src/churro_ocr/providers/specs.py +++ b/src/churro_ocr/providers/specs.py @@ -16,6 +16,8 @@ deepseek_ocr_2_text_postprocessor, default_ocr_image_preprocessor, default_ocr_text_postprocessor, + glm_ocr_image_preprocessor, + glm_ocr_text_postprocessor, identity_text_postprocessor, infinity_parser_7b_text_postprocessor, lfm2_5_vl_text_postprocessor, @@ -35,6 +37,8 @@ DOTS_MOCR_OCR_TEMPLATE, DOTS_OCR_1_5_MODEL_ID, DOTS_OCR_1_5_OCR_TEMPLATE, + GLM_OCR_MODEL_ID, + GLM_OCR_OCR_TEMPLATE, INFINITY_PARSER_7B_MODEL_ID, INFINITY_PARSER_7B_OCR_TEMPLATE, LFM2_5_VL_1_6B_MODEL_ID, @@ -64,6 +68,7 @@ DEFAULT_OCR_MAX_TOKENS = 25_000 CHANDRA_OCR_MAX_TOKENS = 12_384 DEEPSEEK_OCR_2_MAX_TOKENS = 8_192 +GLM_OCR_MAX_TOKENS = 8_192 INFINITY_PARSER_7B_MAX_TOKENS = 8_192 OLMOCR_MAX_TOKENS = 8_000 PADDLEOCR_VL_MAX_TOKENS = 4_096 @@ -283,6 +288,30 @@ def deepseek_ocr_2_profile() -> OCRModelProfile: ) +def glm_ocr_profile() -> OCRModelProfile: + """Return the built-in ``zai-org/GLM-OCR`` OCR profile.""" + return OCRModelProfile( + profile_name=GLM_OCR_MODEL_ID, + template=GLM_OCR_OCR_TEMPLATE, + image_preprocessor=glm_ocr_image_preprocessor, + text_postprocessor=glm_ocr_text_postprocessor, + display_name="GLM-OCR", + transport=LiteLLMTransportConfig( + completion_kwargs={ + "max_tokens": GLM_OCR_MAX_TOKENS, + "temperature": 0.0, + } + ), + huggingface=HuggingFaceOptions( + generation_kwargs={ + "max_new_tokens": GLM_OCR_MAX_TOKENS, + "do_sample": False, + }, + backend_variant="glm-ocr", + ), + ) + + def dots_ocr_1_5_profile() -> OCRModelProfile: """Return the built-in ``kristaller486/dots.ocr-1.5`` OCR profile. @@ -455,6 +484,7 @@ def _profile_registry() -> dict[str, OCRModelProfile]: churro_profile = churro_3b_profile() chandra_profile = chandra_ocr_2_profile() deepseek_profile = deepseek_ocr_2_profile() + glm_profile = glm_ocr_profile() dots_mocr = dots_mocr_profile() dots_profile = dots_ocr_1_5_profile() infinity_parser_profile = infinity_parser_7b_profile() @@ -468,6 +498,7 @@ def _profile_registry() -> dict[str, OCRModelProfile]: churro_profile.profile_name: churro_profile, chandra_profile.profile_name: chandra_profile, deepseek_profile.profile_name: deepseek_profile, + glm_profile.profile_name: glm_profile, dots_mocr.profile_name: dots_mocr, dots_profile.profile_name: dots_profile, infinity_parser_profile.profile_name: infinity_parser_profile, @@ -530,6 +561,9 @@ def resolve_ocr_profile( "default_ocr_image_preprocessor", "default_ocr_profile", "default_ocr_text_postprocessor", + "glm_ocr_image_preprocessor", + "glm_ocr_profile", + "glm_ocr_text_postprocessor", "identity_text_postprocessor", "infinity_parser_7b_profile", "infinity_parser_7b_text_postprocessor", diff --git a/src/churro_ocr/templates/__init__.py b/src/churro_ocr/templates/__init__.py index acdf42c..f1a39f9 100644 --- a/src/churro_ocr/templates/__init__.py +++ b/src/churro_ocr/templates/__init__.py @@ -23,6 +23,9 @@ DOTS_OCR_1_5_MODEL_ID, DOTS_OCR_1_5_OCR_PROMPT, DOTS_OCR_1_5_OCR_TEMPLATE, + GLM_OCR_MODEL_ID, + GLM_OCR_OCR_PROMPT, + GLM_OCR_OCR_TEMPLATE, INFINITY_PARSER_7B_MODEL_ID, INFINITY_PARSER_7B_OCR_PROMPT, INFINITY_PARSER_7B_OCR_TEMPLATE, @@ -64,6 +67,9 @@ "DOTS_OCR_1_5_MODEL_ID", "DOTS_OCR_1_5_OCR_PROMPT", "DOTS_OCR_1_5_OCR_TEMPLATE", + "GLM_OCR_MODEL_ID", + "GLM_OCR_OCR_PROMPT", + "GLM_OCR_OCR_TEMPLATE", "INFINITY_PARSER_7B_MODEL_ID", "INFINITY_PARSER_7B_OCR_PROMPT", "INFINITY_PARSER_7B_OCR_TEMPLATE", diff --git a/src/churro_ocr/templates/presets.py b/src/churro_ocr/templates/presets.py index 82a92cb..9fb06b1 100644 --- a/src/churro_ocr/templates/presets.py +++ b/src/churro_ocr/templates/presets.py @@ -13,6 +13,7 @@ CHURRO_3B_MODEL_ID = "stanford-oval/churro-3B" CHANDRA_OCR_2_MODEL_ID = "datalab-to/chandra-ocr-2" DEEPSEEK_OCR_2_MODEL_ID = "deepseek-ai/DeepSeek-OCR-2" +GLM_OCR_MODEL_ID = "zai-org/GLM-OCR" DOTS_OCR_1_5_MODEL_ID = "kristaller486/dots.ocr-1.5" DOTS_MOCR_MODEL_ID = "rednote-hilab/dots.mocr" INFINITY_PARSER_7B_MODEL_ID = "infly/Infinity-Parser-7B" @@ -39,6 +40,11 @@ system_message=None, user_prompt=DEEPSEEK_OCR_2_OCR_PROMPT, ) +GLM_OCR_OCR_PROMPT = "Text Recognition:" +GLM_OCR_OCR_TEMPLATE = HFChatTemplate( + system_message=None, + user_prompt=GLM_OCR_OCR_PROMPT, +) DOTS_OCR_1_5_OCR_PROMPT = "Extract the text content from this image." DOTS_OCR_1_5_OCR_TEMPLATE = HFChatTemplate( system_message=None, @@ -121,6 +127,9 @@ "DOTS_OCR_1_5_MODEL_ID", "DOTS_OCR_1_5_OCR_PROMPT", "DOTS_OCR_1_5_OCR_TEMPLATE", + "GLM_OCR_MODEL_ID", + "GLM_OCR_OCR_PROMPT", + "GLM_OCR_OCR_TEMPLATE", "INFINITY_PARSER_7B_MODEL_ID", "INFINITY_PARSER_7B_OCR_PROMPT", "INFINITY_PARSER_7B_OCR_TEMPLATE", diff --git a/tests/test_cli.py b/tests/test_cli.py index f87372e..c8b0ae0 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -25,6 +25,8 @@ DEFAULT_OCR_TEMPLATE, DOTS_MOCR_OCR_TEMPLATE, DOTS_OCR_1_5_OCR_TEMPLATE, + GLM_OCR_MODEL_ID, + GLM_OCR_OCR_TEMPLATE, INFINITY_PARSER_7B_MODEL_ID, INFINITY_PARSER_7B_OCR_TEMPLATE, MINERU2_5_2509_1_2B_MODEL_ID, @@ -278,6 +280,26 @@ def test_build_ocr_backend_aligns_templates_for_deepseek_ocr_2() -> None: } +def test_build_ocr_backend_aligns_templates_for_glm_ocr() -> None: + litellm_backend = _build_litellm_backend(GLM_OCR_MODEL_ID) + hf_backend = _build_hf_backend(GLM_OCR_MODEL_ID) + openai_backend = _build_openai_compatible_backend(GLM_OCR_MODEL_ID) + + assert litellm_backend.template == GLM_OCR_OCR_TEMPLATE + assert litellm_backend.template == hf_backend.template == openai_backend.template + assert litellm_backend.model_name == "GLM-OCR" + assert hf_backend.model_name == "GLM-OCR" + assert openai_backend.model_name == "GLM-OCR" + assert litellm_backend.transport.config.completion_kwargs == { + "max_tokens": 8_192, + "temperature": 0.0, + } + assert openai_backend.transport.config.completion_kwargs == { + "max_tokens": 8_192, + "temperature": 0.0, + } + + def test_build_ocr_backend_aligns_templates_for_paddleocr_vl() -> None: litellm_backend = _build_litellm_backend(PADDLEOCR_VL_1_5_MODEL_ID) hf_backend = _build_hf_backend(PADDLEOCR_VL_1_5_MODEL_ID) diff --git a/tests/test_hf_ocr.py b/tests/test_hf_ocr.py index 9d48eda..08a9e87 100644 --- a/tests/test_hf_ocr.py +++ b/tests/test_hf_ocr.py @@ -31,6 +31,7 @@ DeepSeekOCR2OCRBackend, DotsMOCROCRBackend, DotsOCR15OCRBackend, + GlmOCROCRBackend, HuggingFaceVisionOCRBackend, LFM25VLOCRBackend, MinerU25OCRBackend, @@ -39,6 +40,7 @@ from churro_ocr.providers.specs import ( DEFAULT_OCR_MAX_TOKENS, deepseek_ocr_2_text_postprocessor, + glm_ocr_text_postprocessor, infinity_parser_7b_text_postprocessor, lfm2_5_vl_text_postprocessor, ) @@ -55,6 +57,9 @@ DOTS_OCR_1_5_MODEL_ID, DOTS_OCR_1_5_OCR_PROMPT, DOTS_OCR_1_5_OCR_TEMPLATE, + GLM_OCR_MODEL_ID, + GLM_OCR_OCR_PROMPT, + GLM_OCR_OCR_TEMPLATE, INFINITY_PARSER_7B_MODEL_ID, INFINITY_PARSER_7B_OCR_PROMPT, INFINITY_PARSER_7B_OCR_TEMPLATE, @@ -345,6 +350,13 @@ def test_deepseek_ocr_2_text_postprocessor_strips_prompt_echo_and_stop_token() - ) +def test_glm_ocr_text_postprocessor_strips_prompt_echo_and_trailing_tokens() -> None: + assert ( + glm_ocr_text_postprocessor("Text Recognition:\n<|assistant|>\nplain text\n<|user|>\n<|endoftext|>") + == "plain text" + ) + + def test_build_ocr_backend_uses_chandra_profile_defaults_for_hf() -> None: backend = cast( "ChandraOCR2OCRBackend", @@ -388,6 +400,35 @@ def test_build_ocr_backend_uses_deepseek_ocr_2_profile_defaults_for_hf() -> None assert backend.crop_mode is True +def test_build_ocr_backend_uses_glm_ocr_profile_defaults_for_hf() -> None: + backend = cast( + "GlmOCROCRBackend", + build_ocr_backend( + OCRBackendSpec( + provider="hf", + model=GLM_OCR_MODEL_ID, + ) + ), + ) + + assert isinstance(backend, GlmOCROCRBackend) + assert backend.template == GLM_OCR_OCR_TEMPLATE + assert backend.model_name == "GLM-OCR" + assert backend.generation_kwargs == { + "max_new_tokens": 8_192, + "do_sample": False, + } + assert backend.trust_remote_code is False + assert backend.processor_kwargs == {} + assert backend.model_kwargs == {} + preprocessed_image = backend.image_preprocessor( + Image.new("RGBA", (3_508, 2_720), color=(255, 255, 255, 255)) + ) + assert preprocessed_image.size == (2_464, 1_904) + assert preprocessed_image.mode == "RGB" + assert (preprocessed_image.size[0] // 28) * (preprocessed_image.size[1] // 28) <= 6_084 + + def test_build_ocr_backend_uses_olmocr_profile_defaults_for_hf() -> None: backend = cast( "HuggingFaceVisionOCRBackend", @@ -776,6 +817,160 @@ def from_pretrained(model_id: str, **kwargs: object) -> FakeModel: assert captured["output_dir_exists"] is True +@pytest.mark.asyncio +async def test_glm_ocr_huggingface_backend_uses_tokenized_chat_template_and_profile_defaults( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured: dict[str, Any] = {} + + class FakeAttentionMask: + def sum(self, dim: int) -> SimpleNamespace: + captured["attention_mask_sum_dim"] = dim + return SimpleNamespace(tolist=lambda: [4]) + + class FakeBatch(dict[str, object]): + def to(self, device: object) -> FakeBatch: + captured["device"] = device + return self + + class FakeTokenizer: + def __init__(self) -> None: + self.padding_side = "right" + + class FakeProcessor: + def __init__(self) -> None: + self.tokenizer = FakeTokenizer() + + def apply_chat_template( + self, + conversation: object, + *, + add_generation_prompt: bool, + tokenize: bool, + return_dict: bool | None = None, + return_tensors: str | None = None, + padding: bool | None = None, + ) -> object: + captured.setdefault("chat_calls", []).append( + { + "conversation": conversation, + "add_generation_prompt": add_generation_prompt, + "tokenize": tokenize, + "return_dict": return_dict, + "return_tensors": return_tensors, + "padding": padding, + } + ) + if not tokenize: + return "" + return FakeBatch( + { + "input_ids": SimpleNamespace(shape=(1, 4)), + "attention_mask": FakeAttentionMask(), + "token_type_ids": "unused-token-type-ids", + "mm_token_type_ids": "kept-mm-token-type-ids", + } + ) + + def __call__(self, **kwargs: object) -> object: + del kwargs + message = "processor(...) should not be used for GLM-OCR" + raise AssertionError(message) + + def batch_decode( + self, + generated_ids: object, + *, + skip_special_tokens: bool, + clean_up_tokenization_spaces: bool, + ) -> list[str]: + captured["generated_ids"] = generated_ids + captured["skip_special_tokens"] = skip_special_tokens + captured["clean_up_tokenization_spaces"] = clean_up_tokenization_spaces + return ["Text Recognition:\n<|assistant|>\nglm transcription\n<|user|>"] + + class FakeProcessorCls: + @staticmethod + def from_pretrained(model_id: str, **kwargs: object) -> FakeProcessor: + captured["processor_model_id"] = model_id + captured["processor_from_pretrained_kwargs"] = kwargs + return FakeProcessor() + + class FakeModel: + device = "fake-device" + dtype = None + + def eval(self) -> FakeModel: + captured["eval_called"] = True + return self + + def generate(self, **kwargs: object) -> list[list[int | str]]: + captured["generate_kwargs"] = kwargs + return [[0, 1, 2, 3, "completion"]] + + class FakeModelCls: + @staticmethod + def from_pretrained(model_id: str, **kwargs: object) -> FakeModel: + captured["model_model_id"] = model_id + captured["model_from_pretrained_kwargs"] = kwargs + return FakeModel() + + monkeypatch.setattr( + "churro_ocr.providers.hf._load_hf_runtime", + lambda: SimpleNamespace( + processor_cls=FakeProcessorCls, + model_cls=FakeModelCls, + process_vision_info=None, + ), + ) + + backend = cast( + "GlmOCROCRBackend", + build_ocr_backend( + OCRBackendSpec( + provider="hf", + model=GLM_OCR_MODEL_ID, + ) + ), + ) + result = await backend.ocr(DocumentPage.from_image(Image.new("RGB", (32, 32), color="white"))) + + assert result.text == "glm transcription" + assert captured["processor_model_id"] == GLM_OCR_MODEL_ID + assert captured["model_model_id"] == GLM_OCR_MODEL_ID + assert captured["processor_from_pretrained_kwargs"] == {"trust_remote_code": False} + assert captured["model_from_pretrained_kwargs"] == {"trust_remote_code": False} + assert captured["eval_called"] is True + assert cast("FakeProcessor", backend._processor).tokenizer.padding_side == "left" + assert len(cast("list[dict[str, object]]", captured["chat_calls"])) == 2 + assert cast("list[dict[str, object]]", captured["chat_calls"])[0]["tokenize"] is False + assert cast("list[dict[str, object]]", captured["chat_calls"])[1]["tokenize"] is True + assert cast("list[dict[str, object]]", captured["chat_calls"])[1]["return_dict"] is True + assert cast("list[dict[str, object]]", captured["chat_calls"])[1]["return_tensors"] == "pt" + assert cast("list[dict[str, object]]", captured["chat_calls"])[1]["padding"] is False + render_conversation = cast("list[dict[str, object]]", captured["chat_calls"])[0]["conversation"] + assert cast("list[dict[str, object]]", render_conversation)[0]["role"] == "user" + render_content = cast( + "list[dict[str, object]]", + cast("list[dict[str, object]]", render_conversation)[0]["content"], + ) + assert render_content[0]["type"] == "image" + assert render_content[1] == {"type": "text", "text": GLM_OCR_OCR_PROMPT} + assert captured["device"] == "fake-device" + assert captured["attention_mask_sum_dim"] == 1 + assert captured["generate_kwargs"] == { + "input_ids": SimpleNamespace(shape=(1, 4)), + "attention_mask": cast("object", captured["generate_kwargs"]["attention_mask"]), + "mm_token_type_ids": "kept-mm-token-type-ids", + "max_new_tokens": 8_192, + "do_sample": False, + } + assert "token_type_ids" not in cast("dict[str, object]", captured["generate_kwargs"]) + assert captured["generated_ids"] == [["completion"]] + assert captured["skip_special_tokens"] is True + assert captured["clean_up_tokenization_spaces"] is False + + @pytest.mark.asyncio async def test_lfm25_huggingface_backend_uses_tokenized_chat_template_and_ties_lm_head( monkeypatch: pytest.MonkeyPatch, diff --git a/tests/test_tooling_benchmark.py b/tests/test_tooling_benchmark.py index d7c819a..4f1fa9f 100644 --- a/tests/test_tooling_benchmark.py +++ b/tests/test_tooling_benchmark.py @@ -8,13 +8,14 @@ from PIL import Image from churro_ocr.ocr import OCRResult -from churro_ocr.providers.hf import HuggingFaceVisionOCRBackend +from churro_ocr.providers.hf import GlmOCROCRBackend, HuggingFaceVisionOCRBackend from churro_ocr.providers.specs import DEFAULT_OCR_MAX_TOKENS from churro_ocr.templates import ( CHURRO_3B_XML_TEMPLATE, DEEPSEEK_OCR_2_OCR_TEMPLATE, DOTS_MOCR_OCR_TEMPLATE, DOTS_OCR_1_5_OCR_TEMPLATE, + GLM_OCR_OCR_TEMPLATE, INFINITY_PARSER_7B_OCR_TEMPLATE, MINERU2_5_2509_1_2B_OCR_TEMPLATE, PADDLEOCR_VL_1_5_OCR_TEMPLATE, @@ -453,6 +454,55 @@ def test_build_ocr_backend_uses_deepseek_ocr_2_preset_for_openai_compatible() -> } +def test_build_ocr_backend_uses_glm_ocr_preset_for_hf() -> None: + backend = cast( + "HuggingFaceVisionOCRBackend", + benchmark._build_ocr_backend( + benchmark.BenchmarkOptions( + backend="hf", + dataset_split="dev", + model="zai-org/GLM-OCR", + ) + ), + ) + + assert isinstance(backend, GlmOCROCRBackend) + assert backend.model_name == "GLM-OCR" + assert backend.trust_remote_code is False + assert backend.processor_kwargs == {} + assert backend.model_kwargs == {"device_map": "auto", "torch_dtype": "auto"} + assert backend.generation_kwargs == { + "max_new_tokens": 8_192, + "do_sample": False, + } + + +def test_build_ocr_backend_uses_glm_ocr_preset_for_openai_compatible() -> None: + backend = cast( + "LiteLLMVisionOCRBackend", + benchmark._build_ocr_backend( + benchmark.BenchmarkOptions( + backend="openai-compatible", + dataset_split="dev", + model="zai-org/GLM-OCR", + base_url="http://127.0.0.1:8000/v1", + ) + ), + ) + + assert backend.provider_name == "openai-compatible" + assert backend.model_name == "GLM-OCR" + assert backend.template == GLM_OCR_OCR_TEMPLATE + assert backend.transport.config.completion_kwargs == { + "max_tokens": 8_192, + "temperature": 0.0, + } + assert backend.image_preprocessor(Image.new("RGB", (3_508, 2_720), color="white")).size == ( + 2_464, + 1_904, + ) + + def test_build_ocr_backend_uses_paddleocr_vl_preset_for_hf() -> None: backend = cast( "HuggingFaceVisionOCRBackend", From 4c1981bbc7dd1d4604c4fc2dea5d481f17ded46c Mon Sep 17 00:00:00 2001 From: Sina Date: Mon, 13 Apr 2026 22:49:51 +0000 Subject: [PATCH 44/58] fix(internal): handle unmapped LiteLLM models --- src/churro_ocr/_internal/litellm.py | 17 +++++++++++++++++ tests/test_internal_helpers.py | 18 ++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/src/churro_ocr/_internal/litellm.py b/src/churro_ocr/_internal/litellm.py index d1168b8..1688cb8 100644 --- a/src/churro_ocr/_internal/litellm.py +++ b/src/churro_ocr/_internal/litellm.py @@ -397,10 +397,15 @@ def _extract_response_cost(*, model: str, response: object) -> float | None: return float(raw_cost) try: + import litellm from litellm import completion_cost except ImportError: return None + model_cost = getattr(cast("Any", litellm), "model_cost", None) + if not _is_litellm_cost_mapped(model, model_cost=model_cost): + return None + try: cost = completion_cost(completion_response=response, model=model) except (AttributeError, KeyError, LookupError, TypeError, ValueError): @@ -410,6 +415,18 @@ def _extract_response_cost(*, model: str, response: object) -> float | None: return float(cost) +def _is_litellm_cost_mapped(model: str, *, model_cost: object) -> bool: + """Return whether LiteLLM exposes a cost-map entry for ``model``.""" + if not isinstance(model_cost, dict): + return True + + known_models = {str(key).casefold() for key in model_cost} + candidates = {model.casefold()} + if "/" in model: + candidates.add(model.split("/", 1)[1].casefold()) + return any(candidate in known_models for candidate in candidates) + + def _prepare_messages_from_conversation( conversation: OCRConversation, *, diff --git a/tests/test_internal_helpers.py b/tests/test_internal_helpers.py index 7a67c02..347af01 100644 --- a/tests/test_internal_helpers.py +++ b/tests/test_internal_helpers.py @@ -43,6 +43,7 @@ def _make_fake_litellm_module(*, acompletion: object, completion_cost: object | module = cast("Any", ModuleType("litellm")) module.acompletion = acompletion module.completion_cost = completion_cost or (lambda **_: None) + module.model_cost = {"example/model": {}} module.turn_off_message_logging = False module.success_callback = ["stale"] module.failure_callback = ["stale"] @@ -239,6 +240,23 @@ def test_extract_response_cost_returns_none_for_non_numeric_fallback(monkeypatch assert cost is None +def test_extract_response_cost_returns_none_for_unmapped_model( + monkeypatch: pytest.MonkeyPatch, +) -> None: + completion_cost_calls: list[dict[str, object]] = [] + fake_module = _make_fake_litellm_module( + acompletion=lambda **_: None, + completion_cost=lambda **kwargs: completion_cost_calls.append(kwargs) or 0.75, + ) + cast("Any", fake_module).model_cost = {} + monkeypatch.setitem(sys.modules, "litellm", fake_module) + + cost = litellm_module._extract_response_cost(model="openai/example/model", response=SimpleNamespace()) + + assert cost is None + assert completion_cost_calls == [] + + def test_ensure_initialized_wraps_logging_worker_when_present(monkeypatch: pytest.MonkeyPatch) -> None: fake_module = _make_fake_litellm_module(acompletion=lambda **_: None) worker_calls: list[object] = [] From 3aa0adad57c032ff4328960088f184c91cee6452 Mon Sep 17 00:00:00 2001 From: Sina Date: Mon, 13 Apr 2026 22:50:00 +0000 Subject: [PATCH 45/58] style(repo): apply formatting cleanup --- src/churro_ocr/providers/_page_detection_helpers.py | 4 +--- tests/test_install.py | 2 ++ 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/churro_ocr/providers/_page_detection_helpers.py b/src/churro_ocr/providers/_page_detection_helpers.py index 62edba6..26e82fa 100644 --- a/src/churro_ocr/providers/_page_detection_helpers.py +++ b/src/churro_ocr/providers/_page_detection_helpers.py @@ -625,9 +625,7 @@ def _merge_instruction_prompts(*parts: str | None) -> str: return "\n\n".join(merged_parts) -def _normalize_azure_page_polygon( - page: _AzurePageLike, *, image: Image.Image -) -> Polygon: +def _normalize_azure_page_polygon(page: _AzurePageLike, *, image: Image.Image) -> Polygon: raw_polygon = getattr(page, "polygon", None) polygon = _normalize_polygon(raw_polygon) if not polygon: diff --git a/tests/test_install.py b/tests/test_install.py index 9079ec0..2d2e5b7 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -18,6 +18,7 @@ def test_install_runtime_dependencies_installs_hf_and_torch_with_uv( monkeypatch: pytest.MonkeyPatch, ) -> None: commands: list[list[str]] = [] + def _run(command: list[str], *, check: bool = True) -> SimpleNamespace: _ = check commands.append(list(command)) @@ -71,6 +72,7 @@ def test_install_runtime_dependencies_installs_local_client_with_uv( monkeypatch: pytest.MonkeyPatch, ) -> None: commands: list[list[str]] = [] + def _run(command: list[str], *, check: bool = True) -> SimpleNamespace: _ = check commands.append(list(command)) From ff7d323b116635b1a4ba9fb58affdcfb21bca67f Mon Sep 17 00:00:00 2001 From: Sina Date: Mon, 13 Apr 2026 22:50:06 +0000 Subject: [PATCH 46/58] docs(leaderboard): add GLM OCR entry and logo --- benchmark_results.json | 9 ++++ docs/_static/img/glm-ocr-logo.svg | 69 +++++++++++++++++++++++++++++++ docs/conf.py | 1 + 3 files changed, 79 insertions(+) create mode 100644 docs/_static/img/glm-ocr-logo.svg diff --git a/benchmark_results.json b/benchmark_results.json index a67f345..318865a 100644 --- a/benchmark_results.json +++ b/benchmark_results.json @@ -508,5 +508,14 @@ "printed": 9.0269, "handwritten": 5.3393, "total": 7.0413 + }, + { + "modelName": "GLM OCR", + "modelId": "zai-org/GLM-OCR", + "modelUrl": "https://huggingface.co/zai-org/GLM-OCR", + "iconPath": "_static/img/glm-ocr-logo.svg", + "printed": 65.2, + "handwritten": 40.2, + "total": 51.8 } ] diff --git a/docs/_static/img/glm-ocr-logo.svg b/docs/_static/img/glm-ocr-logo.svg new file mode 100644 index 0000000..aa32441 --- /dev/null +++ b/docs/_static/img/glm-ocr-logo.svg @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/conf.py b/docs/conf.py index 1930332..66322e0 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -50,6 +50,7 @@ autodoc_preserve_defaults = True autodoc_typehints = "description" autoclass_content = "both" +python_use_unqualified_type_names = False napoleon_google_docstring = False napoleon_numpy_docstring = False From 8918b2e23059a14656dd805be03e08341d5cb398 Mon Sep 17 00:00:00 2001 From: Sina Date: Mon, 13 Apr 2026 23:23:21 +0000 Subject: [PATCH 47/58] feat(leaderboard): enhance benchmark leaderboard with expandable per-language scores --- benchmark_results.json | 2636 +++++++++++++++++++++- docs/_static/css/custom.css | 84 +- docs/_static/js/benchmark-leaderboard.js | 284 ++- docs/leaderboard.md | 2 + 4 files changed, 2911 insertions(+), 95 deletions(-) diff --git a/benchmark_results.json b/benchmark_results.json index 318865a..9ef7851 100644 --- a/benchmark_results.json +++ b/benchmark_results.json @@ -6,7 +6,52 @@ "iconPath": "_static/img/churro.png", "printed": 82.3309, "handwritten": 70.0965, - "total": 75.7431 + "total": 75.7431, + "main_language_and_type_metrics": { + "print": { + "Bangla": 70.9, + "Bulgarian": 96.1, + "Chinese": 6.2, + "Czech": 95.6, + "Dutch": 95.7, + "English": 91.0, + "Finnish": 63.2, + "French": 89.6, + "German": 82.3, + "Hindi": 94.6, + "Japanese": 74.1, + "Latin": 92.0, + "Polish": 81.6, + "Romanian": 79.6, + "Sanskrit": 93.1, + "Slovenian": 97.6, + "Spanish": 91.4, + "Swedish": 87.2 + }, + "handwriting": { + "Arabic": 77.5, + "Catalan": 90.2, + "Chinese": 78.2, + "Dutch": 83.8, + "English": 84.0, + "French": 81.1, + "German": 83.1, + "Greek": 67.4, + "Hebrew": 42.3, + "Italian": 88.4, + "Japanese": 59.6, + "Khmer": 25.7, + "Latin": 70.9, + "Norwegian": 79.7, + "Persian": 78.0, + "Portuguese": 52.9, + "Sanskrit": 21.5, + "Spanish": 78.0, + "Swedish": 85.4, + "Turkish": 69.1, + "Vietnamese": 75.1 + } + } }, { "modelName": "Gemini 3 Flash", @@ -15,16 +60,106 @@ "iconPath": "_static/img/gemini-symbol.png", "printed": 82.6, "handwritten": 66.5, - "total": 73.9 + "total": 73.9, + "main_language_and_type_metrics": { + "print": { + "Bangla": 92.2, + "Bulgarian": 96.9, + "Chinese": 2.0, + "Czech": 97.8, + "Dutch": 97.2, + "English": 93.4, + "Finnish": 69.2, + "French": 87.5, + "German": 82.3, + "Hindi": 93.3, + "Japanese": 61.6, + "Latin": 87.7, + "Polish": 89.4, + "Romanian": 63.3, + "Sanskrit": 93.1, + "Slovenian": 97.7, + "Spanish": 91.0, + "Swedish": 91.0 + }, + "handwriting": { + "Arabic": 80.3, + "Catalan": 86.5, + "Chinese": 70.1, + "Dutch": 80.4, + "English": 85.6, + "French": 83.3, + "German": 81.4, + "Greek": 63.5, + "Hebrew": 48.9, + "Italian": 88.8, + "Japanese": 14.6, + "Khmer": 39.2, + "Latin": 69.3, + "Norwegian": 85.6, + "Persian": 82.0, + "Portuguese": 49.0, + "Sanskrit": 14.9, + "Spanish": 77.2, + "Swedish": 86.8, + "Turkish": 44.3, + "Vietnamese": 65.5 + } + } }, - { + { "modelName": "Gemini 3.1 Pro Preview", "modelId": "gemini-3.1-pro-preview", "modelUrl": "https://ai.google.dev/gemini-api/docs/models", "iconPath": "_static/img/gemini-symbol.png", "printed": 80.3, "handwritten": 68.3, - "total": 73.8 + "total": 73.8, + "main_language_and_type_metrics": { + "print": { + "Bangla": 93.5, + "Bulgarian": 96.3, + "Chinese": 8.1, + "Czech": 95.3, + "Dutch": 93.9, + "English": 93.0, + "Finnish": 63.9, + "French": 91.1, + "German": 84.7, + "Hindi": 79.8, + "Japanese": 67.1, + "Latin": 89.3, + "Polish": 82.2, + "Romanian": 52.4, + "Sanskrit": 87.1, + "Slovenian": 98.1, + "Spanish": 85.5, + "Swedish": 85.1 + }, + "handwriting": { + "Arabic": 87.5, + "Catalan": 87.6, + "Chinese": 76.2, + "Dutch": 84.1, + "English": 86.3, + "French": 85.0, + "German": 78.3, + "Greek": 62.9, + "Hebrew": 37.2, + "Italian": 91.7, + "Japanese": 22.0, + "Khmer": 40.1, + "Latin": 66.7, + "Norwegian": 93.2, + "Persian": 85.5, + "Portuguese": 52.9, + "Sanskrit": 15.4, + "Spanish": 78.2, + "Swedish": 83.2, + "Turkish": 45.0, + "Vietnamese": 74.9 + } + } }, { "modelName": "Gemini 3 Pro", @@ -33,7 +168,52 @@ "iconPath": "_static/img/gemini-symbol.png", "printed": 78.2, "handwritten": 66.7, - "total": 72.0 + "total": 72.0, + "main_language_and_type_metrics": { + "print": { + "Bangla": 93.1, + "Bulgarian": 96.9, + "Chinese": 8.4, + "Czech": 97.9, + "Dutch": 88.2, + "English": 93.6, + "Finnish": 49.8, + "French": 93.4, + "German": 76.8, + "Hindi": 76.5, + "Japanese": 63.7, + "Latin": 89.8, + "Polish": 88.0, + "Romanian": 42.4, + "Sanskrit": 82.8, + "Slovenian": 94.8, + "Spanish": 94.5, + "Swedish": 77.4 + }, + "handwriting": { + "Arabic": 86.8, + "Catalan": 89.8, + "Chinese": 74.9, + "Dutch": 85.4, + "English": 84.1, + "French": 84.7, + "German": 77.1, + "Greek": 61.5, + "Hebrew": 44.0, + "Italian": 88.8, + "Japanese": 21.7, + "Khmer": 35.6, + "Latin": 69.0, + "Norwegian": 87.8, + "Persian": 81.7, + "Portuguese": 45.3, + "Sanskrit": 14.5, + "Spanish": 74.1, + "Swedish": 89.4, + "Turkish": 39.4, + "Vietnamese": 66.1 + } + } }, { "modelName": "Gemini 2.5 Pro", @@ -42,7 +222,52 @@ "iconPath": "_static/img/gemini-symbol.png", "printed": 80.8523, "handwritten": 63.6329, - "total": 71.5803 + "total": 71.5803, + "main_language_and_type_metrics": { + "print": { + "Bangla": 94.5, + "Bulgarian": 93.4, + "Chinese": 6.6, + "Czech": 95.6, + "Dutch": 97.9, + "English": 93.3, + "Finnish": 55.3, + "French": 93.5, + "German": 76.9, + "Hindi": 93.2, + "Japanese": 61.7, + "Latin": 87.5, + "Polish": 80.3, + "Romanian": 66.3, + "Sanskrit": 89.7, + "Slovenian": 97.4, + "Spanish": 91.3, + "Swedish": 81.1 + }, + "handwriting": { + "Arabic": 87.0, + "Catalan": 87.9, + "Chinese": 74.6, + "Dutch": 78.6, + "English": 80.0, + "French": 74.5, + "German": 73.9, + "Greek": 54.6, + "Hebrew": 30.4, + "Italian": 88.0, + "Japanese": 18.0, + "Khmer": 24.6, + "Latin": 66.5, + "Norwegian": 87.7, + "Persian": 81.6, + "Portuguese": 45.9, + "Sanskrit": 15.8, + "Spanish": 75.5, + "Swedish": 76.1, + "Turkish": 45.7, + "Vietnamese": 69.3 + } + } }, { "modelName": "dots.mocr", @@ -51,7 +276,52 @@ "iconPath": "_static/img/dots-logo.png", "printed": 81.2, "handwritten": 55.0, - "total": 67.1 + "total": 67.1, + "main_language_and_type_metrics": { + "print": { + "Bangla": 86.9, + "Bulgarian": 96.0, + "Chinese": 3.5, + "Czech": 93.3, + "Dutch": 92.4, + "English": 87.0, + "Finnish": 64.6, + "French": 88.3, + "German": 79.6, + "Hindi": 94.2, + "Japanese": 74.7, + "Latin": 83.9, + "Polish": 88.8, + "Romanian": 62.5, + "Sanskrit": 92.1, + "Slovenian": 96.5, + "Spanish": 93.1, + "Swedish": 84.6 + }, + "handwriting": { + "Arabic": 67.1, + "Catalan": 81.1, + "Chinese": 69.0, + "Dutch": 72.4, + "English": 69.2, + "French": 71.7, + "German": 65.3, + "Greek": 36.3, + "Hebrew": 17.2, + "Italian": 86.3, + "Japanese": 3.4, + "Khmer": 12.7, + "Latin": 62.9, + "Norwegian": 87.2, + "Persian": 61.6, + "Portuguese": 39.4, + "Sanskrit": 13.2, + "Spanish": 62.6, + "Swedish": 77.7, + "Turkish": 35.2, + "Vietnamese": 64.1 + } + } }, { "modelName": "GPT-5.4", @@ -61,7 +331,52 @@ "iconInvertDark": true, "printed": 75.8, "handwritten": 52.8, - "total": 63.4 + "total": 63.4, + "main_language_and_type_metrics": { + "print": { + "Bangla": 86.6, + "Bulgarian": 95.8, + "Chinese": 3.8, + "Czech": 97.3, + "Dutch": 90.7, + "English": 89.2, + "Finnish": 41.1, + "French": 89.9, + "German": 78.3, + "Hindi": 68.6, + "Japanese": 71.3, + "Latin": 88.9, + "Polish": 82.8, + "Romanian": 42.7, + "Sanskrit": 68.8, + "Slovenian": 97.0, + "Spanish": 92.9, + "Swedish": 79.3 + }, + "handwriting": { + "Arabic": 57.8, + "Catalan": 87.4, + "Chinese": 65.2, + "Dutch": 69.9, + "English": 75.9, + "French": 74.5, + "German": 66.0, + "Greek": 44.8, + "Hebrew": 24.9, + "Italian": 86.5, + "Japanese": 13.9, + "Khmer": 2.6, + "Latin": 65.2, + "Norwegian": 73.9, + "Persian": 39.3, + "Portuguese": 38.2, + "Sanskrit": 3.3, + "Spanish": 66.8, + "Swedish": 76.8, + "Turkish": 28.6, + "Vietnamese": 46.3 + } + } }, { "modelName": "GPT-5.4 Mini", @@ -71,7 +386,52 @@ "iconInvertDark": true, "printed": 70.2, "handwritten": 39.8, - "total": 53.8 + "total": 53.8, + "main_language_and_type_metrics": { + "print": { + "Bangla": 75.2, + "Bulgarian": 90.7, + "Chinese": 2.4, + "Czech": 93.2, + "Dutch": 87.6, + "English": 88.2, + "Finnish": 29.8, + "French": 82.9, + "German": 71.4, + "Hindi": 67.0, + "Japanese": 51.1, + "Latin": 87.6, + "Polish": 72.6, + "Romanian": 44.3, + "Sanskrit": 55.6, + "Slovenian": 95.4, + "Spanish": 92.9, + "Swedish": 75.0 + }, + "handwriting": { + "Arabic": 36.0, + "Catalan": 80.0, + "Chinese": 44.5, + "Dutch": 55.6, + "English": 69.1, + "French": 65.1, + "German": 53.6, + "Greek": 18.1, + "Hebrew": 13.6, + "Italian": 78.7, + "Japanese": 3.9, + "Khmer": 1.3, + "Latin": 47.5, + "Norwegian": 63.5, + "Persian": 18.4, + "Portuguese": 22.0, + "Sanskrit": 1.1, + "Spanish": 52.4, + "Swedish": 63.9, + "Turkish": 13.2, + "Vietnamese": 35.1 + } + } }, { "modelName": "GPT-5.4 Nano", @@ -81,7 +441,52 @@ "iconInvertDark": true, "printed": 56.8, "handwritten": 27.9, - "total": 41.2 + "total": 41.2, + "main_language_and_type_metrics": { + "print": { + "Bangla": 29.8, + "Bulgarian": 81.0, + "Chinese": 2.3, + "Czech": 85.9, + "Dutch": 71.3, + "English": 82.3, + "Finnish": 19.1, + "French": 78.4, + "German": 60.9, + "Hindi": 39.2, + "Japanese": 8.6, + "Latin": 78.4, + "Polish": 66.6, + "Romanian": 49.4, + "Sanskrit": 27.6, + "Slovenian": 90.6, + "Spanish": 88.3, + "Swedish": 62.6 + }, + "handwriting": { + "Arabic": 17.5, + "Catalan": 65.7, + "Chinese": 11.2, + "Dutch": 39.6, + "English": 52.6, + "French": 47.0, + "German": 35.9, + "Greek": 13.7, + "Hebrew": 12.3, + "Italian": 55.7, + "Japanese": 6.0, + "Khmer": 4.6, + "Latin": 34.5, + "Norwegian": 43.0, + "Persian": 17.0, + "Portuguese": 20.4, + "Sanskrit": 3.8, + "Spanish": 38.1, + "Swedish": 45.9, + "Turkish": 14.9, + "Vietnamese": 5.6 + } + } }, { "modelName": "Gemini 2.5 Flash", @@ -90,7 +495,52 @@ "iconPath": "_static/img/gemini-symbol.png", "printed": 73.719, "handwritten": 58.7283, - "total": 65.6471 + "total": 65.6471, + "main_language_and_type_metrics": { + "print": { + "Bangla": 91.2, + "Bulgarian": 91.0, + "Chinese": 8.3, + "Czech": 94.8, + "Dutch": 87.1, + "English": 84.6, + "Finnish": 32.8, + "French": 80.9, + "German": 69.3, + "Hindi": 78.6, + "Japanese": 47.1, + "Latin": 85.0, + "Polish": 73.7, + "Romanian": 60.2, + "Sanskrit": 82.1, + "Slovenian": 97.0, + "Spanish": 94.4, + "Swedish": 68.9 + }, + "handwriting": { + "Arabic": 80.7, + "Catalan": 80.4, + "Chinese": 71.0, + "Dutch": 73.9, + "English": 77.2, + "French": 71.6, + "German": 69.4, + "Greek": 47.1, + "Hebrew": 29.0, + "Italian": 84.3, + "Japanese": 12.9, + "Khmer": 27.2, + "Latin": 57.4, + "Norwegian": 81.2, + "Persian": 76.6, + "Portuguese": 38.1, + "Sanskrit": 11.4, + "Spanish": 66.3, + "Swedish": 68.7, + "Turkish": 42.8, + "Vietnamese": 66.3 + } + } }, { "modelName": "Qwen 3 VL (8B)", @@ -99,7 +549,52 @@ "iconPath": "_static/img/qwen-logo.svg", "printed": 76.6136, "handwritten": 48.4963, - "total": 61.4735 + "total": 61.4735, + "main_language_and_type_metrics": { + "print": { + "Bangla": 83.3, + "Bulgarian": 95.0, + "Chinese": 0.0, + "Czech": 96.0, + "Dutch": 84.8, + "English": 87.9, + "Finnish": 51.3, + "French": 87.3, + "German": 67.6, + "Hindi": 87.8, + "Japanese": 70.2, + "Latin": 81.8, + "Polish": 80.1, + "Romanian": 58.5, + "Sanskrit": 80.8, + "Slovenian": 96.0, + "Spanish": 93.6, + "Swedish": 77.0 + }, + "handwriting": { + "Arabic": 48.5, + "Catalan": 85.4, + "Chinese": 70.7, + "Dutch": 68.4, + "English": 71.3, + "French": 70.4, + "German": 57.5, + "Greek": 28.4, + "Hebrew": 8.1, + "Italian": 85.4, + "Japanese": 13.2, + "Khmer": 2.5, + "Latin": 58.5, + "Norwegian": 72.7, + "Persian": 45.1, + "Portuguese": 26.7, + "Sanskrit": 0.8, + "Spanish": 57.5, + "Swedish": 69.4, + "Turkish": 17.2, + "Vietnamese": 60.5 + } + } }, { "modelName": "Qwen 3 VL (30B-A3B)", @@ -108,7 +603,52 @@ "iconPath": "_static/img/qwen-logo.svg", "printed": 74.8873, "handwritten": 49.558, - "total": 61.2485 + "total": 61.2485, + "main_language_and_type_metrics": { + "print": { + "Bangla": 85.7, + "Bulgarian": 95.1, + "Chinese": 0.0, + "Czech": 96.1, + "Dutch": 73.7, + "English": 85.5, + "Finnish": 29.9, + "French": 82.5, + "German": 69.6, + "Hindi": 85.9, + "Japanese": 71.3, + "Latin": 83.6, + "Polish": 80.3, + "Romanian": 59.5, + "Sanskrit": 82.8, + "Slovenian": 95.7, + "Spanish": 92.4, + "Swedish": 78.3 + }, + "handwriting": { + "Arabic": 53.6, + "Catalan": 82.6, + "Chinese": 72.8, + "Dutch": 67.5, + "English": 73.3, + "French": 71.4, + "German": 58.2, + "Greek": 28.2, + "Hebrew": 6.1, + "Italian": 84.1, + "Japanese": 9.8, + "Khmer": 4.4, + "Latin": 59.6, + "Norwegian": 74.9, + "Persian": 38.0, + "Portuguese": 37.5, + "Sanskrit": 8.1, + "Spanish": 56.6, + "Swedish": 69.0, + "Turkish": 23.2, + "Vietnamese": 61.9 + } + } }, { "modelName": "NuMarkdown", @@ -117,7 +657,52 @@ "iconPath": "_static/img/numarkdown-logo.png", "printed": 72.7318, "handwritten": 51.2224, - "total": 61.1498 + "total": 61.1498, + "main_language_and_type_metrics": { + "print": { + "Bangla": 84.7, + "Bulgarian": 87.1, + "Chinese": 1.1, + "Czech": 95.7, + "Dutch": 84.7, + "English": 83.0, + "Finnish": 39.0, + "French": 87.1, + "German": 71.9, + "Hindi": 75.5, + "Japanese": 55.2, + "Latin": 80.6, + "Polish": 77.4, + "Romanian": 52.9, + "Sanskrit": 73.3, + "Slovenian": 95.6, + "Spanish": 86.9, + "Swedish": 77.3 + }, + "handwriting": { + "Arabic": 55.9, + "Catalan": 81.0, + "Chinese": 66.3, + "Dutch": 72.6, + "English": 70.2, + "French": 67.4, + "German": 64.7, + "Greek": 24.6, + "Hebrew": 17.7, + "Italian": 85.2, + "Japanese": 11.0, + "Khmer": 11.9, + "Latin": 61.4, + "Norwegian": 68.5, + "Persian": 48.1, + "Portuguese": 41.2, + "Sanskrit": 10.3, + "Spanish": 60.6, + "Swedish": 69.2, + "Turkish": 16.4, + "Vietnamese": 71.5 + } + } }, { "modelName": "GPT-4.1 Mini", @@ -127,7 +712,52 @@ "iconInvertDark": true, "printed": 73.1042, "handwritten": 50.2487, - "total": 60.7974 + "total": 60.7974, + "main_language_and_type_metrics": { + "print": { + "Bangla": 77.3, + "Bulgarian": 90.3, + "Chinese": 5.3, + "Czech": 93.8, + "Dutch": 82.7, + "English": 88.3, + "Finnish": 40.1, + "French": 76.6, + "German": 75.3, + "Hindi": 82.2, + "Japanese": 55.3, + "Latin": 82.3, + "Polish": 73.1, + "Romanian": 56.1, + "Sanskrit": 71.9, + "Slovenian": 96.3, + "Spanish": 89.9, + "Swedish": 79.0 + }, + "handwriting": { + "Arabic": 60.6, + "Catalan": 78.0, + "Chinese": 62.1, + "Dutch": 66.0, + "English": 73.9, + "French": 64.8, + "German": 55.7, + "Greek": 38.3, + "Hebrew": 28.0, + "Italian": 79.7, + "Japanese": 14.7, + "Khmer": 12.7, + "Latin": 57.1, + "Norwegian": 77.9, + "Persian": 50.7, + "Portuguese": 36.4, + "Sanskrit": 10.2, + "Spanish": 61.0, + "Swedish": 57.9, + "Turkish": 31.2, + "Vietnamese": 38.3 + } + } }, { "modelName": "Qwen 2.5 VL (72B)", @@ -136,7 +766,52 @@ "iconPath": "_static/img/qwen-logo.svg", "printed": 66.2783, "handwritten": 54.4717, - "total": 59.9209 + "total": 59.9209, + "main_language_and_type_metrics": { + "print": { + "Bangla": 87.9, + "Bulgarian": 89.6, + "Chinese": 3.7, + "Czech": 83.6, + "Dutch": 65.9, + "English": 75.7, + "Finnish": 10.6, + "French": 80.2, + "German": 53.3, + "Hindi": 89.0, + "Japanese": 47.3, + "Latin": 85.3, + "Polish": 57.1, + "Romanian": 57.4, + "Sanskrit": 70.2, + "Slovenian": 95.9, + "Spanish": 89.5, + "Swedish": 50.9 + }, + "handwriting": { + "Arabic": 67.8, + "Catalan": 85.7, + "Chinese": 58.0, + "Dutch": 74.1, + "English": 77.0, + "French": 75.1, + "German": 67.0, + "Greek": 38.7, + "Hebrew": 16.3, + "Italian": 80.4, + "Japanese": 11.9, + "Khmer": 0.5, + "Latin": 56.1, + "Norwegian": 74.8, + "Persian": 55.9, + "Portuguese": 43.8, + "Sanskrit": 13.3, + "Spanish": 67.8, + "Swedish": 74.8, + "Turkish": 36.8, + "Vietnamese": 68.1 + } + } }, { "modelName": "Azure OCR", @@ -145,7 +820,52 @@ "iconPath": "_static/img/azure-logo.png", "printed": 71.8617, "handwritten": 47.7443, - "total": 58.8754 + "total": 58.8754, + "main_language_and_type_metrics": { + "print": { + "Bangla": 15.3, + "Bulgarian": 91.4, + "Chinese": 9.4, + "Czech": 91.7, + "Dutch": 91.6, + "English": 86.1, + "Finnish": 65.0, + "French": 84.5, + "German": 77.9, + "Hindi": 72.9, + "Japanese": 53.6, + "Latin": 82.6, + "Polish": 81.1, + "Romanian": 50.7, + "Sanskrit": 80.8, + "Slovenian": 93.9, + "Spanish": 85.5, + "Swedish": 79.6 + }, + "handwriting": { + "Arabic": 74.3, + "Catalan": 72.7, + "Chinese": 50.6, + "Dutch": 58.0, + "English": 73.5, + "French": 60.6, + "German": 49.4, + "Greek": 20.5, + "Hebrew": 38.6, + "Italian": 70.9, + "Japanese": 11.4, + "Khmer": 3.7, + "Latin": 53.0, + "Norwegian": 64.1, + "Persian": 45.4, + "Portuguese": 37.7, + "Sanskrit": 14.6, + "Spanish": 62.2, + "Swedish": 46.5, + "Turkish": 39.8, + "Vietnamese": 55.1 + } + } }, { "modelName": "GPT-5.2", @@ -155,7 +875,52 @@ "iconInvertDark": true, "printed": 71.4, "handwritten": 46.8, - "total": 58.2 + "total": 58.2, + "main_language_and_type_metrics": { + "print": { + "Bangla": 80.2, + "Bulgarian": 90.9, + "Chinese": 3.3, + "Czech": 94.3, + "Dutch": 88.1, + "English": 86.2, + "Finnish": 31.9, + "French": 81.8, + "German": 68.6, + "Hindi": 65.4, + "Japanese": 62.4, + "Latin": 86.0, + "Polish": 77.5, + "Romanian": 39.5, + "Sanskrit": 65.9, + "Slovenian": 96.6, + "Spanish": 92.5, + "Swedish": 75.0 + }, + "handwriting": { + "Arabic": 56.3, + "Catalan": 81.8, + "Chinese": 52.2, + "Dutch": 63.4, + "English": 72.8, + "French": 65.9, + "German": 56.5, + "Greek": 38.5, + "Hebrew": 14.6, + "Italian": 80.0, + "Japanese": 6.8, + "Khmer": 1.1, + "Latin": 54.1, + "Norwegian": 72.9, + "Persian": 40.7, + "Portuguese": 32.7, + "Sanskrit": 6.4, + "Spanish": 61.7, + "Swedish": 62.7, + "Turkish": 28.6, + "Vietnamese": 33.7 + } + } }, { "modelName": "GPT-5 Mini", @@ -165,7 +930,52 @@ "iconInvertDark": true, "printed": 69.8107, "handwritten": 47.5121, - "total": 57.8038 + "total": 57.8038, + "main_language_and_type_metrics": { + "print": { + "Bangla": 78.6, + "Bulgarian": 83.8, + "Chinese": 4.6, + "Czech": 92.6, + "Dutch": 80.5, + "English": 86.4, + "Finnish": 39.3, + "French": 83.4, + "German": 67.8, + "Hindi": 78.2, + "Japanese": 44.3, + "Latin": 81.5, + "Polish": 70.4, + "Romanian": 43.5, + "Sanskrit": 65.5, + "Slovenian": 92.9, + "Spanish": 90.6, + "Swedish": 72.7 + }, + "handwriting": { + "Arabic": 54.2, + "Catalan": 78.4, + "Chinese": 48.5, + "Dutch": 60.1, + "English": 76.8, + "French": 66.0, + "German": 56.3, + "Greek": 34.0, + "Hebrew": 25.9, + "Italian": 75.5, + "Japanese": 12.2, + "Khmer": 14.8, + "Latin": 51.8, + "Norwegian": 78.6, + "Persian": 45.8, + "Portuguese": 37.4, + "Sanskrit": 9.2, + "Spanish": 59.7, + "Swedish": 49.6, + "Turkish": 30.5, + "Vietnamese": 32.5 + } + } }, { "modelName": "Claude Sonnet 3.7", @@ -174,7 +984,52 @@ "iconPath": "_static/img/claude-symbol.svg", "printed": 70.1866, "handwritten": 46.5904, - "total": 57.481 + "total": 57.481, + "main_language_and_type_metrics": { + "print": { + "Bangla": 85.2, + "Bulgarian": 86.4, + "Chinese": 1.8, + "Czech": 94.8, + "Dutch": 76.1, + "English": 80.1, + "Finnish": 25.8, + "French": 76.1, + "German": 64.7, + "Hindi": 89.8, + "Japanese": 29.8, + "Latin": 85.0, + "Polish": 71.3, + "Romanian": 64.1, + "Sanskrit": 82.1, + "Slovenian": 95.0, + "Spanish": 84.8, + "Swedish": 70.2 + }, + "handwriting": { + "Arabic": 61.2, + "Catalan": 83.2, + "Chinese": 28.3, + "Dutch": 64.4, + "English": 75.8, + "French": 63.9, + "German": 58.6, + "Greek": 50.4, + "Hebrew": 29.6, + "Italian": 74.8, + "Japanese": 2.4, + "Khmer": 8.2, + "Latin": 58.0, + "Norwegian": 75.2, + "Persian": 42.8, + "Portuguese": 41.8, + "Sanskrit": 6.5, + "Spanish": 60.7, + "Swedish": 49.7, + "Turkish": 31.6, + "Vietnamese": 11.1 + } + } }, { "modelName": "RolmOCR", @@ -183,7 +1038,52 @@ "iconPath": "_static/img/reducto-logomark.svg", "printed": 67.2281, "handwritten": 49.0075, - "total": 57.417 + "total": 57.417, + "main_language_and_type_metrics": { + "print": { + "Bangla": 82.7, + "Bulgarian": 79.4, + "Chinese": 1.2, + "Czech": 87.5, + "Dutch": 73.7, + "English": 88.4, + "Finnish": 17.0, + "French": 78.4, + "German": 63.9, + "Hindi": 85.1, + "Japanese": 35.1, + "Latin": 79.2, + "Polish": 65.5, + "Romanian": 57.9, + "Sanskrit": 67.7, + "Slovenian": 86.1, + "Spanish": 90.7, + "Swedish": 70.8 + }, + "handwriting": { + "Arabic": 51.8, + "Catalan": 81.9, + "Chinese": 64.5, + "Dutch": 69.1, + "English": 74.1, + "French": 67.0, + "German": 63.9, + "Greek": 14.9, + "Hebrew": 2.8, + "Italian": 84.3, + "Japanese": 7.9, + "Khmer": 0.6, + "Latin": 58.6, + "Norwegian": 72.3, + "Persian": 50.9, + "Portuguese": 37.9, + "Sanskrit": 9.2, + "Spanish": 60.7, + "Swedish": 69.2, + "Turkish": 21.1, + "Vietnamese": 66.6 + } + } }, { "modelName": "Qwen 3 VL (4B)", @@ -192,7 +1092,52 @@ "iconPath": "_static/img/qwen-logo.svg", "printed": 74.9451, "handwritten": 40.6463, - "total": 56.4765 + "total": 56.4765, + "main_language_and_type_metrics": { + "print": { + "Bangla": 78.1, + "Bulgarian": 93.4, + "Chinese": 0.0, + "Czech": 93.8, + "Dutch": 92.0, + "English": 86.2, + "Finnish": 48.8, + "French": 78.0, + "German": 70.5, + "Hindi": 84.3, + "Japanese": 65.8, + "Latin": 79.6, + "Polish": 80.0, + "Romanian": 58.0, + "Sanskrit": 76.4, + "Slovenian": 94.8, + "Spanish": 89.7, + "Swedish": 79.7 + }, + "handwriting": { + "Arabic": 34.6, + "Catalan": 76.4, + "Chinese": 69.0, + "Dutch": 59.3, + "English": 65.6, + "French": 56.6, + "German": 41.6, + "Greek": 28.8, + "Hebrew": 2.0, + "Italian": 77.7, + "Japanese": 6.9, + "Khmer": 1.3, + "Latin": 49.5, + "Norwegian": 65.9, + "Persian": 20.0, + "Portuguese": 23.9, + "Sanskrit": 0.7, + "Spanish": 42.7, + "Swedish": 59.9, + "Turkish": 19.1, + "Vietnamese": 52.1 + } + } }, { "modelName": "Nanonets OCR", @@ -201,7 +1146,52 @@ "iconPath": "_static/img/nanonets-logo.png", "printed": 69.7033, "handwritten": 43.1781, - "total": 55.4205 + "total": 55.4205, + "main_language_and_type_metrics": { + "print": { + "Bangla": 75.6, + "Bulgarian": 85.5, + "Chinese": 0.4, + "Czech": 94.7, + "Dutch": 78.1, + "English": 85.7, + "Finnish": 24.8, + "French": 82.9, + "German": 69.4, + "Hindi": 83.1, + "Japanese": 32.9, + "Latin": 79.7, + "Polish": 74.2, + "Romanian": 57.3, + "Sanskrit": 71.4, + "Slovenian": 95.7, + "Spanish": 91.2, + "Swedish": 72.2 + }, + "handwriting": { + "Arabic": 52.0, + "Catalan": 77.8, + "Chinese": 56.8, + "Dutch": 64.9, + "English": 62.3, + "French": 61.6, + "German": 56.0, + "Greek": 20.4, + "Hebrew": 0.8, + "Italian": 77.5, + "Japanese": 4.3, + "Khmer": 0.3, + "Latin": 56.4, + "Norwegian": 67.9, + "Persian": 17.3, + "Portuguese": 36.2, + "Sanskrit": 2.0, + "Spanish": 57.3, + "Swedish": 63.2, + "Turkish": 13.9, + "Vietnamese": 57.6 + } + } }, { "modelName": "Infinity Parser", @@ -210,7 +1200,52 @@ "iconPath": "_static/img/infly-logo.png", "printed": 76.3, "handwritten": 50.9, - "total": 62.6 + "total": 62.6, + "main_language_and_type_metrics": { + "print": { + "Bangla": 85.2, + "Bulgarian": 94.1, + "Chinese": 6.2, + "Czech": 96.5, + "Dutch": 93.9, + "English": 88.6, + "Finnish": 59.0, + "French": 86.8, + "German": 78.8, + "Hindi": 84.2, + "Japanese": 32.7, + "Latin": 83.2, + "Polish": 83.2, + "Romanian": 52.7, + "Sanskrit": 80.3, + "Slovenian": 95.9, + "Spanish": 91.9, + "Swedish": 80.9 + }, + "handwriting": { + "Arabic": 61.5, + "Catalan": 58.6, + "Chinese": 59.5, + "Dutch": 72.9, + "English": 70.5, + "French": 72.6, + "German": 66.5, + "Greek": 35.8, + "Hebrew": 15.1, + "Italian": 84.7, + "Japanese": 6.4, + "Khmer": 2.2, + "Latin": 61.5, + "Norwegian": 67.0, + "Persian": 54.2, + "Portuguese": 41.8, + "Sanskrit": 11.2, + "Spanish": 60.7, + "Swedish": 71.7, + "Turkish": 26.4, + "Vietnamese": 67.5 + } + } }, { "modelName": "Qwen 3.5 (9B)", @@ -219,7 +1254,52 @@ "iconPath": "_static/img/qwen-logo.svg", "printed": 69.1, "handwritten": 42.4, - "total": 54.7 + "total": 54.7, + "main_language_and_type_metrics": { + "print": { + "Bangla": 75.9, + "Bulgarian": 84.6, + "Chinese": 3.6, + "Czech": 91.1, + "Dutch": 81.2, + "English": 85.1, + "Finnish": 48.4, + "French": 81.6, + "German": 71.6, + "Hindi": 63.1, + "Japanese": 55.9, + "Latin": 78.3, + "Polish": 75.0, + "Romanian": 36.0, + "Sanskrit": 59.8, + "Slovenian": 93.0, + "Spanish": 87.1, + "Swedish": 73.0 + }, + "handwriting": { + "Arabic": 37.6, + "Catalan": 70.5, + "Chinese": 57.2, + "Dutch": 62.5, + "English": 69.2, + "French": 58.4, + "German": 49.7, + "Greek": 21.5, + "Hebrew": 13.5, + "Italian": 78.2, + "Japanese": 8.6, + "Khmer": 10.1, + "Latin": 46.7, + "Norwegian": 67.0, + "Persian": 28.5, + "Portuguese": 30.9, + "Sanskrit": 5.9, + "Spanish": 51.2, + "Swedish": 53.7, + "Turkish": 20.2, + "Vietnamese": 48.3 + } + } }, { "modelName": "olmOCR", @@ -228,7 +1308,52 @@ "iconPath": "_static/img/ai2-symbol.svg", "printed": 69.766, "handwritten": 41.5165, - "total": 54.5547 + "total": 54.5547, + "main_language_and_type_metrics": { + "print": { + "Bangla": 79.2, + "Bulgarian": 84.8, + "Chinese": 0.2, + "Czech": 90.0, + "Dutch": 82.9, + "English": 79.7, + "Finnish": 28.5, + "French": 83.7, + "German": 66.4, + "Hindi": 85.0, + "Japanese": 42.5, + "Latin": 82.5, + "Polish": 72.4, + "Romanian": 51.4, + "Sanskrit": 66.4, + "Slovenian": 96.2, + "Spanish": 90.7, + "Swedish": 73.2 + }, + "handwriting": { + "Arabic": 44.0, + "Catalan": 80.6, + "Chinese": 55.0, + "Dutch": 64.6, + "English": 68.0, + "French": 60.5, + "German": 53.4, + "Greek": 11.7, + "Hebrew": 4.3, + "Italian": 79.4, + "Japanese": 3.9, + "Khmer": 0.4, + "Latin": 52.2, + "Norwegian": 67.2, + "Persian": 23.0, + "Portuguese": 21.5, + "Sanskrit": 0.5, + "Spanish": 48.0, + "Swedish": 60.6, + "Turkish": 13.1, + "Vietnamese": 60.0 + } + } }, { "modelName": "olmOCR 2", @@ -237,7 +1362,52 @@ "iconPath": "_static/img/ai2-symbol.svg", "printed": 71.1, "handwritten": 44.6, - "total": 56.8 + "total": 56.8, + "main_language_and_type_metrics": { + "print": { + "Bangla": 78.8, + "Bulgarian": 87.0, + "Chinese": 3.8, + "Czech": 93.7, + "Dutch": 84.1, + "English": 83.5, + "Finnish": 36.2, + "French": 83.7, + "German": 65.0, + "Hindi": 85.3, + "Japanese": 60.4, + "Latin": 81.2, + "Polish": 67.5, + "Romanian": 38.6, + "Sanskrit": 71.4, + "Slovenian": 95.7, + "Spanish": 92.1, + "Swedish": 71.0 + }, + "handwriting": { + "Arabic": 53.9, + "Catalan": 80.7, + "Chinese": 66.0, + "Dutch": 67.8, + "English": 69.5, + "French": 65.6, + "German": 51.1, + "Greek": 18.2, + "Hebrew": 3.8, + "Italian": 81.6, + "Japanese": 4.7, + "Khmer": 0.4, + "Latin": 53.2, + "Norwegian": 76.2, + "Persian": 22.7, + "Portuguese": 29.7, + "Sanskrit": 3.7, + "Spanish": 54.2, + "Swedish": 50.0, + "Turkish": 17.5, + "Vietnamese": 66.2 + } + } }, { "modelName": "LiquidAI LFM2.5 VL (1.6B)", @@ -246,7 +1416,52 @@ "iconPath": "_static/img/liquidai-logo.png", "printed": 40.2, "handwritten": 25.5, - "total": 32.3 + "total": 32.3, + "main_language_and_type_metrics": { + "print": { + "Bangla": 17.9, + "Bulgarian": 34.4, + "Chinese": 3.5, + "Czech": 60.5, + "Dutch": 40.0, + "English": 69.3, + "Finnish": 9.7, + "French": 59.1, + "German": 40.7, + "Hindi": 39.3, + "Japanese": 5.6, + "Latin": 66.5, + "Polish": 38.1, + "Romanian": 29.0, + "Sanskrit": 24.3, + "Slovenian": 83.4, + "Spanish": 68.4, + "Swedish": 34.7 + }, + "handwriting": { + "Arabic": 22.8, + "Catalan": 37.9, + "Chinese": 6.2, + "Dutch": 33.8, + "English": 55.6, + "French": 37.8, + "German": 26.7, + "Greek": 17.3, + "Hebrew": 7.2, + "Italian": 50.1, + "Japanese": 3.4, + "Khmer": 11.4, + "Latin": 35.2, + "Norwegian": 49.7, + "Persian": 23.3, + "Portuguese": 25.4, + "Sanskrit": 6.3, + "Spanish": 32.1, + "Swedish": 29.8, + "Turkish": 18.4, + "Vietnamese": 4.4 + } + } }, { "modelName": "Qwen 2.5 VL (3B)", @@ -255,7 +1470,52 @@ "iconPath": "_static/img/qwen-logo.svg", "printed": 67.8366, "handwritten": 42.8576, - "total": 54.3864 + "total": 54.3864, + "main_language_and_type_metrics": { + "print": { + "Bangla": 70.0, + "Bulgarian": 83.3, + "Chinese": 0.5, + "Czech": 93.6, + "Dutch": 75.7, + "English": 82.0, + "Finnish": 30.3, + "French": 79.9, + "German": 64.6, + "Hindi": 77.9, + "Japanese": 37.1, + "Latin": 80.4, + "Polish": 67.7, + "Romanian": 59.1, + "Sanskrit": 72.6, + "Slovenian": 94.8, + "Spanish": 87.2, + "Swedish": 64.3 + }, + "handwriting": { + "Arabic": 55.0, + "Catalan": 79.1, + "Chinese": 52.8, + "Dutch": 64.1, + "English": 67.5, + "French": 59.2, + "German": 51.2, + "Greek": 4.8, + "Hebrew": 0.2, + "Italian": 73.6, + "Japanese": 5.0, + "Khmer": 2.2, + "Latin": 51.7, + "Norwegian": 66.9, + "Persian": 41.2, + "Portuguese": 36.0, + "Sanskrit": 0.2, + "Spanish": 51.6, + "Swedish": 52.5, + "Turkish": 26.7, + "Vietnamese": 58.6 + } + } }, { "modelName": "O4 Mini", @@ -265,7 +1525,52 @@ "iconInvertDark": true, "printed": 65.5032, "handwritten": 41.4801, - "total": 52.5677 + "total": 52.5677, + "main_language_and_type_metrics": { + "print": { + "Bangla": 75.8, + "Bulgarian": 81.9, + "Chinese": 2.4, + "Czech": 89.2, + "Dutch": 74.2, + "English": 82.3, + "Finnish": 21.2, + "French": 80.5, + "German": 63.2, + "Hindi": 73.3, + "Japanese": 38.8, + "Latin": 79.5, + "Polish": 64.0, + "Romanian": 39.4, + "Sanskrit": 59.6, + "Slovenian": 95.1, + "Spanish": 89.0, + "Swedish": 69.6 + }, + "handwriting": { + "Arabic": 48.6, + "Catalan": 73.1, + "Chinese": 44.0, + "Dutch": 55.2, + "English": 74.3, + "French": 59.7, + "German": 48.9, + "Greek": 26.8, + "Hebrew": 9.8, + "Italian": 73.3, + "Japanese": 6.3, + "Khmer": 8.1, + "Latin": 47.6, + "Norwegian": 72.3, + "Persian": 38.0, + "Portuguese": 30.8, + "Sanskrit": 6.7, + "Spanish": 53.3, + "Swedish": 39.2, + "Turkish": 26.3, + "Vietnamese": 28.7 + } + } }, { "modelName": "Claude Opus 4.1", @@ -274,7 +1579,52 @@ "iconPath": "_static/img/claude-symbol.svg", "printed": 66.6052, "handwritten": 40.1808, - "total": 52.3767 + "total": 52.3767, + "main_language_and_type_metrics": { + "print": { + "Bangla": 82.3, + "Bulgarian": 82.9, + "Chinese": 1.8, + "Czech": 91.5, + "Dutch": 70.1, + "English": 78.3, + "Finnish": 16.8, + "French": 77.7, + "German": 58.7, + "Hindi": 87.5, + "Japanese": 17.3, + "Latin": 84.1, + "Polish": 65.3, + "Romanian": 57.7, + "Sanskrit": 79.5, + "Slovenian": 96.3, + "Spanish": 85.7, + "Swedish": 65.2 + }, + "handwriting": { + "Arabic": 48.1, + "Catalan": 76.4, + "Chinese": 18.6, + "Dutch": 57.2, + "English": 71.6, + "French": 59.0, + "German": 52.3, + "Greek": 30.7, + "Hebrew": 23.9, + "Italian": 69.8, + "Japanese": 7.3, + "Khmer": 2.2, + "Latin": 50.5, + "Norwegian": 66.6, + "Persian": 39.2, + "Portuguese": 33.5, + "Sanskrit": 9.2, + "Spanish": 55.4, + "Swedish": 39.1, + "Turkish": 25.6, + "Vietnamese": 7.5 + } + } }, { "modelName": "GPT-4.1", @@ -284,7 +1634,52 @@ "iconInvertDark": true, "printed": 64.4017, "handwritten": 41.4127, - "total": 52.023 + "total": 52.023, + "main_language_and_type_metrics": { + "print": { + "Bangla": 71.4, + "Bulgarian": 78.3, + "Chinese": 3.9, + "Czech": 89.5, + "Dutch": 76.6, + "English": 81.8, + "Finnish": 34.5, + "French": 75.0, + "German": 63.7, + "Hindi": 76.4, + "Japanese": 19.7, + "Latin": 76.9, + "Polish": 64.8, + "Romanian": 40.9, + "Sanskrit": 60.6, + "Slovenian": 90.6, + "Spanish": 88.0, + "Swedish": 66.5 + }, + "handwriting": { + "Arabic": 50.7, + "Catalan": 66.9, + "Chinese": 31.5, + "Dutch": 51.7, + "English": 70.6, + "French": 57.2, + "German": 48.9, + "Greek": 28.3, + "Hebrew": 21.7, + "Italian": 68.8, + "Japanese": 11.1, + "Khmer": 10.6, + "Latin": 48.0, + "Norwegian": 71.4, + "Persian": 42.6, + "Portuguese": 34.4, + "Sanskrit": 9.6, + "Spanish": 49.8, + "Swedish": 38.8, + "Turkish": 31.6, + "Vietnamese": 25.4 + } + } }, { "modelName": "Claude Sonnet 4", @@ -293,7 +1688,52 @@ "iconPath": "_static/img/claude-symbol.svg", "printed": 62.2358, "handwritten": 37.0701, - "total": 48.685 + "total": 48.685, + "main_language_and_type_metrics": { + "print": { + "Bangla": 73.3, + "Bulgarian": 77.6, + "Chinese": 2.0, + "Czech": 86.6, + "Dutch": 70.7, + "English": 76.2, + "Finnish": 15.8, + "French": 69.9, + "German": 56.1, + "Hindi": 76.5, + "Japanese": 17.5, + "Latin": 83.1, + "Polish": 63.4, + "Romanian": 51.0, + "Sanskrit": 69.1, + "Slovenian": 93.8, + "Spanish": 78.9, + "Swedish": 58.8 + }, + "handwriting": { + "Arabic": 45.3, + "Catalan": 70.0, + "Chinese": 20.5, + "Dutch": 46.4, + "English": 63.0, + "French": 49.6, + "German": 48.6, + "Greek": 30.4, + "Hebrew": 29.3, + "Italian": 64.3, + "Japanese": 9.6, + "Khmer": 8.0, + "Latin": 46.5, + "Norwegian": 54.5, + "Persian": 33.0, + "Portuguese": 34.7, + "Sanskrit": 8.6, + "Spanish": 52.1, + "Swedish": 30.8, + "Turkish": 27.1, + "Vietnamese": 6.0 + } + } }, { "modelName": "O1", @@ -303,7 +1743,52 @@ "iconInvertDark": true, "printed": 62.4692, "handwritten": 35.0806, - "total": 47.7215 + "total": 47.7215, + "main_language_and_type_metrics": { + "print": { + "Bangla": 74.7, + "Bulgarian": 76.0, + "Chinese": 2.0, + "Czech": 87.5, + "Dutch": 74.0, + "English": 82.1, + "Finnish": 24.3, + "French": 72.2, + "German": 58.9, + "Hindi": 78.1, + "Japanese": 15.6, + "Latin": 77.5, + "Polish": 64.2, + "Romanian": 35.3, + "Sanskrit": 60.2, + "Slovenian": 90.4, + "Spanish": 89.0, + "Swedish": 62.6 + }, + "handwriting": { + "Arabic": 43.1, + "Catalan": 63.9, + "Chinese": 24.7, + "Dutch": 48.0, + "English": 67.7, + "French": 49.0, + "German": 44.2, + "Greek": 21.3, + "Hebrew": 6.0, + "Italian": 64.7, + "Japanese": 3.4, + "Khmer": 0.5, + "Latin": 39.1, + "Norwegian": 67.3, + "Persian": 37.7, + "Portuguese": 24.0, + "Sanskrit": 2.9, + "Spanish": 46.8, + "Swedish": 38.3, + "Turkish": 25.8, + "Vietnamese": 18.5 + } + } }, { "modelName": "PaddleOCR-VL 1.5", @@ -312,7 +1797,52 @@ "iconPath": "_static/img/paddleocr-logo.png", "printed": 62.6, "handwritten": 33.6, - "total": 47.0 + "total": 47.0, + "main_language_and_type_metrics": { + "print": { + "Bangla": 73.8, + "Bulgarian": 80.7, + "Chinese": 7.5, + "Czech": 86.7, + "Dutch": 73.5, + "English": 81.2, + "Finnish": 24.1, + "French": 74.7, + "German": 49.6, + "Hindi": 63.5, + "Japanese": 38.3, + "Latin": 74.6, + "Polish": 64.3, + "Romanian": 39.5, + "Sanskrit": 62.5, + "Slovenian": 94.2, + "Spanish": 83.6, + "Swedish": 54.4 + }, + "handwriting": { + "Arabic": 46.9, + "Catalan": 49.9, + "Chinese": 70.5, + "Dutch": 31.8, + "English": 53.8, + "French": 36.7, + "German": 34.2, + "Greek": 7.8, + "Hebrew": 9.1, + "Italian": 59.4, + "Japanese": 2.6, + "Khmer": 1.4, + "Latin": 31.7, + "Norwegian": 56.0, + "Persian": 34.7, + "Portuguese": 20.9, + "Sanskrit": 0.4, + "Spanish": 36.0, + "Swedish": 29.4, + "Turkish": 23.6, + "Vietnamese": 68.2 + } + } }, { "modelName": "O3", @@ -322,7 +1852,52 @@ "iconInvertDark": true, "printed": 62.65, "handwritten": 30.9051, - "total": 45.5566 + "total": 45.5566, + "main_language_and_type_metrics": { + "print": { + "Bangla": 76.5, + "Bulgarian": 77.8, + "Chinese": 1.5, + "Czech": 88.0, + "Dutch": 73.1, + "English": 81.6, + "Finnish": 19.7, + "French": 80.1, + "German": 58.4, + "Hindi": 76.3, + "Japanese": 12.0, + "Latin": 79.6, + "Polish": 65.5, + "Romanian": 33.6, + "Sanskrit": 59.8, + "Slovenian": 94.8, + "Spanish": 89.4, + "Swedish": 60.0 + }, + "handwriting": { + "Arabic": 32.3, + "Catalan": 64.5, + "Chinese": 28.7, + "Dutch": 36.2, + "English": 71.7, + "French": 43.2, + "German": 41.5, + "Greek": 19.8, + "Hebrew": 8.6, + "Italian": 62.3, + "Japanese": 3.3, + "Khmer": 0.1, + "Latin": 31.7, + "Norwegian": 71.3, + "Persian": 12.2, + "Portuguese": 13.8, + "Sanskrit": 0.2, + "Spanish": 47.0, + "Swedish": 22.5, + "Turkish": 24.6, + "Vietnamese": 13.5 + } + } }, { "modelName": "Mistral OCR 2", @@ -331,16 +1906,106 @@ "iconPath": "_static/img/mistral-logo.svg", "printed": 64.3, "handwritten": 30.4, - "total": 46.0 + "total": 46.0, + "main_language_and_type_metrics": { + "print": { + "Bangla": 57.4, + "Bulgarian": 89.2, + "Chinese": 1.3, + "Czech": 86.6, + "Dutch": 75.9, + "English": 79.5, + "Finnish": 29.8, + "French": 77.0, + "German": 60.1, + "Hindi": 82.0, + "Japanese": 2.6, + "Latin": 80.0, + "Polish": 62.4, + "Romanian": 48.5, + "Sanskrit": 83.1, + "Slovenian": 93.4, + "Spanish": 89.2, + "Swedish": 59.7 + }, + "handwriting": { + "Arabic": 59.6, + "Catalan": 53.4, + "Chinese": 4.6, + "Dutch": 34.9, + "English": 63.6, + "French": 39.8, + "German": 31.6, + "Greek": 4.8, + "Hebrew": 3.1, + "Italian": 67.7, + "Japanese": 4.5, + "Khmer": 2.3, + "Latin": 37.0, + "Norwegian": 53.9, + "Persian": 47.0, + "Portuguese": 16.0, + "Sanskrit": 1.9, + "Spanish": 37.6, + "Swedish": 32.6, + "Turkish": 38.8, + "Vietnamese": 2.8 + } + } }, - { + { "modelName": "Mistral OCR 3", "modelId": "mistral-ocr-2512", "modelUrl": null, "iconPath": "_static/img/mistral-logo.svg", "printed": 71.5, "handwritten": 47.6, - "total": 58.6 + "total": 58.6, + "main_language_and_type_metrics": { + "print": { + "Bangla": 84.2, + "Bulgarian": 94.1, + "Chinese": 1.2, + "Czech": 91.7, + "Dutch": 83.9, + "English": 83.4, + "Finnish": 37.7, + "French": 80.5, + "German": 68.2, + "Hindi": 69.4, + "Japanese": 47.0, + "Latin": 83.1, + "Polish": 69.7, + "Romanian": 57.8, + "Sanskrit": 76.5, + "Slovenian": 95.4, + "Spanish": 90.6, + "Swedish": 73.1 + }, + "handwriting": { + "Arabic": 75.7, + "Catalan": 80.4, + "Chinese": 24.9, + "Dutch": 62.3, + "English": 81.3, + "French": 66.8, + "German": 55.9, + "Greek": 13.9, + "Hebrew": 26.4, + "Italian": 84.6, + "Japanese": 4.7, + "Khmer": 4.7, + "Latin": 48.1, + "Norwegian": 81.5, + "Persian": 60.1, + "Portuguese": 27.4, + "Sanskrit": 10.2, + "Spanish": 57.8, + "Swedish": 72.4, + "Turkish": 45.2, + "Vietnamese": 14.2 + } + } }, { "modelName": "GPT-5", @@ -350,7 +2015,52 @@ "iconInvertDark": true, "printed": 60.7442, "handwritten": 30.4073, - "total": 44.409 + "total": 44.409, + "main_language_and_type_metrics": { + "print": { + "Bangla": 73.6, + "Bulgarian": 77.1, + "Chinese": 1.3, + "Czech": 86.9, + "Dutch": 71.8, + "English": 81.1, + "Finnish": 19.9, + "French": 79.8, + "German": 59.2, + "Hindi": 61.5, + "Japanese": 14.2, + "Latin": 78.2, + "Polish": 64.2, + "Romanian": 33.1, + "Sanskrit": 53.0, + "Slovenian": 90.3, + "Spanish": 89.6, + "Swedish": 58.6 + }, + "handwriting": { + "Arabic": 38.4, + "Catalan": 64.3, + "Chinese": 27.8, + "Dutch": 36.8, + "English": 70.6, + "French": 44.7, + "German": 35.4, + "Greek": 19.3, + "Hebrew": 8.1, + "Italian": 63.6, + "Japanese": 3.8, + "Khmer": 1.1, + "Latin": 29.6, + "Norwegian": 67.3, + "Persian": 16.7, + "Portuguese": 18.4, + "Sanskrit": 0.8, + "Spanish": 41.2, + "Swedish": 26.3, + "Turkish": 19.1, + "Vietnamese": 5.4 + } + } }, { "modelName": "GPT-4o", @@ -360,7 +2070,52 @@ "iconInvertDark": true, "printed": 56.2807, "handwritten": 34.1932, - "total": 44.3875 + "total": 44.3875, + "main_language_and_type_metrics": { + "print": { + "Bangla": 66.7, + "Bulgarian": 63.0, + "Chinese": 2.1, + "Czech": 82.9, + "Dutch": 62.8, + "English": 78.3, + "Finnish": 15.6, + "French": 70.2, + "German": 48.8, + "Hindi": 70.6, + "Japanese": 11.9, + "Latin": 74.2, + "Polish": 54.8, + "Romanian": 40.1, + "Sanskrit": 48.1, + "Slovenian": 89.5, + "Spanish": 86.4, + "Swedish": 47.1 + }, + "handwriting": { + "Arabic": 45.1, + "Catalan": 56.8, + "Chinese": 23.6, + "Dutch": 42.8, + "English": 65.5, + "French": 48.9, + "German": 39.1, + "Greek": 24.1, + "Hebrew": 18.4, + "Italian": 55.2, + "Japanese": 9.2, + "Khmer": 7.1, + "Latin": 37.9, + "Norwegian": 60.9, + "Persian": 30.3, + "Portuguese": 27.5, + "Sanskrit": 8.8, + "Spanish": 41.2, + "Swedish": 31.7, + "Turkish": 26.2, + "Vietnamese": 17.9 + } + } }, { "modelName": "MiMo VL", @@ -369,7 +2124,52 @@ "iconPath": "_static/img/mimo-logo.png", "printed": 54.8469, "handwritten": 34.6299, - "total": 43.9608 + "total": 43.9608, + "main_language_and_type_metrics": { + "print": { + "Bangla": 63.8, + "Bulgarian": 72.7, + "Chinese": 2.1, + "Czech": 82.4, + "Dutch": 50.4, + "English": 71.5, + "Finnish": 7.2, + "French": 72.6, + "German": 51.0, + "Hindi": 51.4, + "Japanese": 26.8, + "Latin": 82.6, + "Polish": 49.6, + "Romanian": 40.0, + "Sanskrit": 44.7, + "Slovenian": 92.4, + "Spanish": 75.7, + "Swedish": 50.5 + }, + "handwriting": { + "Arabic": 16.6, + "Catalan": 76.1, + "Chinese": 31.6, + "Dutch": 57.6, + "English": 63.0, + "French": 44.8, + "German": 43.9, + "Greek": 16.3, + "Hebrew": 1.4, + "Italian": 63.1, + "Japanese": 4.1, + "Khmer": 8.2, + "Latin": 33.8, + "Norwegian": 57.2, + "Persian": 30.7, + "Portuguese": 29.2, + "Sanskrit": 8.1, + "Spanish": 46.6, + "Swedish": 33.3, + "Turkish": 16.9, + "Vietnamese": 44.5 + } + } }, { "modelName": "Gemma 3 (27B)", @@ -378,7 +2178,52 @@ "iconPath": "_static/img/gemma-icon.svg", "printed": 55.3292, "handwritten": 34.1056, - "total": 43.9011 + "total": 43.9011, + "main_language_and_type_metrics": { + "print": { + "Bangla": 61.6, + "Bulgarian": 62.0, + "Chinese": 5.4, + "Czech": 78.3, + "Dutch": 50.4, + "English": 70.4, + "Finnish": 21.0, + "French": 66.8, + "German": 49.7, + "Hindi": 74.3, + "Japanese": 18.3, + "Latin": 69.1, + "Polish": 52.7, + "Romanian": 48.7, + "Sanskrit": 61.4, + "Slovenian": 89.4, + "Spanish": 72.5, + "Swedish": 44.0 + }, + "handwriting": { + "Arabic": 35.6, + "Catalan": 53.4, + "Chinese": 26.5, + "Dutch": 42.4, + "English": 62.1, + "French": 46.9, + "German": 37.9, + "Greek": 29.8, + "Hebrew": 22.7, + "Italian": 53.4, + "Japanese": 9.4, + "Khmer": 9.1, + "Latin": 40.8, + "Norwegian": 56.0, + "Persian": 33.4, + "Portuguese": 33.2, + "Sanskrit": 8.8, + "Spanish": 41.7, + "Swedish": 28.6, + "Turkish": 24.2, + "Vietnamese": 20.4 + } + } }, { "modelName": "Qwen 3.5 (2B)", @@ -387,7 +2232,52 @@ "iconPath": "_static/img/qwen-logo.svg", "printed": 57.6, "handwritten": 31.6, - "total": 43.6 + "total": 43.6, + "main_language_and_type_metrics": { + "print": { + "Bangla": 61.9, + "Bulgarian": 64.8, + "Chinese": 3.0, + "Czech": 79.9, + "Dutch": 63.7, + "English": 80.3, + "Finnish": 34.1, + "French": 78.0, + "German": 55.8, + "Hindi": 48.2, + "Japanese": 20.2, + "Latin": 76.5, + "Polish": 61.3, + "Romanian": 33.8, + "Sanskrit": 38.1, + "Slovenian": 88.5, + "Spanish": 88.3, + "Swedish": 61.1 + }, + "handwriting": { + "Arabic": 14.1, + "Catalan": 67.5, + "Chinese": 24.5, + "Dutch": 42.4, + "English": 59.8, + "French": 52.2, + "German": 37.8, + "Greek": 10.2, + "Hebrew": 14.9, + "Italian": 62.0, + "Japanese": 5.7, + "Khmer": 8.1, + "Latin": 42.6, + "Norwegian": 52.5, + "Persian": 16.5, + "Portuguese": 22.0, + "Sanskrit": 2.9, + "Spanish": 42.6, + "Swedish": 40.9, + "Turkish": 8.1, + "Vietnamese": 36.6 + } + } }, { "modelName": "GPT-4o Mini", @@ -397,7 +2287,52 @@ "iconInvertDark": true, "printed": 52.8335, "handwritten": 29.7653, - "total": 40.4121 + "total": 40.4121, + "main_language_and_type_metrics": { + "print": { + "Bangla": 50.9, + "Bulgarian": 58.7, + "Chinese": 2.4, + "Czech": 76.9, + "Dutch": 56.9, + "English": 77.1, + "Finnish": 17.0, + "French": 66.6, + "German": 48.9, + "Hindi": 57.8, + "Japanese": 11.9, + "Latin": 69.4, + "Polish": 52.1, + "Romanian": 43.8, + "Sanskrit": 42.1, + "Slovenian": 85.2, + "Spanish": 85.4, + "Swedish": 47.9 + }, + "handwriting": { + "Arabic": 32.0, + "Catalan": 49.1, + "Chinese": 15.4, + "Dutch": 37.2, + "English": 64.0, + "French": 42.4, + "German": 32.4, + "Greek": 22.3, + "Hebrew": 14.6, + "Italian": 51.3, + "Japanese": 9.7, + "Khmer": 3.1, + "Latin": 33.2, + "Norwegian": 53.8, + "Persian": 28.0, + "Portuguese": 24.7, + "Sanskrit": 8.6, + "Spanish": 38.4, + "Swedish": 29.7, + "Turkish": 24.7, + "Vietnamese": 10.6 + } + } }, { "modelName": "MiniCPM-V 4.5", @@ -406,7 +2341,52 @@ "iconPath": "_static/img/minicpm-logo.png", "printed": 49.7495, "handwritten": 32.0729, - "total": 40.2314 + "total": 40.2314, + "main_language_and_type_metrics": { + "print": { + "Bangla": 25.3, + "Bulgarian": 30.9, + "Chinese": 2.0, + "Czech": 81.1, + "Dutch": 69.3, + "English": 78.6, + "Finnish": 19.4, + "French": 73.9, + "German": 50.4, + "Hindi": 48.3, + "Japanese": 15.1, + "Latin": 73.5, + "Polish": 52.8, + "Romanian": 43.9, + "Sanskrit": 35.6, + "Slovenian": 74.8, + "Spanish": 73.6, + "Swedish": 47.1 + }, + "handwriting": { + "Arabic": 3.4, + "Catalan": 66.7, + "Chinese": 39.4, + "Dutch": 57.8, + "English": 62.1, + "French": 55.1, + "German": 39.9, + "Greek": 17.6, + "Hebrew": 4.1, + "Italian": 70.5, + "Japanese": 2.0, + "Khmer": 0.8, + "Latin": 42.2, + "Norwegian": 62.9, + "Persian": 2.6, + "Portuguese": 28.2, + "Sanskrit": 1.5, + "Spanish": 43.3, + "Swedish": 45.6, + "Turkish": 2.8, + "Vietnamese": 24.9 + } + } }, { "modelName": "GPT-4.1 Nano", @@ -416,7 +2396,52 @@ "iconInvertDark": true, "printed": 51.8564, "handwritten": 28.3488, - "total": 39.1984 + "total": 39.1984, + "main_language_and_type_metrics": { + "print": { + "Bangla": 36.2, + "Bulgarian": 61.0, + "Chinese": 3.6, + "Czech": 82.5, + "Dutch": 58.2, + "English": 81.3, + "Finnish": 20.4, + "French": 67.8, + "German": 54.4, + "Hindi": 34.1, + "Japanese": 14.0, + "Latin": 71.9, + "Polish": 58.4, + "Romanian": 41.4, + "Sanskrit": 26.1, + "Slovenian": 86.3, + "Spanish": 82.3, + "Swedish": 53.6 + }, + "handwriting": { + "Arabic": 26.2, + "Catalan": 50.4, + "Chinese": 14.5, + "Dutch": 35.3, + "English": 59.9, + "French": 42.9, + "German": 33.9, + "Greek": 22.1, + "Hebrew": 12.6, + "Italian": 53.8, + "Japanese": 8.8, + "Khmer": 9.2, + "Latin": 33.0, + "Norwegian": 54.6, + "Persian": 23.2, + "Portuguese": 20.4, + "Sanskrit": 4.3, + "Spanish": 34.1, + "Swedish": 25.5, + "Turkish": 18.2, + "Vietnamese": 12.4 + } + } }, { "modelName": "MinerU 2.5", @@ -425,7 +2450,52 @@ "iconPath": null, "printed": 56.0, "handwritten": 20.4, - "total": 36.8 + "total": 36.8, + "main_language_and_type_metrics": { + "print": { + "Bangla": 9.2, + "Bulgarian": 16.2, + "Chinese": 7.9, + "Czech": 86.1, + "Dutch": 89.8, + "English": 87.0, + "Finnish": 66.7, + "French": 86.1, + "German": 78.3, + "Hindi": 8.1, + "Japanese": 22.9, + "Latin": 78.2, + "Polish": 77.7, + "Romanian": 31.4, + "Sanskrit": 6.6, + "Slovenian": 91.9, + "Spanish": 86.3, + "Swedish": 77.9 + }, + "handwriting": { + "Arabic": 1.5, + "Catalan": 18.7, + "Chinese": 39.9, + "Dutch": 20.9, + "English": 38.0, + "French": 33.7, + "German": 30.0, + "Greek": 4.0, + "Hebrew": 1.8, + "Italian": 47.9, + "Japanese": 2.9, + "Khmer": 0.3, + "Latin": 38.4, + "Norwegian": 23.1, + "Persian": 2.4, + "Portuguese": 16.9, + "Sanskrit": 0.5, + "Spanish": 35.8, + "Swedish": 38.2, + "Turkish": 0.5, + "Vietnamese": 32.8 + } + } }, { "modelName": "DeepSeek OCR 2", @@ -434,7 +2504,52 @@ "iconPath": "_static/img/deepseek-logo.png", "printed": 56.1, "handwritten": 20.0, - "total": 36.7 + "total": 36.7, + "main_language_and_type_metrics": { + "print": { + "Bangla": 36.0, + "Bulgarian": 68.3, + "Chinese": 5.7, + "Czech": 81.0, + "Dutch": 65.1, + "English": 76.7, + "Finnish": 25.0, + "French": 74.5, + "German": 52.0, + "Hindi": 62.3, + "Japanese": 4.0, + "Latin": 70.0, + "Polish": 61.3, + "Romanian": 44.8, + "Sanskrit": 54.9, + "Slovenian": 92.7, + "Spanish": 84.3, + "Swedish": 51.3 + }, + "handwriting": { + "Arabic": 22.4, + "Catalan": 29.5, + "Chinese": 16.3, + "Dutch": 23.7, + "English": 45.0, + "French": 27.2, + "German": 25.3, + "Greek": 9.0, + "Hebrew": 9.7, + "Italian": 30.0, + "Japanese": 4.0, + "Khmer": 7.0, + "Latin": 29.1, + "Norwegian": 30.5, + "Persian": 16.2, + "Portuguese": 14.2, + "Sanskrit": 2.9, + "Spanish": 23.9, + "Swedish": 12.9, + "Turkish": 20.9, + "Vietnamese": 20.5 + } + } }, { "modelName": "Skywork R1V3", @@ -443,7 +2558,52 @@ "iconPath": "_static/img/skywork-logo.png", "printed": 42.4283, "handwritten": 25.619, - "total": 33.3771 + "total": 33.3771, + "main_language_and_type_metrics": { + "print": { + "Bangla": 20.7, + "Bulgarian": 35.8, + "Chinese": 2.7, + "Czech": 70.6, + "Dutch": 47.2, + "English": 73.4, + "Finnish": 17.6, + "French": 68.7, + "German": 42.3, + "Hindi": 18.8, + "Japanese": 12.2, + "Latin": 67.3, + "Polish": 46.8, + "Romanian": 31.5, + "Sanskrit": 17.1, + "Slovenian": 81.6, + "Spanish": 72.3, + "Swedish": 37.0 + }, + "handwriting": { + "Arabic": 14.5, + "Catalan": 45.2, + "Chinese": 41.0, + "Dutch": 30.7, + "English": 50.6, + "French": 36.9, + "German": 26.0, + "Greek": 16.1, + "Hebrew": 6.0, + "Italian": 37.8, + "Japanese": 5.3, + "Khmer": 7.0, + "Latin": 33.4, + "Norwegian": 36.4, + "Persian": 16.6, + "Portuguese": 25.1, + "Sanskrit": 6.7, + "Spanish": 31.9, + "Swedish": 20.5, + "Turkish": 14.7, + "Vietnamese": 35.4 + } + } }, { "modelName": "InternVL 3.5 (30B-A3B)", @@ -452,7 +2612,52 @@ "iconPath": "_static/img/internvl-logo.png", "printed": 35.8059, "handwritten": 26.3691, - "total": 30.7246 + "total": 30.7246, + "main_language_and_type_metrics": { + "print": { + "Bangla": 16.4, + "Bulgarian": 10.2, + "Chinese": 2.8, + "Czech": 56.5, + "Dutch": 42.7, + "English": 76.3, + "Finnish": 13.1, + "French": 64.6, + "German": 35.3, + "Hindi": 16.0, + "Japanese": 13.2, + "Latin": 62.8, + "Polish": 31.5, + "Romanian": 18.8, + "Sanskrit": 11.6, + "Slovenian": 77.3, + "Spanish": 67.5, + "Swedish": 27.8 + }, + "handwriting": { + "Arabic": 8.3, + "Catalan": 44.4, + "Chinese": 62.5, + "Dutch": 27.5, + "English": 52.4, + "French": 32.7, + "German": 23.1, + "Greek": 17.2, + "Hebrew": 6.3, + "Italian": 41.2, + "Japanese": 5.6, + "Khmer": 6.9, + "Latin": 31.2, + "Norwegian": 40.5, + "Persian": 9.6, + "Portuguese": 20.2, + "Sanskrit": 1.8, + "Spanish": 24.9, + "Swedish": 21.4, + "Turkish": 11.6, + "Vietnamese": 64.4 + } + } }, { "modelName": "Qwen 3.5 (0.8B)", @@ -461,7 +2666,52 @@ "iconPath": "_static/img/qwen-logo.svg", "printed": 41.7, "handwritten": 16.9, - "total": 28.4 + "total": 28.4, + "main_language_and_type_metrics": { + "print": { + "Bangla": 33.2, + "Bulgarian": 44.2, + "Chinese": 1.9, + "Czech": 69.1, + "Dutch": 54.2, + "English": 68.5, + "Finnish": 23.0, + "French": 61.4, + "German": 43.9, + "Hindi": 28.7, + "Japanese": 8.0, + "Latin": 61.4, + "Polish": 31.8, + "Romanian": 19.7, + "Sanskrit": 15.9, + "Slovenian": 77.2, + "Spanish": 67.9, + "Swedish": 40.8 + }, + "handwriting": { + "Arabic": 13.9, + "Catalan": 35.6, + "Chinese": 23.9, + "Dutch": 21.9, + "English": 37.1, + "French": 25.6, + "German": 17.6, + "Greek": 3.9, + "Hebrew": 6.6, + "Italian": 29.4, + "Japanese": 3.1, + "Khmer": 3.7, + "Latin": 18.0, + "Norwegian": 37.7, + "Persian": 8.7, + "Portuguese": 12.3, + "Sanskrit": 2.3, + "Spanish": 14.8, + "Swedish": 19.4, + "Turkish": 9.1, + "Vietnamese": 10.7 + } + } }, { "modelName": "R", @@ -470,7 +2720,52 @@ "iconPath": "_static/img/r4b-logo.png", "printed": 32.6874, "handwritten": 21.735, - "total": 26.79 + "total": 26.79, + "main_language_and_type_metrics": { + "print": { + "Bangla": 19.0, + "Bulgarian": 17.2, + "Chinese": 2.9, + "Czech": 43.9, + "Dutch": 32.0, + "English": 67.7, + "Finnish": 13.9, + "French": 62.0, + "German": 28.4, + "Hindi": 25.2, + "Japanese": 6.3, + "Latin": 48.8, + "Polish": 27.3, + "Romanian": 24.6, + "Sanskrit": 16.9, + "Slovenian": 62.5, + "Spanish": 62.8, + "Swedish": 27.0 + }, + "handwriting": { + "Arabic": 17.7, + "Catalan": 30.0, + "Chinese": 34.9, + "Dutch": 24.8, + "English": 41.5, + "French": 30.0, + "German": 22.7, + "Greek": 11.9, + "Hebrew": 8.9, + "Italian": 30.5, + "Japanese": 5.2, + "Khmer": 3.6, + "Latin": 28.6, + "Norwegian": 30.5, + "Persian": 18.8, + "Portuguese": 21.2, + "Sanskrit": 5.1, + "Spanish": 23.6, + "Swedish": 21.1, + "Turkish": 13.0, + "Vietnamese": 33.0 + } + } }, { "modelName": "GPT-5 Nano", @@ -480,7 +2775,52 @@ "iconInvertDark": true, "printed": 38.7162, "handwritten": 14.165, - "total": 25.4963 + "total": 25.4963, + "main_language_and_type_metrics": { + "print": { + "Bangla": 28.2, + "Bulgarian": 27.9, + "Chinese": 0.5, + "Czech": 68.2, + "Dutch": 32.2, + "English": 73.0, + "Finnish": 5.3, + "French": 65.3, + "German": 41.1, + "Hindi": 28.5, + "Japanese": 6.1, + "Latin": 56.9, + "Polish": 38.7, + "Romanian": 27.3, + "Sanskrit": 13.0, + "Slovenian": 78.0, + "Spanish": 69.5, + "Swedish": 37.2 + }, + "handwriting": { + "Arabic": 8.7, + "Catalan": 24.9, + "Chinese": 11.6, + "Dutch": 14.4, + "English": 50.5, + "French": 28.1, + "German": 18.8, + "Greek": 4.4, + "Hebrew": 1.5, + "Italian": 30.6, + "Japanese": 2.7, + "Khmer": 1.2, + "Latin": 16.1, + "Norwegian": 41.3, + "Persian": 7.2, + "Portuguese": 6.4, + "Sanskrit": 0.9, + "Spanish": 16.8, + "Swedish": 5.6, + "Turkish": 4.4, + "Vietnamese": 1.3 + } + } }, { "modelName": "Nemotron Nano VL", @@ -489,7 +2829,52 @@ "iconPath": "_static/img/nvidia-logo.svg", "printed": 29.3383, "handwritten": 15.2615, - "total": 21.7585 + "total": 21.7585, + "main_language_and_type_metrics": { + "print": { + "Bangla": 7.7, + "Bulgarian": 14.2, + "Chinese": 1.1, + "Czech": 35.8, + "Dutch": 32.7, + "English": 57.1, + "Finnish": 6.6, + "French": 60.0, + "German": 34.3, + "Hindi": 11.7, + "Japanese": 0.4, + "Latin": 55.9, + "Polish": 33.1, + "Romanian": 14.8, + "Sanskrit": 9.4, + "Slovenian": 59.0, + "Spanish": 63.4, + "Swedish": 30.9 + }, + "handwriting": { + "Arabic": 6.2, + "Catalan": 28.5, + "Chinese": 0.8, + "Dutch": 22.7, + "English": 38.3, + "French": 26.0, + "German": 21.1, + "Greek": 4.2, + "Hebrew": 3.5, + "Italian": 34.0, + "Japanese": 0.9, + "Khmer": 2.9, + "Latin": 23.5, + "Norwegian": 37.0, + "Persian": 7.7, + "Portuguese": 17.1, + "Sanskrit": 2.0, + "Spanish": 23.0, + "Swedish": 15.7, + "Turkish": 4.3, + "Vietnamese": 1.0 + } + } }, { "modelName": "dots.ocr 1.5", @@ -498,7 +2883,52 @@ "iconPath": "_static/img/dots-logo.png", "printed": 22.2, "handwritten": 10.4, - "total": 15.9 + "total": 15.9, + "main_language_and_type_metrics": { + "print": { + "Bangla": 8.7, + "Bulgarian": 54.5, + "Chinese": 4.2, + "Czech": 30.7, + "Dutch": 31.0, + "English": 15.1, + "Finnish": 16.7, + "French": 12.1, + "German": 23.9, + "Hindi": 21.6, + "Japanese": 5.7, + "Latin": 27.9, + "Polish": 37.0, + "Romanian": 10.3, + "Sanskrit": 13.3, + "Slovenian": 19.9, + "Spanish": 40.8, + "Swedish": 26.6 + }, + "handwriting": { + "Arabic": 2.8, + "Catalan": 22.1, + "Chinese": 3.9, + "Dutch": 22.6, + "English": 21.1, + "French": 16.3, + "German": 13.9, + "Greek": 3.3, + "Hebrew": 3.7, + "Italian": 19.3, + "Japanese": 0.7, + "Khmer": 0.6, + "Latin": 11.9, + "Norwegian": 31.2, + "Persian": 5.8, + "Portuguese": 0.5, + "Sanskrit": 0.6, + "Spanish": 13.7, + "Swedish": 25.1, + "Turkish": 0.0, + "Vietnamese": 0.2 + } + } }, { "modelName": "Phi 4 Multimodal", @@ -507,7 +2937,52 @@ "iconPath": "_static/img/phi4-logo.png", "printed": 9.0269, "handwritten": 5.3393, - "total": 7.0413 + "total": 7.0413, + "main_language_and_type_metrics": { + "print": { + "Bangla": 2.2, + "Bulgarian": 1.7, + "Chinese": 0.7, + "Czech": 4.0, + "Dutch": 7.0, + "English": 42.9, + "Finnish": 5.2, + "French": 11.6, + "German": 5.3, + "Hindi": 3.7, + "Japanese": 1.8, + "Latin": 11.4, + "Polish": 6.3, + "Romanian": 5.6, + "Sanskrit": 3.5, + "Slovenian": 25.6, + "Spanish": 16.7, + "Swedish": 7.4 + }, + "handwriting": { + "Arabic": 1.1, + "Catalan": 9.1, + "Chinese": 1.3, + "Dutch": 5.2, + "English": 20.8, + "French": 6.5, + "German": 8.6, + "Greek": 2.6, + "Hebrew": 0.9, + "Italian": 12.5, + "Japanese": 0.7, + "Khmer": 1.5, + "Latin": 8.1, + "Norwegian": 13.3, + "Persian": 1.0, + "Portuguese": 5.9, + "Sanskrit": 0.6, + "Spanish": 4.5, + "Swedish": 5.4, + "Turkish": 0.9, + "Vietnamese": 1.5 + } + } }, { "modelName": "GLM OCR", @@ -516,6 +2991,51 @@ "iconPath": "_static/img/glm-ocr-logo.svg", "printed": 65.2, "handwritten": 40.2, - "total": 51.8 + "total": 51.8, + "main_language_and_type_metrics": { + "print": { + "Bangla": 31.0, + "Bulgarian": 80.4, + "Chinese": 5.5, + "Czech": 93.7, + "Dutch": 85.7, + "English": 86.9, + "Finnish": 38.1, + "French": 83.5, + "German": 72.7, + "Hindi": 32.0, + "Japanese": 66.8, + "Latin": 84.0, + "Polish": 71.9, + "Romanian": 59.4, + "Sanskrit": 19.6, + "Slovenian": 95.9, + "Spanish": 92.6, + "Swedish": 74.0 + }, + "handwriting": { + "Arabic": 6.1, + "Catalan": 80.1, + "Chinese": 71.1, + "Dutch": 61.8, + "English": 62.3, + "French": 57.3, + "German": 44.8, + "Greek": 11.1, + "Hebrew": 8.8, + "Italian": 81.6, + "Japanese": 17.0, + "Khmer": 2.2, + "Latin": 56.1, + "Norwegian": 70.3, + "Persian": 4.9, + "Portuguese": 34.5, + "Sanskrit": 2.3, + "Spanish": 50.2, + "Swedish": 60.9, + "Turkish": 3.0, + "Vietnamese": 58.3 + } + } } ] diff --git a/docs/_static/css/custom.css b/docs/_static/css/custom.css index df6d205..534af8a 100644 --- a/docs/_static/css/custom.css +++ b/docs/_static/css/custom.css @@ -108,10 +108,19 @@ html[data-theme="dark"] { font-size: 0.9rem; font-weight: 600; position: sticky; - top: 0; z-index: 1; } +.benchmark-table thead tr:first-child th { + top: 0; + z-index: 3; +} + +.benchmark-table thead tr:nth-child(2) th { + top: 3.4rem; + z-index: 2; +} + .benchmark-table tbody tr:nth-child(even) { background: rgba(139, 69, 31, 0.03); } @@ -132,11 +141,55 @@ html[data-theme="dark"] { color: var(--pst-color-link-hover); } +.benchmark-group-header { + align-items: center; + display: flex; + gap: 0.75rem; + justify-content: space-between; +} + +.benchmark-group-header--expanded { + justify-content: flex-start; +} + +.benchmark-group-label { + color: inherit; + font-weight: 600; +} + +.benchmark-group-toggle { + appearance: none; + background: rgba(139, 69, 31, 0.08); + border: 1px solid rgba(139, 69, 31, 0.16); + border-radius: 999px; + color: inherit; + font: inherit; + font-size: 0.76rem; + font-weight: 600; + padding: 0.22rem 0.55rem; + white-space: nowrap; +} + +.benchmark-group-toggle:hover:not(:disabled) { + background: rgba(139, 69, 31, 0.12); + color: var(--pst-color-link-hover); +} + +.benchmark-group-toggle:disabled { + cursor: not-allowed; + opacity: 0.6; +} + .benchmark-sort-indicator { color: var(--pst-color-text-muted); font-size: 0.8rem; } +.benchmark-subcolumn { + font-size: 0.8rem; + min-width: 6rem; +} + .benchmark-rank, .benchmark-score { text-align: right; @@ -233,6 +286,12 @@ html[data-theme="dark"] .benchmark-table tbody tr:nth-child(even) { background: rgba(244, 178, 94, 0.05); } +html[data-theme="dark"] .benchmark-group-toggle, +[data-bs-theme="dark"] .benchmark-group-toggle { + background: rgba(244, 178, 94, 0.1); + border-color: rgba(244, 178, 94, 0.2); +} + html[data-theme="dark"] .benchmark-model-icon-placeholder, [data-bs-theme="dark"] .benchmark-model-icon-placeholder { background: rgba(244, 178, 94, 0.08); @@ -288,3 +347,26 @@ html[data-theme="dark"] .bd-content .benchmark-model-icon--invert-dark, padding-inline: 0.9rem; } } + +@media (max-width: 960px) { + .benchmark-table th, + .benchmark-table td { + padding: 0.72rem 0.65rem; + } + + .benchmark-group-header { + align-items: start; + flex-direction: column; + } + + .benchmark-group-header--expanded { + align-items: center; + flex-direction: row; + flex-wrap: wrap; + justify-content: flex-start; + } + + .benchmark-table thead tr:nth-child(2) th { + top: 4.55rem; + } +} diff --git a/docs/_static/js/benchmark-leaderboard.js b/docs/_static/js/benchmark-leaderboard.js index 47f959b..202f4ad 100644 --- a/docs/_static/js/benchmark-leaderboard.js +++ b/docs/_static/js/benchmark-leaderboard.js @@ -3,17 +3,12 @@ minimumFractionDigits: 1, maximumFractionDigits: 1, }); - - const COLUMN_DEFINITIONS = [ - { key: "modelName", label: "Model", numeric: false }, - { key: "printed", label: "Printed", numeric: true }, - { key: "handwritten", label: "Handwritten", numeric: true }, - { key: "total", label: "Total", numeric: true }, + const GROUP_DEFINITIONS = [ + { key: "printed", label: "Printed", metricType: "print" }, + { key: "handwritten", label: "Handwritten", metricType: "handwriting" }, ]; - const NUMERIC_COLUMNS = new Set( - COLUMN_DEFINITIONS.filter((column) => column.numeric).map((column) => column.key), - ); const DEFAULT_SORT_STATE = { key: "total", direction: "desc" }; + const DEFAULT_EXPANDED_GROUPS = { printed: false, handwritten: false }; function contentRoot() { return document.documentElement.dataset.content_root || ""; @@ -36,6 +31,56 @@ return SCORE_FORMATTER.format(value); } + function getGroupDefinition(groupKey) { + return GROUP_DEFINITIONS.find((group) => group.key === groupKey) || null; + } + + function getGroupForColumn(columnKey) { + const groupKey = columnKey.includes(":") ? columnKey.split(":")[0] : columnKey; + return getGroupDefinition(groupKey); + } + + function getLanguageMetrics(row, group) { + return row.main_language_and_type_metrics?.[group.metricType] || {}; + } + + function getGroupLanguages(rows) { + const languagesByGroup = {}; + + GROUP_DEFINITIONS.forEach((group) => { + const languages = new Set(); + rows.forEach((row) => { + Object.keys(getLanguageMetrics(row, group)).forEach((language) => { + languages.add(language); + }); + }); + languagesByGroup[group.key] = [...languages].sort((left, right) => left.localeCompare(right)); + }); + + return languagesByGroup; + } + + function getColumnValue(row, columnKey) { + if (columnKey === "modelName") { + return row.modelName; + } + if (columnKey === "printed" || columnKey === "handwritten" || columnKey === "total") { + return row[columnKey]; + } + + const [groupKey, language] = columnKey.split(":"); + const group = getGroupDefinition(groupKey); + if (!group || !language) { + return undefined; + } + + return getLanguageMetrics(row, group)[language]; + } + + function isNumericColumn(columnKey) { + return columnKey !== "modelName"; + } + function createIconFrame(row) { const iconFrame = document.createElement("span"); iconFrame.className = "benchmark-model-icon-frame"; @@ -63,13 +108,19 @@ function compareRows(left, right, sortState) { const { key, direction } = sortState; const multiplier = direction === "asc" ? 1 : -1; - const leftValue = left[key]; - const rightValue = right[key]; + const leftValue = getColumnValue(left, key); + const rightValue = getColumnValue(right, key); - if (NUMERIC_COLUMNS.has(key)) { + if (isNumericColumn(key)) { const leftNumber = Number(leftValue); const rightNumber = Number(rightValue); + if (Number.isNaN(leftNumber) && !Number.isNaN(rightNumber)) { + return 1; + } + if (!Number.isNaN(leftNumber) && Number.isNaN(rightNumber)) { + return -1; + } if (!Number.isNaN(leftNumber) && !Number.isNaN(rightNumber) && leftNumber !== rightNumber) { return (leftNumber - rightNumber) * multiplier; } @@ -110,29 +161,117 @@ return wrapper; } - function createHeaderButton(column, sortState, onSort) { + function createHeaderButton(columnKey, label, sortState, onSort) { const button = document.createElement("button"); button.className = "benchmark-sort-button"; button.type = "button"; - button.textContent = column.label; - button.dataset.sortKey = column.key; + button.textContent = label; + button.dataset.sortKey = columnKey; const indicator = document.createElement("span"); indicator.className = "benchmark-sort-indicator"; - if (sortState.key === column.key) { + if (sortState.key === columnKey) { indicator.textContent = sortState.direction === "asc" ? "▲" : "▼"; } else { indicator.textContent = "↕"; } button.append(indicator); - button.addEventListener("click", () => onSort(column.key)); + button.addEventListener("click", () => onSort(columnKey)); + return button; + } + + function createGroupToggle(group, expanded, languageCount, onToggle) { + const button = document.createElement("button"); + button.className = "benchmark-group-toggle"; + button.type = "button"; + button.textContent = expanded ? "Collapse" : "Expand"; + button.disabled = languageCount === 0; + button.setAttribute("aria-expanded", expanded ? "true" : "false"); + button.setAttribute( + "aria-label", + expanded + ? `Collapse ${group.label.toLowerCase()} language columns` + : `Expand ${group.label.toLowerCase()} language columns (${languageCount} languages)`, + ); + button.title = + languageCount === 0 + ? `No per-language ${group.label.toLowerCase()} scores available` + : expanded + ? `Hide ${group.label.toLowerCase()} language scores` + : `Show ${languageCount} ${group.label.toLowerCase()} language scores`; + button.addEventListener("click", () => onToggle(group.key)); return button; } - function renderLeaderboard(container, rows, sortState) { + function createStandaloneHeaderCell(columnKey, label, sortState, onSort) { + const th = document.createElement("th"); + th.scope = "col"; + th.append(createHeaderButton(columnKey, label, sortState, onSort)); + return th; + } + + function createFlatGroupHeaderCell(group, sortState, onSort, onToggle, expanded, languageCount) { + const th = document.createElement("th"); + th.scope = "col"; + + const wrapper = document.createElement("div"); + wrapper.className = "benchmark-group-header"; + wrapper.append(createHeaderButton(group.key, group.label, sortState, onSort)); + wrapper.append(createGroupToggle(group, expanded, languageCount, onToggle)); + th.append(wrapper); + + return th; + } + + function createExpandedGroupHeaderCell(group, onToggle, languageCount) { + const th = document.createElement("th"); + th.scope = "colgroup"; + th.colSpan = languageCount + 1; + + const wrapper = document.createElement("div"); + wrapper.className = "benchmark-group-header benchmark-group-header--expanded"; + + const label = document.createElement("span"); + label.className = "benchmark-group-label"; + label.textContent = group.label; + wrapper.append(label); + wrapper.append(createGroupToggle(group, true, languageCount, onToggle)); + + th.append(wrapper); + return th; + } + + function createVisibleScoreColumns(groupLanguages, expandedGroups) { + const visibleColumns = []; + + GROUP_DEFINITIONS.forEach((group) => { + visibleColumns.push(group.key); + if (expandedGroups[group.key]) { + groupLanguages[group.key].forEach((language) => { + visibleColumns.push(`${group.key}:${language}`); + }); + } + }); + + visibleColumns.push("total"); + return visibleColumns; + } + + function normalizeSortState(sortState, expandedGroups) { + const group = getGroupForColumn(sortState.key); + if (group && sortState.key.includes(":") && !expandedGroups[group.key]) { + sortState.key = group.key; + } + } + + function renderLeaderboard(container, rows, sortState, expandedGroups) { + normalizeSortState(sortState, expandedGroups); container.replaceChildren(); + const groupLanguages = getGroupLanguages(rows); const sortedRows = [...rows].sort((left, right) => compareRows(left, right, sortState)); + const hasExpandedGroups = GROUP_DEFINITIONS.some((group) => expandedGroups[group.key]); + const visibleScoreColumns = createVisibleScoreColumns(groupLanguages, expandedGroups); const wrapper = document.createElement("div"); wrapper.className = "benchmark-table-wrapper"; @@ -140,14 +279,6 @@ const table = document.createElement("table"); table.className = "benchmark-table"; - const thead = document.createElement("thead"); - const headerRow = document.createElement("tr"); - - const rankHeader = document.createElement("th"); - rankHeader.scope = "col"; - rankHeader.textContent = "#"; - headerRow.append(rankHeader); - const handleSort = (key) => { if (sortState.key === key) { sortState.direction = sortState.direction === "desc" ? "asc" : "desc"; @@ -155,17 +286,97 @@ sortState.key = key; sortState.direction = key === "modelName" ? "asc" : "desc"; } - renderLeaderboard(container, rows, sortState); + renderLeaderboard(container, rows, sortState, expandedGroups); + }; + + const handleToggle = (groupKey) => { + expandedGroups[groupKey] = !expandedGroups[groupKey]; + normalizeSortState(sortState, expandedGroups); + renderLeaderboard(container, rows, sortState, expandedGroups); }; - for (const column of COLUMN_DEFINITIONS) { - const th = document.createElement("th"); - th.scope = "col"; - th.append(createHeaderButton(column, sortState, handleSort)); - headerRow.append(th); + const thead = document.createElement("thead"); + const topHeaderRow = document.createElement("tr"); + + const rankHeader = document.createElement("th"); + rankHeader.scope = "col"; + rankHeader.textContent = "#"; + if (hasExpandedGroups) { + rankHeader.rowSpan = 2; } + topHeaderRow.append(rankHeader); - thead.append(headerRow); + const modelHeader = createStandaloneHeaderCell("modelName", "Model", sortState, handleSort); + if (hasExpandedGroups) { + modelHeader.rowSpan = 2; + } + topHeaderRow.append(modelHeader); + + if (!hasExpandedGroups) { + GROUP_DEFINITIONS.forEach((group) => { + topHeaderRow.append( + createFlatGroupHeaderCell( + group, + sortState, + handleSort, + handleToggle, + false, + groupLanguages[group.key].length, + ), + ); + }); + topHeaderRow.append(createStandaloneHeaderCell("total", "Total", sortState, handleSort)); + thead.append(topHeaderRow); + } else { + GROUP_DEFINITIONS.forEach((group) => { + if (expandedGroups[group.key]) { + topHeaderRow.append( + createExpandedGroupHeaderCell(group, handleToggle, groupLanguages[group.key].length), + ); + return; + } + + const flatHeader = createFlatGroupHeaderCell( + group, + sortState, + handleSort, + handleToggle, + false, + groupLanguages[group.key].length, + ); + flatHeader.rowSpan = 2; + topHeaderRow.append(flatHeader); + }); + + const totalHeader = createStandaloneHeaderCell("total", "Total", sortState, handleSort); + totalHeader.rowSpan = 2; + topHeaderRow.append(totalHeader); + thead.append(topHeaderRow); + + const subheaderRow = document.createElement("tr"); + GROUP_DEFINITIONS.forEach((group) => { + if (!expandedGroups[group.key]) { + return; + } + + const overallHeader = document.createElement("th"); + overallHeader.className = "benchmark-subcolumn"; + overallHeader.scope = "col"; + overallHeader.append(createHeaderButton(group.key, "Overall", sortState, handleSort)); + subheaderRow.append(overallHeader); + + groupLanguages[group.key].forEach((language) => { + const languageHeader = document.createElement("th"); + languageHeader.className = "benchmark-subcolumn"; + languageHeader.scope = "col"; + languageHeader.append( + createHeaderButton(`${group.key}:${language}`, language, sortState, handleSort), + ); + subheaderRow.append(languageHeader); + }); + }); + thead.append(subheaderRow); + } table.append(thead); const tbody = document.createElement("tbody"); @@ -185,10 +396,10 @@ modelCell.append(createModelCell(row)); tr.append(modelCell); - for (const key of ["printed", "handwritten", "total"]) { + for (const key of visibleScoreColumns) { const scoreCell = document.createElement("td"); scoreCell.className = "benchmark-score"; - scoreCell.textContent = formatScore(row[key]); + scoreCell.textContent = formatScore(getColumnValue(row, key)); tr.append(scoreCell); } @@ -217,7 +428,8 @@ const rows = await response.json(); const sortState = { ...DEFAULT_SORT_STATE }; - renderLeaderboard(container, rows, sortState); + const expandedGroups = { ...DEFAULT_EXPANDED_GROUPS }; + renderLeaderboard(container, rows, sortState, expandedGroups); } catch (error) { renderError(container, "Unable to load the benchmark leaderboard data."); console.error("[benchmark-leaderboard] failed to initialize", error); diff --git a/docs/leaderboard.md b/docs/leaderboard.md index fd1028e..e0b9806 100644 --- a/docs/leaderboard.md +++ b/docs/leaderboard.md @@ -4,6 +4,8 @@
+Click "Expand" in the Printed or Handwritten header to reveal per-language scores for that subset. + Printed : performance on printed-document pages. From 588e4cd9683d76fa0caf64b495e1ea8d2e99176d Mon Sep 17 00:00:00 2001 From: Sina Date: Tue, 14 Apr 2026 07:38:46 +0000 Subject: [PATCH 48/58] feat(providers): add FireRed-OCR support --- docs/guides/advanced-customization.md | 1 + docs/guides/providers.md | 4 +- src/churro_ocr/providers/_ocr_processing.py | 18 +++ src/churro_ocr/providers/specs.py | 32 ++++++ src/churro_ocr/templates/__init__.py | 6 + src/churro_ocr/templates/presets.py | 33 ++++++ tests/test_cli.py | 24 ++++ tests/test_hf_ocr.py | 64 +++++++++++ tests/test_providers.py | 118 ++++++++++++++++++++ tests/test_tooling_benchmark.py | 51 +++++++++ 10 files changed, 350 insertions(+), 1 deletion(-) diff --git a/docs/guides/advanced-customization.md b/docs/guides/advanced-customization.md index 6a600cd..82bae68 100644 --- a/docs/guides/advanced-customization.md +++ b/docs/guides/advanced-customization.md @@ -45,6 +45,7 @@ Use the [templates API](../api/templates.md) for exact signatures. | `CHURRO_3B_XML_TEMPLATE` | Built-in template for `stanford-oval/churro-3B`. | | `CHANDRA_OCR_2_OCR_TEMPLATE` | Built-in template for `datalab-to/chandra-ocr-2`. | | `DEEPSEEK_OCR_2_OCR_TEMPLATE` | Built-in template for `deepseek-ai/DeepSeek-OCR-2`. | +| `FIRERED_OCR_OCR_TEMPLATE` | Built-in markdown-conversion template for `FireRedTeam/FireRed-OCR`. | | `DOTS_OCR_1_5_OCR_TEMPLATE` | Built-in template for `kristaller486/dots.ocr-1.5`. | | `DOTS_MOCR_OCR_TEMPLATE` | Built-in template for `rednote-hilab/dots.mocr`. | | `INFINITY_PARSER_7B_OCR_TEMPLATE` | Built-in markdown-conversion template for `infly/Infinity-Parser-7B`. | diff --git a/docs/guides/providers.md b/docs/guides/providers.md index 2cde803..f3e7c07 100644 --- a/docs/guides/providers.md +++ b/docs/guides/providers.md @@ -59,7 +59,9 @@ backend = build_ocr_backend( ) ``` -Built-in model-specific profiles are resolved automatically for known models such as `stanford-oval/churro-3B`, `datalab-to/chandra-ocr-2`, `deepseek-ai/DeepSeek-OCR-2`, `zai-org/GLM-OCR`, `kristaller486/dots.ocr-1.5`, `rednote-hilab/dots.mocr`, `infly/Infinity-Parser-7B`, `opendatalab/MinerU2.5-2509-1.2B`, `PaddlePaddle/PaddleOCR-VL-1.5`, `LiquidAI/LFM2.5-VL-1.6B`, and the supported `olmOCR` checkpoints. +Built-in model-specific profiles are resolved automatically for known models such as `stanford-oval/churro-3B`, `datalab-to/chandra-ocr-2`, `deepseek-ai/DeepSeek-OCR-2`, `FireRedTeam/FireRed-OCR`, `zai-org/GLM-OCR`, `kristaller486/dots.ocr-1.5`, `rednote-hilab/dots.mocr`, `infly/Infinity-Parser-7B`, `opendatalab/MinerU2.5-2509-1.2B`, `PaddlePaddle/PaddleOCR-VL-1.5`, `LiquidAI/LFM2.5-VL-1.6B`, and the supported `olmOCR` checkpoints. + +For `FireRedTeam/FireRed-OCR`, the built-in `hf` and `openai-compatible` backends use the model's published Markdown-conversion prompt. The OCR result preserves the raw markdown in metadata, and repo-local benchmark evaluation normalizes that markdown or embedded HTML back to plain text before metrics are computed. For `zai-org/GLM-OCR`, the built-in `hf` and `openai-compatible` backends both use the model's documented `Text Recognition:` prompt so local Transformers inference and served vLLM inference stay aligned. diff --git a/src/churro_ocr/providers/_ocr_processing.py b/src/churro_ocr/providers/_ocr_processing.py index 44d7e47..caee61e 100644 --- a/src/churro_ocr/providers/_ocr_processing.py +++ b/src/churro_ocr/providers/_ocr_processing.py @@ -18,6 +18,7 @@ ) from churro_ocr.templates import ( DEEPSEEK_OCR_2_OCR_PROMPT, + FIRERED_OCR_OCR_PROMPT, GLM_OCR_OCR_PROMPT, INFINITY_PARSER_7B_OCR_PROMPT, INFINITY_PARSER_7B_SYSTEM_PROMPT, @@ -177,6 +178,23 @@ def infinity_parser_7b_text_postprocessor(text: str) -> TextPostprocessorResult: } +def firered_ocr_text_postprocessor(text: str) -> TextPostprocessorResult: + """Normalize FireRed-OCR markdown output to plain text and preserve raw markdown.""" + cleaned = strip_leading_chat_scaffold(text, prompts=[FIRERED_OCR_OCR_PROMPT]) + for _ in range(8): + previous = cleaned + for token in ("<|im_end|>", "<|endoftext|>", "<|assistant|>", "<|user|>", "<|system|>"): + if cleaned.endswith(token): + cleaned = cleaned[: -len(token)].rstrip() + break + if cleaned == previous: + break + raw_markdown = strip_outer_fenced_code_block(cleaned) + return strip_rich_ocr_markup_to_plain_text(raw_markdown), { + "raw_markdown": raw_markdown, + } + + def deepseek_ocr_2_text_postprocessor(text: str) -> str: """Strip DeepSeek OCR 2 prompt echoes, chat scaffold, and trailing stop tokens.""" cleaned = text.strip() diff --git a/src/churro_ocr/providers/specs.py b/src/churro_ocr/providers/specs.py index 894e789..6b0f8a2 100644 --- a/src/churro_ocr/providers/specs.py +++ b/src/churro_ocr/providers/specs.py @@ -16,6 +16,7 @@ deepseek_ocr_2_text_postprocessor, default_ocr_image_preprocessor, default_ocr_text_postprocessor, + firered_ocr_text_postprocessor, glm_ocr_image_preprocessor, glm_ocr_text_postprocessor, identity_text_postprocessor, @@ -37,6 +38,8 @@ DOTS_MOCR_OCR_TEMPLATE, DOTS_OCR_1_5_MODEL_ID, DOTS_OCR_1_5_OCR_TEMPLATE, + FIRERED_OCR_MODEL_ID, + FIRERED_OCR_OCR_TEMPLATE, GLM_OCR_MODEL_ID, GLM_OCR_OCR_TEMPLATE, INFINITY_PARSER_7B_MODEL_ID, @@ -68,6 +71,7 @@ DEFAULT_OCR_MAX_TOKENS = 25_000 CHANDRA_OCR_MAX_TOKENS = 12_384 DEEPSEEK_OCR_2_MAX_TOKENS = 8_192 +FIRERED_OCR_MAX_TOKENS = 4_096 GLM_OCR_MAX_TOKENS = 8_192 INFINITY_PARSER_7B_MAX_TOKENS = 8_192 OLMOCR_MAX_TOKENS = 8_000 @@ -288,6 +292,30 @@ def deepseek_ocr_2_profile() -> OCRModelProfile: ) +def firered_ocr_profile() -> OCRModelProfile: + """Return the built-in ``FireRedTeam/FireRed-OCR`` OCR profile.""" + return OCRModelProfile( + profile_name=FIRERED_OCR_MODEL_ID, + template=FIRERED_OCR_OCR_TEMPLATE, + image_preprocessor=default_ocr_image_preprocessor, + text_postprocessor=firered_ocr_text_postprocessor, + display_name="FireRed-OCR", + transport=LiteLLMTransportConfig( + completion_kwargs={ + "max_tokens": FIRERED_OCR_MAX_TOKENS, + "temperature": 0.0, + "top_p": 1.0, + } + ), + huggingface=HuggingFaceOptions( + generation_kwargs={ + "max_new_tokens": FIRERED_OCR_MAX_TOKENS, + "do_sample": False, + }, + ), + ) + + def glm_ocr_profile() -> OCRModelProfile: """Return the built-in ``zai-org/GLM-OCR`` OCR profile.""" return OCRModelProfile( @@ -484,6 +512,7 @@ def _profile_registry() -> dict[str, OCRModelProfile]: churro_profile = churro_3b_profile() chandra_profile = chandra_ocr_2_profile() deepseek_profile = deepseek_ocr_2_profile() + firered_profile = firered_ocr_profile() glm_profile = glm_ocr_profile() dots_mocr = dots_mocr_profile() dots_profile = dots_ocr_1_5_profile() @@ -498,6 +527,7 @@ def _profile_registry() -> dict[str, OCRModelProfile]: churro_profile.profile_name: churro_profile, chandra_profile.profile_name: chandra_profile, deepseek_profile.profile_name: deepseek_profile, + firered_profile.profile_name: firered_profile, glm_profile.profile_name: glm_profile, dots_mocr.profile_name: dots_mocr, dots_profile.profile_name: dots_profile, @@ -561,6 +591,8 @@ def resolve_ocr_profile( "default_ocr_image_preprocessor", "default_ocr_profile", "default_ocr_text_postprocessor", + "firered_ocr_profile", + "firered_ocr_text_postprocessor", "glm_ocr_image_preprocessor", "glm_ocr_profile", "glm_ocr_text_postprocessor", diff --git a/src/churro_ocr/templates/__init__.py b/src/churro_ocr/templates/__init__.py index f1a39f9..d60dbc6 100644 --- a/src/churro_ocr/templates/__init__.py +++ b/src/churro_ocr/templates/__init__.py @@ -23,6 +23,9 @@ DOTS_OCR_1_5_MODEL_ID, DOTS_OCR_1_5_OCR_PROMPT, DOTS_OCR_1_5_OCR_TEMPLATE, + FIRERED_OCR_MODEL_ID, + FIRERED_OCR_OCR_PROMPT, + FIRERED_OCR_OCR_TEMPLATE, GLM_OCR_MODEL_ID, GLM_OCR_OCR_PROMPT, GLM_OCR_OCR_TEMPLATE, @@ -67,6 +70,9 @@ "DOTS_OCR_1_5_MODEL_ID", "DOTS_OCR_1_5_OCR_PROMPT", "DOTS_OCR_1_5_OCR_TEMPLATE", + "FIRERED_OCR_MODEL_ID", + "FIRERED_OCR_OCR_PROMPT", + "FIRERED_OCR_OCR_TEMPLATE", "GLM_OCR_MODEL_ID", "GLM_OCR_OCR_PROMPT", "GLM_OCR_OCR_TEMPLATE", diff --git a/src/churro_ocr/templates/presets.py b/src/churro_ocr/templates/presets.py index 9fb06b1..ea7eb33 100644 --- a/src/churro_ocr/templates/presets.py +++ b/src/churro_ocr/templates/presets.py @@ -14,6 +14,7 @@ CHANDRA_OCR_2_MODEL_ID = "datalab-to/chandra-ocr-2" DEEPSEEK_OCR_2_MODEL_ID = "deepseek-ai/DeepSeek-OCR-2" GLM_OCR_MODEL_ID = "zai-org/GLM-OCR" +FIRERED_OCR_MODEL_ID = "FireRedTeam/FireRed-OCR" DOTS_OCR_1_5_MODEL_ID = "kristaller486/dots.ocr-1.5" DOTS_MOCR_MODEL_ID = "rednote-hilab/dots.mocr" INFINITY_PARSER_7B_MODEL_ID = "infly/Infinity-Parser-7B" @@ -45,6 +46,35 @@ system_message=None, user_prompt=GLM_OCR_OCR_PROMPT, ) +FIRERED_OCR_OCR_PROMPT = ( + "You are an AI assistant specialized in converting PDF images to Markdown format. " + "Please follow these instructions for the conversion:\n\n" + "1. Text Processing:\n" + "- Accurately recognize all text content in the PDF image without guessing or inferring.\n" + "- Convert the recognized text into Markdown format.\n" + "- Maintain the original document structure, including headings, paragraphs, lists, etc.\n\n" + "2. Mathematical Formula Processing:\n" + "- Convert all mathematical formulas to LaTeX format.\n" + "- Enclose inline formulas with,(,). For example: This is an inline formula,( E = mc^2,)\n" + "- Enclose block formulas with,\\[,\\]. For example:,[,frac{-b,pm,sqrt{b^2 - 4ac}}{2a},]\n\n" + "3. Table Processing:\n" + "- Convert tables to HTML format.\n" + "- Wrap the entire table with

and
.\n\n" + "4. Figure Handling:\n" + "- Ignore figures content in the PDF image. Do not attempt to describe or convert images.\n\n" + "5. Output Format:\n" + "- Ensure the output Markdown document has a clear structure with appropriate line breaks " + "between elements.\n" + "- For complex layouts, try to maintain the original document's structure and format as " + "closely as possible.\n\n" + "Please strictly follow these guidelines to ensure accuracy and consistency in the " + "conversion. Your task is to accurately convert the content of the PDF image into Markdown " + "format without adding any extra explanations or comments." +) +FIRERED_OCR_OCR_TEMPLATE = HFChatTemplate( + system_message=None, + user_prompt=FIRERED_OCR_OCR_PROMPT, +) DOTS_OCR_1_5_OCR_PROMPT = "Extract the text content from this image." DOTS_OCR_1_5_OCR_TEMPLATE = HFChatTemplate( system_message=None, @@ -127,6 +157,9 @@ "DOTS_OCR_1_5_MODEL_ID", "DOTS_OCR_1_5_OCR_PROMPT", "DOTS_OCR_1_5_OCR_TEMPLATE", + "FIRERED_OCR_MODEL_ID", + "FIRERED_OCR_OCR_PROMPT", + "FIRERED_OCR_OCR_TEMPLATE", "GLM_OCR_MODEL_ID", "GLM_OCR_OCR_PROMPT", "GLM_OCR_OCR_TEMPLATE", diff --git a/tests/test_cli.py b/tests/test_cli.py index c8b0ae0..a7f0e30 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -25,6 +25,8 @@ DEFAULT_OCR_TEMPLATE, DOTS_MOCR_OCR_TEMPLATE, DOTS_OCR_1_5_OCR_TEMPLATE, + FIRERED_OCR_MODEL_ID, + FIRERED_OCR_OCR_TEMPLATE, GLM_OCR_MODEL_ID, GLM_OCR_OCR_TEMPLATE, INFINITY_PARSER_7B_MODEL_ID, @@ -280,6 +282,28 @@ def test_build_ocr_backend_aligns_templates_for_deepseek_ocr_2() -> None: } +def test_build_ocr_backend_aligns_templates_for_firered_ocr() -> None: + litellm_backend = _build_litellm_backend(FIRERED_OCR_MODEL_ID) + hf_backend = _build_hf_backend(FIRERED_OCR_MODEL_ID) + openai_backend = _build_openai_compatible_backend(FIRERED_OCR_MODEL_ID) + + assert litellm_backend.template == FIRERED_OCR_OCR_TEMPLATE + assert litellm_backend.template == hf_backend.template == openai_backend.template + assert litellm_backend.model_name == "FireRed-OCR" + assert hf_backend.model_name == "FireRed-OCR" + assert openai_backend.model_name == "FireRed-OCR" + assert litellm_backend.transport.config.completion_kwargs == { + "max_tokens": 4_096, + "temperature": 0.0, + "top_p": 1.0, + } + assert openai_backend.transport.config.completion_kwargs == { + "max_tokens": 4_096, + "temperature": 0.0, + "top_p": 1.0, + } + + def test_build_ocr_backend_aligns_templates_for_glm_ocr() -> None: litellm_backend = _build_litellm_backend(GLM_OCR_MODEL_ID) hf_backend = _build_hf_backend(GLM_OCR_MODEL_ID) diff --git a/tests/test_hf_ocr.py b/tests/test_hf_ocr.py index 08a9e87..c467ee1 100644 --- a/tests/test_hf_ocr.py +++ b/tests/test_hf_ocr.py @@ -40,6 +40,7 @@ from churro_ocr.providers.specs import ( DEFAULT_OCR_MAX_TOKENS, deepseek_ocr_2_text_postprocessor, + firered_ocr_text_postprocessor, glm_ocr_text_postprocessor, infinity_parser_7b_text_postprocessor, lfm2_5_vl_text_postprocessor, @@ -57,6 +58,9 @@ DOTS_OCR_1_5_MODEL_ID, DOTS_OCR_1_5_OCR_PROMPT, DOTS_OCR_1_5_OCR_TEMPLATE, + FIRERED_OCR_MODEL_ID, + FIRERED_OCR_OCR_PROMPT, + FIRERED_OCR_OCR_TEMPLATE, GLM_OCR_MODEL_ID, GLM_OCR_OCR_PROMPT, GLM_OCR_OCR_TEMPLATE, @@ -134,6 +138,16 @@ def test_deepseek_ocr_2_template_builds_image_before_prompt() -> None: assert conversation[0]["content"][1]["text"] == DEEPSEEK_OCR_2_OCR_PROMPT +def test_firered_ocr_template_matches_documented_prompt_shape() -> None: + page = DocumentPage.from_image(Image.new("RGB", (20, 20), color="white")) + + conversation = FIRERED_OCR_OCR_TEMPLATE.build_conversation(page) + + assert conversation[0]["role"] == "user" + assert conversation[0]["content"][0]["type"] == "image" + assert conversation[0]["content"][1]["text"] == FIRERED_OCR_OCR_PROMPT + + def test_infinity_parser_template_matches_documented_prompt_shape() -> None: page = DocumentPage.from_image(Image.new("RGB", (20, 20), color="white")) @@ -341,6 +355,30 @@ def test_infinity_parser_text_postprocessor_strips_outer_markdown_fence() -> Non } +def test_firered_ocr_text_postprocessor_strips_prompt_echo_and_preserves_raw_markdown() -> None: + processed = firered_ocr_text_postprocessor( + f"{FIRERED_OCR_OCR_PROMPT}\n" + "assistant:\n" + "```markdown\n" + "# Heading\n\n" + "
YearValue
190042
\n\n" + "Paragraph with [note](https://example.test).\n" + "```\n" + "<|im_end|>" + ) + assert isinstance(processed, tuple) + text, metadata = processed + + assert text == "Heading\n\nYear | Value\n1900 | 42\n\nParagraph with note." + assert metadata == { + "raw_markdown": ( + "# Heading\n\n" + "
YearValue
190042
\n\n" + "Paragraph with [note](https://example.test)." + ), + } + + def test_deepseek_ocr_2_text_postprocessor_strips_prompt_echo_and_stop_token() -> None: assert ( deepseek_ocr_2_text_postprocessor( @@ -400,6 +438,32 @@ def test_build_ocr_backend_uses_deepseek_ocr_2_profile_defaults_for_hf() -> None assert backend.crop_mode is True +def test_build_ocr_backend_uses_firered_ocr_profile_defaults_for_hf() -> None: + backend = cast( + "HuggingFaceVisionOCRBackend", + build_ocr_backend( + OCRBackendSpec( + provider="hf", + model=FIRERED_OCR_MODEL_ID, + ) + ), + ) + + assert type(backend) is HuggingFaceVisionOCRBackend + assert backend.template == FIRERED_OCR_OCR_TEMPLATE + assert backend.model_name == "FireRed-OCR" + assert backend.generation_kwargs == { + "max_new_tokens": 4_096, + "do_sample": False, + } + assert backend.trust_remote_code is False + assert backend.processor_kwargs == {} + assert backend.model_kwargs == {} + preprocessed_image = backend.image_preprocessor(Image.new("RGBA", (32, 16), color=(255, 255, 255, 255))) + assert preprocessed_image.size == (32, 16) + assert preprocessed_image.mode == "RGB" + + def test_build_ocr_backend_uses_glm_ocr_profile_defaults_for_hf() -> None: backend = cast( "GlmOCROCRBackend", diff --git a/tests/test_providers.py b/tests/test_providers.py index fdb6e48..95d942a 100644 --- a/tests/test_providers.py +++ b/tests/test_providers.py @@ -52,6 +52,9 @@ DEEPSEEK_OCR_2_OCR_TEMPLATE, DEFAULT_OCR_TEMPLATE, DOTS_MOCR_OCR_TEMPLATE, + FIRERED_OCR_MODEL_ID, + FIRERED_OCR_OCR_PROMPT, + FIRERED_OCR_OCR_TEMPLATE, INFINITY_PARSER_7B_MODEL_ID, INFINITY_PARSER_7B_OCR_PROMPT, INFINITY_PARSER_7B_OCR_TEMPLATE, @@ -901,6 +904,31 @@ def test_build_ocr_backend_uses_deepseek_ocr_2_profile_defaults_for_openai_compa } +def test_build_ocr_backend_uses_firered_ocr_profile_defaults_for_openai_compatible() -> None: + backend = cast( + "OpenAICompatibleOCRBackend", + build_ocr_backend( + OCRBackendSpec( + provider="openai-compatible", + model=FIRERED_OCR_MODEL_ID, + transport=LiteLLMTransportConfig(api_base="http://127.0.0.1:8000/v1"), + ) + ), + ) + + assert type(backend) is OpenAICompatibleOCRBackend + assert backend.template == FIRERED_OCR_OCR_TEMPLATE + assert backend.model_name == "FireRed-OCR" + assert backend.transport.config.completion_kwargs == { + "max_tokens": 4_096, + "temperature": 0.0, + "top_p": 1.0, + } + prompt_image = backend.image_preprocessor(Image.new("RGBA", (32, 16), color=(255, 255, 255, 255))) + assert prompt_image.size == (32, 16) + assert prompt_image.mode == "RGB" + + @pytest.mark.asyncio async def test_openai_compatible_backend_uses_deepseek_ocr_2_prompt_and_postprocessing( monkeypatch: pytest.MonkeyPatch, @@ -962,6 +990,96 @@ async def _fake_complete_text( assert content[1] == {"type": "text", "text": DEEPSEEK_OCR_2_OCR_PROMPT} +@pytest.mark.asyncio +async def test_openai_compatible_backend_uses_firered_prompt_and_markdown_postprocessing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured: dict[str, object] = {} + + def _fake_prepare_messages_from_conversation( + self: LiteLLMTransport, + conversation: list[dict[str, object]], + ) -> list[dict[str, object]]: + captured["conversation"] = conversation + captured["completion_kwargs"] = dict(self.config.completion_kwargs) + return [{"role": "user", "content": [{"type": "text", "text": "prompt"}]}] + + async def _fake_complete_text( + _transport: LiteLLMTransport, + *, + model: str, + messages: list[dict[str, object]], + timeout_seconds: int = 600, + output_json: bool = False, + allow_empty: bool = False, + ) -> str: + captured["model"] = model + captured["messages"] = messages + captured["timeout_seconds"] = timeout_seconds + captured["output_json"] = output_json + captured["allow_empty"] = allow_empty + captured["completion_kwargs"] = dict(_transport.config.completion_kwargs) + return ( + f"{FIRERED_OCR_OCR_PROMPT}\n" + "assistant:\n" + "```markdown\n" + "# Ledger\n\n" + "" + "
YearValue
190042
\n\n" + "Paragraph with [note](https://example.test).\n" + "```\n" + "<|im_end|>" + ) + + monkeypatch.setattr( + LiteLLMTransport, + "prepare_messages_from_conversation", + _fake_prepare_messages_from_conversation, + ) + monkeypatch.setattr(LiteLLMTransport, "complete_text", _fake_complete_text) + + backend = cast( + "OpenAICompatibleOCRBackend", + build_ocr_backend( + OCRBackendSpec( + provider="openai-compatible", + model=FIRERED_OCR_MODEL_ID, + transport=LiteLLMTransportConfig(api_base="http://127.0.0.1:8000/v1"), + ) + ), + ) + result = await backend.ocr( + DocumentPage.from_image(Image.new("RGBA", (32, 16), color=(255, 255, 255, 255))) + ) + + assert result.text == "Ledger\n\nYear | Value\n1900 | 42\n\nParagraph with note." + assert result.metadata == { + "raw_markdown": ( + "# Ledger\n\n" + "
YearValue
190042
\n\n" + "Paragraph with [note](https://example.test)." + ), + } + assert captured["model"] == f"openai/{FIRERED_OCR_MODEL_ID}" + assert captured["messages"] == [{"role": "user", "content": [{"type": "text", "text": "prompt"}]}] + assert captured["timeout_seconds"] == 600 + assert captured["output_json"] is False + assert captured["allow_empty"] is True + assert captured["completion_kwargs"] == { + "max_tokens": 4_096, + "temperature": 0.0, + "top_p": 1.0, + } + conversation = cast("list[dict[str, object]]", captured["conversation"]) + assert conversation[0]["role"] == "user" + user_content = cast("list[dict[str, object]]", conversation[0]["content"]) + assert user_content[0]["type"] == "image" + assert user_content[1] == {"type": "text", "text": FIRERED_OCR_OCR_PROMPT} + prompt_image = cast("Image.Image", user_content[0]["image"]) + assert prompt_image.size == (32, 16) + assert prompt_image.mode == "RGB" + + @pytest.mark.asyncio async def test_openai_compatible_backend_uses_olmocr_prompt_and_plain_text_postprocessing( monkeypatch: pytest.MonkeyPatch, diff --git a/tests/test_tooling_benchmark.py b/tests/test_tooling_benchmark.py index 4f1fa9f..bb0f127 100644 --- a/tests/test_tooling_benchmark.py +++ b/tests/test_tooling_benchmark.py @@ -15,6 +15,7 @@ DEEPSEEK_OCR_2_OCR_TEMPLATE, DOTS_MOCR_OCR_TEMPLATE, DOTS_OCR_1_5_OCR_TEMPLATE, + FIRERED_OCR_OCR_TEMPLATE, GLM_OCR_OCR_TEMPLATE, INFINITY_PARSER_7B_OCR_TEMPLATE, MINERU2_5_2509_1_2B_OCR_TEMPLATE, @@ -454,6 +455,56 @@ def test_build_ocr_backend_uses_deepseek_ocr_2_preset_for_openai_compatible() -> } +def test_build_ocr_backend_uses_firered_ocr_preset_for_hf() -> None: + backend = cast( + "HuggingFaceVisionOCRBackend", + benchmark._build_ocr_backend( + benchmark.BenchmarkOptions( + backend="hf", + dataset_split="dev", + model="FireRedTeam/FireRed-OCR", + ) + ), + ) + + assert type(backend) is HuggingFaceVisionOCRBackend + assert backend.model_name == "FireRed-OCR" + assert backend.template == FIRERED_OCR_OCR_TEMPLATE + assert backend.trust_remote_code is False + assert backend.processor_kwargs == {} + assert backend.model_kwargs == { + "device_map": "auto", + "torch_dtype": "auto", + } + assert backend.generation_kwargs == { + "max_new_tokens": 4_096, + "do_sample": False, + } + + +def test_build_ocr_backend_uses_firered_ocr_preset_for_openai_compatible() -> None: + backend = cast( + "LiteLLMVisionOCRBackend", + benchmark._build_ocr_backend( + benchmark.BenchmarkOptions( + backend="openai-compatible", + dataset_split="dev", + model="FireRedTeam/FireRed-OCR", + base_url="http://127.0.0.1:8000/v1", + ) + ), + ) + + assert backend.provider_name == "openai-compatible" + assert backend.model_name == "FireRed-OCR" + assert backend.template == FIRERED_OCR_OCR_TEMPLATE + assert backend.transport.config.completion_kwargs == { + "max_tokens": 4_096, + "temperature": 0.0, + "top_p": 1.0, + } + + def test_build_ocr_backend_uses_glm_ocr_preset_for_hf() -> None: backend = cast( "HuggingFaceVisionOCRBackend", From 4004778b2f570b7401e5a6016e1fe2f5d2befbba Mon Sep 17 00:00:00 2001 From: Sina Date: Tue, 14 Apr 2026 07:38:51 +0000 Subject: [PATCH 49/58] docs(api): document canonical reference modules --- docs/api/churro_ocr.md | 20 +++++++++++++------- docs/api/index.md | 6 +++--- docs/api/providers.md | 18 +++++++++++++----- docs/api/templates.md | 33 ++++++++++++++++++++++++++++++--- 4 files changed, 59 insertions(+), 18 deletions(-) diff --git a/docs/api/churro_ocr.md b/docs/api/churro_ocr.md index 68b87f2..768cb56 100644 --- a/docs/api/churro_ocr.md +++ b/docs/api/churro_ocr.md @@ -1,9 +1,15 @@ # `churro_ocr` -```{eval-rst} -.. automodule:: churro_ocr - :members: - :exclude-members: HFChatTemplate, OCRPromptTemplate - :imported-members: - :show-inheritance: -``` +`churro_ocr` is a convenience namespace. It re-exports the most common classes and helpers from the package's owning modules so application code can import from one place. + +Use the canonical module pages below when you need exact signatures and field definitions: + +| Convenience import | Canonical reference | +| --- | --- | +| `DocumentOCRPipeline`, `DocumentOCRResult` | [`churro_ocr.document`](document.md) | +| `OCRClient`, `OCRResult`, `OCRBackend`, `BatchOCRBackend` | [`churro_ocr.ocr`](ocr.md) | +| `DocumentPage`, `DocumentPageDetector`, `PageCandidate`, `PageDetectionRequest`, `PageDetectionResult`, `PageDetector`, `PageDetectionBackend` | [`churro_ocr.page_detection`](page_detection.md) | +| `DEFAULT_OCR_TEMPLATE`, `CHURRO_3B_MODEL_ID`, `CHURRO_3B_XML_TEMPLATE` | [`Template APIs`](templates.md) | +| `ChurroError`, `ConfigurationError`, `ProviderError` | root package convenience import path | + +The root package is intentionally documented as a convenience-import page instead of a second full API target. That keeps each public symbol anchored to one canonical module in the generated reference. diff --git a/docs/api/index.md b/docs/api/index.md index 974cd4e..bcb775b 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -1,15 +1,15 @@ # API Reference -The pages in this section are generated from the package docstrings and public module exports. Use them when you need exact class signatures, field definitions, and helper function behavior. +The pages in this section are generated from the owning modules for each public API surface. Use them when you need exact class signatures, field definitions, and helper function behavior. Most readers should start with the page that matches the task they are working on: -- [`churro_ocr`](churro_ocr.md) for the top-level public exports. +- [`churro_ocr`](churro_ocr.md) for the top-level convenience imports. - [`churro_ocr.document`](document.md) for document OCR pipelines and result types. - [`churro_ocr.ocr`](ocr.md) for single-image OCR entry points. - [`churro_ocr.page_detection`](page_detection.md) for crop extraction and detection requests. - [`Provider APIs`](providers.md) for backend specs, provider builders, and detector backends. -- [`churro_ocr.templates`](templates.md) and [`churro_ocr.prompts`](prompts.md) for advanced customization. +- [`Template APIs`](templates.md) and [`churro_ocr.prompts`](prompts.md) for advanced customization. ```{toctree} :maxdepth: 1 diff --git a/docs/api/providers.md b/docs/api/providers.md index cb8c20b..2298d89 100644 --- a/docs/api/providers.md +++ b/docs/api/providers.md @@ -1,12 +1,20 @@ # Provider APIs -## `churro_ocr.providers` +`churro_ocr.providers` is a convenience namespace. It re-exports backend builders, provider option dataclasses, and page-detection helpers from the owning modules below. + +Use the canonical module that owns each symbol: + +| Convenience import | Canonical reference | +| --- | --- | +| `build_ocr_backend(...)` | `churro_ocr.providers.builder` | +| `OCRBackendSpec`, `OCRModelProfile`, `LiteLLMTransportConfig`, `HuggingFaceOptions`, `OpenAICompatibleOptions`, `AzureDocumentIntelligenceOptions`, `MistralOptions`, `resolve_ocr_profile(...)` | `churro_ocr.providers.specs` | +| `AzurePageDetector`, `LLMPageDetector`, `locate_text_block_bbox_with_llm(...)`, `locate_text_block_bbox_with_llm_sync(...)` | `churro_ocr.providers.page_detection` | + +## `churro_ocr.providers.builder` ```{eval-rst} -.. automodule:: churro_ocr.providers - :members: - :imported-members: - :show-inheritance: +.. automodule:: churro_ocr.providers.builder + :members: build_ocr_backend ``` ## `churro_ocr.providers.specs` diff --git a/docs/api/templates.md b/docs/api/templates.md index b987ad0..1a2f270 100644 --- a/docs/api/templates.md +++ b/docs/api/templates.md @@ -1,8 +1,35 @@ -# `churro_ocr.templates` +# Template APIs + +`churro_ocr.templates` is a convenience namespace that re-exports template protocols, chat-template helpers, and built-in model presets from the owning modules below. + +Use the canonical module that defines each symbol: + +| Convenience import | Canonical reference | +| --- | --- | +| `OCRConversation`, `OCRPromptTemplate`, `OCRPromptTemplateCallable`, `OCRPromptTemplateLike`, `build_ocr_conversation(...)` | `churro_ocr.templates.base` | +| `HFChatTemplate` | `churro_ocr.templates.hf` | +| `DEFAULT_OCR_TEMPLATE`, model ids, and built-in prompt presets | `churro_ocr.templates.presets` | + +## `churro_ocr.templates.base` + +```{eval-rst} +.. automodule:: churro_ocr.templates.base + :members: + :show-inheritance: +``` + +## `churro_ocr.templates.hf` + +```{eval-rst} +.. automodule:: churro_ocr.templates.hf + :members: + :show-inheritance: +``` + +## `churro_ocr.templates.presets` ```{eval-rst} -.. automodule:: churro_ocr.templates +.. automodule:: churro_ocr.templates.presets :members: - :imported-members: :show-inheritance: ``` From b483623077e45279b17a3a6ac4d893e860e05fd7 Mon Sep 17 00:00:00 2001 From: Sina Date: Tue, 14 Apr 2026 07:39:01 +0000 Subject: [PATCH 50/58] docs(leaderboard): add FireRed results and simplify icons --- benchmark_results.json | 71 ++++++++++++++++++----- docs/_static/css/custom.css | 5 -- docs/_static/img/firered-ocr-logo.png | Bin 0 -> 24120 bytes docs/_static/img/mineru-logo.png | Bin 0 -> 18483 bytes docs/_static/js/benchmark-leaderboard.js | 3 - 5 files changed, 55 insertions(+), 24 deletions(-) create mode 100644 docs/_static/img/firered-ocr-logo.png create mode 100644 docs/_static/img/mineru-logo.png diff --git a/benchmark_results.json b/benchmark_results.json index 9ef7851..bde2e55 100644 --- a/benchmark_results.json +++ b/benchmark_results.json @@ -328,7 +328,6 @@ "modelId": "gpt-5.4-2026-03-05", "modelUrl": "https://platform.openai.com/docs/models/gpt-5.4", "iconPath": "_static/img/openai-symbol.svg", - "iconInvertDark": true, "printed": 75.8, "handwritten": 52.8, "total": 63.4, @@ -383,7 +382,6 @@ "modelId": "gpt-5.4-mini-2026-03-17", "modelUrl": "https://platform.openai.com/docs/models/gpt-5.4-mini", "iconPath": "_static/img/openai-symbol.svg", - "iconInvertDark": true, "printed": 70.2, "handwritten": 39.8, "total": 53.8, @@ -438,7 +436,6 @@ "modelId": "gpt-5.4-nano-2026-03-17", "modelUrl": "https://platform.openai.com/docs/models/gpt-5.4-nano", "iconPath": "_static/img/openai-symbol.svg", - "iconInvertDark": true, "printed": 56.8, "handwritten": 27.9, "total": 41.2, @@ -709,7 +706,6 @@ "modelId": "gpt-4.1-mini-2025-04-14", "modelUrl": "https://platform.openai.com/docs/models/gpt-4.1-mini", "iconPath": "_static/img/openai-symbol.svg", - "iconInvertDark": true, "printed": 73.1042, "handwritten": 50.2487, "total": 60.7974, @@ -872,7 +868,6 @@ "modelId": "gpt-5.2-2025-12-11", "modelUrl": "https://platform.openai.com/docs/models/gpt-5.2", "iconPath": "_static/img/openai-symbol.svg", - "iconInvertDark": true, "printed": 71.4, "handwritten": 46.8, "total": 58.2, @@ -927,7 +922,6 @@ "modelId": "gpt-5-mini-2025-08-07", "modelUrl": "https://platform.openai.com/docs/models/gpt-5-mini", "iconPath": "_static/img/openai-symbol.svg", - "iconInvertDark": true, "printed": 69.8107, "handwritten": 47.5121, "total": 57.8038, @@ -1522,7 +1516,6 @@ "modelId": "o4-mini-2025-04-16", "modelUrl": "https://platform.openai.com/docs/models/o4-mini", "iconPath": "_static/img/openai-symbol.svg", - "iconInvertDark": true, "printed": 65.5032, "handwritten": 41.4801, "total": 52.5677, @@ -1631,7 +1624,6 @@ "modelId": "gpt-4.1-2025-04-14", "modelUrl": "https://platform.openai.com/docs/models/gpt-4.1", "iconPath": "_static/img/openai-symbol.svg", - "iconInvertDark": true, "printed": 64.4017, "handwritten": 41.4127, "total": 52.023, @@ -1740,7 +1732,6 @@ "modelId": "o1-2024-12-17", "modelUrl": "https://platform.openai.com/docs/models/o1", "iconPath": "_static/img/openai-symbol.svg", - "iconInvertDark": true, "printed": 62.4692, "handwritten": 35.0806, "total": 47.7215, @@ -1849,7 +1840,6 @@ "modelId": "o3-2025-04-16", "modelUrl": "https://platform.openai.com/docs/models/o3", "iconPath": "_static/img/openai-symbol.svg", - "iconInvertDark": true, "printed": 62.65, "handwritten": 30.9051, "total": 45.5566, @@ -2012,7 +2002,6 @@ "modelId": "gpt-5-2025-08-07", "modelUrl": "https://platform.openai.com/docs/models/gpt-5", "iconPath": "_static/img/openai-symbol.svg", - "iconInvertDark": true, "printed": 60.7442, "handwritten": 30.4073, "total": 44.409, @@ -2067,7 +2056,6 @@ "modelId": "gpt-4o-2024-11-20", "modelUrl": "https://platform.openai.com/docs/models/gpt-4o", "iconPath": "_static/img/openai-symbol.svg", - "iconInvertDark": true, "printed": 56.2807, "handwritten": 34.1932, "total": 44.3875, @@ -2284,7 +2272,6 @@ "modelId": "gpt-4o-mini-2024-07-18", "modelUrl": "https://platform.openai.com/docs/models/gpt-4o-mini", "iconPath": "_static/img/openai-symbol.svg", - "iconInvertDark": true, "printed": 52.8335, "handwritten": 29.7653, "total": 40.4121, @@ -2393,7 +2380,6 @@ "modelId": "gpt-4.1-nano-2025-04-14", "modelUrl": "https://platform.openai.com/docs/models/gpt-4.1-nano", "iconPath": "_static/img/openai-symbol.svg", - "iconInvertDark": true, "printed": 51.8564, "handwritten": 28.3488, "total": 39.1984, @@ -2447,7 +2433,7 @@ "modelName": "MinerU 2.5", "modelId": "opendatalab/MinerU2.5-2509-1.2B", "modelUrl": "https://huggingface.co/opendatalab/MinerU2.5-2509-1.2B", - "iconPath": null, + "iconPath": "_static/img/mineru-logo.png", "printed": 56.0, "handwritten": 20.4, "total": 36.8, @@ -2772,7 +2758,6 @@ "modelId": "gpt-5-nano-2025-08-07", "modelUrl": "https://platform.openai.com/docs/models/gpt-5-nano", "iconPath": "_static/img/openai-symbol.svg", - "iconInvertDark": true, "printed": 38.7162, "handwritten": 14.165, "total": 25.4963, @@ -3037,5 +3022,59 @@ "Vietnamese": 58.3 } } + }, + { + "modelName": "FireRed OCR", + "modelId": "FireRedTeam/FireRed-OCR", + "modelUrl": "https://huggingface.co/FireRedTeam/FireRed-OCR", + "iconPath": "_static/img/firered-ocr-logo.png", + "printed": 70.7, + "handwritten": 35.5, + "total": 51.7, + "main_language_and_type_metrics": { + "print": { + "Czech": 88.0, + "German": 70.5, + "Latin": 70.2, + "French": 86.2, + "English": 88.9, + "Hindi": 62.3, + "Sanskrit": 55.5, + "Swedish": 74.8, + "Chinese": 6.8, + "Dutch": 85.9, + "Finnish": 42.5, + "Polish": 80.2, + "Spanish": 87.9, + "Slovenian": 95.0, + "Bulgarian": 85.5, + "Japanese": 70.2, + "Bangla": 68.4, + "Romanian": 53.4 + }, + "handwriting": { + "Spanish": 40.1, + "Dutch": 50.4, + "French": 48.7, + "Latin": 43.6, + "Hebrew": 8.8, + "Japanese": 8.6, + "Italian": 64.0, + "German": 40.5, + "Greek": 8.4, + "Swedish": 43.8, + "Arabic": 33.6, + "Chinese": 64.8, + "English": 61.0, + "Catalan": 54.0, + "Portuguese": 24.2, + "Sanskrit": 5.8, + "Turkish": 11.9, + "Persian": 13.3, + "Vietnamese": 56.7, + "Norwegian": 54.4, + "Khmer": 8.8 + } + } } ] diff --git a/docs/_static/css/custom.css b/docs/_static/css/custom.css index 534af8a..0470d44 100644 --- a/docs/_static/css/custom.css +++ b/docs/_static/css/custom.css @@ -307,11 +307,6 @@ html[data-theme="dark"] .bd-content .benchmark-model-icon-frame, border-radius: 0; } -html[data-theme="dark"] .bd-content .benchmark-model-icon--invert-dark, -[data-bs-theme="dark"] .bd-content .benchmark-model-icon--invert-dark { - filter: invert(1); -} - @media (min-width: 1200px) { .bd-page-width { max-width: 96rem; diff --git a/docs/_static/img/firered-ocr-logo.png b/docs/_static/img/firered-ocr-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..5974815242a920992e58022477e6731e7fd63fba GIT binary patch literal 24120 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 RA3p#9002ovPDHLkV1m;baS8wc literal 0 HcmV?d00001 diff --git a/docs/_static/img/mineru-logo.png b/docs/_static/img/mineru-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..9593896efdb346fee69450b3c78dbc3127096703 GIT binary patch literal 18483 zcmY&=bx>Pxur}`Q?oMzi?(P%|4nd21ad#;0?rkAJgBK_o9Eue$R@~j~^1JihKfcT) z@0?8LoIUUEKD+zup2TTsC}N_Kp~1kwU@9xgeSBYs|9hjry?@e)2#>rksBTJzp6}~_ zFIbpJKjQZT92jLeXLt!)ZuR%CKAqoWKr4UQm-m-3N#@ea_Vp|mPlp_ zG$@0vGa&-6>1$OyMR&* zsZE_-4Mkkvr~bB?Cj1?*=W{>O;P|NZH<2KVANTYFL8f3QeA9B;2L>20BB6?Ke`{i94K`j}X_@llM*mO? z*>Jm`W02>L%769L{sag&huXSNlVSu*cl{UZqW^a3yn#k0I7B?1t@1!(LyFq+kY_AsB5*dP*ZVeG(laHIo zFp|C!q18!%RzO(hUo~m(C!!f+$akUo<8l5Q6b3w!yJ6!&3@kgXQwy$)AzoW&pDOpd zxL#k9T1pM2T4VM6tv+Bd%sw6|$uILU5`CkaZ;d*4U@P`b6D>eOWzk`hvvEVo9IU`% zvx!tvs7!U{n`R@zbjpZyS`#zeUSRy#fXZsOIqVlZ5_&>?AZ}J;o_B5`9d2t`=Ho&~ zGn;M^m?3mZBg(2k@vGj_0kHMR8ClV9yxuG6+VjPhMSh3@&Y#Jl!-t5$_mE5?AGF%( zcQv6V^!)QRQ%y-^pj=PMsU=oR{+rq%=VZahlYx)3KY^&ZkrfmVcedOYhWExw$o@3Q zGH+l~hlYO7XNvVqU*5!@;T) zfZALkWYVG9!^6I+{o|`-&}GB_*dW9poR5RHZlu zZB|QgF2X}hr?>RT2u=L*&0QF_qbtndva|$|YUG7Mc8$I3v&J^Vc+a)+H8{)oqSU`h#YbfaIaV#+BKHp7Z76jH%^v$gEh3WG0cQMY}Hn_DrhynX3bKRvDn6r{)@TW6v zE}T@48W;V5ML+znYIJd=w=2qQIZQ5*O_K@{dM+7eBnfJ6E|}W0#2;Ez=AOFqWZseU zSYFkD8k0+NiKMKx+dfs*9S!ugF%|+oktVLL@X~6K5z~(^V>k9&&PYea;c{QVsG%?? zt|*dpWOLhMTVA`6G$(L-WJhAG)6&TH0po*Mu4Xh&5=8l5`h1}-$enF*FZGzqZ@`5D z-}X&TtPnYk5d+2>jIvyXuTmpTbX*xE8zEhFLTQ7PV+2o0zzl0d)Hro!i@)*eF;kP* zF7*VHmWq~EedWweI(ftu+*BfYv?=0uyh3B6XqOS1N7H}=L*lhszfvq$ z9$*77UOpcopQfbru2@b9{#eI~=!ZV!&&<|E(ERWyXw?9#R-S9@t}b|PWMY(ZB}8qa zdDidi(zq1HyyW8CbLJ2_s$j5pFsdbIGs*p62$kPbG|Gc?xIES^msHfRjn{Gpf3sn@ z0`V|#1&BOhB560JXvd6>RE`b$CS;Ai=!(?A5gBGIZ{4FWWri|-_e3y!BACjf;o;-M z4nNDLPj{6_hkh1_Jd*oNtCl;<&3EG-e7 zOfMPsquY!;dB~{WqUW`Nm%Y(*F`NDyCgK{kl}jbOe|S4U->UZ3o(h+pk^JAsf$kKb zl2;M!E&$)1=y?sX$>9;!7F40{laNm^z;>XB0E>^84&0)*1o0=Xuk(96l}~N|?PTu^ ziXQz@J!yrn>VKNjmwJrCGh6&k|FV$n7lI-ttH!$)z}V^|8h}r? z&)DW;K<=Gq#vqh3az_c-=Yj4ypf;E}K=#)MZP_adVhX7vRL6sk&r$?QYsYi!U-ns6 z8OEhjdLEonA!PlTZ2H=jDZR$fFp|2Ko&??ybgNWiIF;wu z6<_>vM^Ky??$MAr`*)!B&k#KbsDXwqGkGi$p4oDZmiwno1DbRV1-8RfwR^yiY>Nw- z#6&_Zuy*RdVfeq`S_7i9j{KW?a|i26s7BhXnG4YXn>FqAaHaN>g$%>gsjCx$<#jOP zGdt9stJf0Sa@(aZ)|4B>oFhX}wN23BY(b0^A&bhdrCpQqns4Mca<=xUDGmBlXz|sQ zHbK^ zJa!A^dRz3uY*`+JeFNH0h)^(4=eB>CBT%bvDUp2}!Lm3u91=dyq%R5)aou(jfGc>a z6k?N=s=;|KtBIqj4LZ}2gOXqA7$0_G0`%0RpL$p4_knX zgy9VQ@PRKwvKYFPi9m>5n=FZccj$}ok9!<7%#co?)n{cd3zu$LF9vuYkFZ^*SC^xU zrd+SfygZv2CIqEwZq#gjlijz^&;Fw@=S5`W!?j z2#S$c{dbi3<+_^sZBPT8=8OUV>(HTH_Fj&gnb*VUCIGSu`>Xys1OXR~9sK#UEUH4X? z4$MSFMOX4}{Ea*!rZk9UlPSQsPjcXMr+--^Y%#w~hzH18;;4V*%eM}?hVTo5FB7Ok zoKuU6fQS?b%;sbbI(_^pqx>K)pG~}*xFp!`$^;_P@;vHVv|P?<j3ico={SZL$RM1~JnZis+0{VrD?b5x zxY;e8#iaBraJKgE^L;A7xyR0F*;~sA;`U(1SZAii3OFx|t!9$9kcwLSN;!BwKzJcyT1E<#|Y_o51u>Bp;V9r#FeJp$5uM+`!30K!20STNE+ zpLE%RapKnLX>3$T)XU^Xt;jR;x>@_|a5&BS%)QP4+qR|E%%s;w{7_g9l$X-KK3?9I zqMdh@hKmugNE-NXdo(~kbD{HLi%)3|W}>ysQ;vZxTgtV_3m`4HTI(3|6SQ;M*~M;Z zX#kBEL)YH32o_UYoKs6k9UrGB8`?eT8Hc*CqZ+|~Ag?<4eqRwA7>o(32fsm#!1LI0 z>-Y({3|>d6FzY7xppS7tuQ+HU!+ft;>?>SLL9}uJzXN1Biai}D+h;`D88vi zk>cZ`SA+Kjvyu^xk-c1ZAi0E)9>M481A8pubYyMe{oJCIe#V_kl;AK1>&6TrbcS6N z+qlWi@ihCe42o$v6E^bVK}I{}7!Rua4b>Bxa-8RH*0TFUdCDs=q+QvRou;!2tRj&g z={5DS*XYt#-aNj~po=cPQT}bCt9uv|`G^qaChlJ|xj8(XUcSG|Edzk!HKwV!d*shPcXyQlO#~B{LdY<)0u?JP*G{ z216+heNePi1JBRAKSq$W9cdReEO$p`vBXtC3Gi0b+xc)!7sR@tL{&Ol_8ymCe9*CA zLlAz!`-_ucjIs@y5!Wc~tATy=E$syiZz4gzwm)-i&@T}^lc1W|!CTm*MirG1@Qjgv z8m{I^MB4VRDErqiYfc}7Cu$l2Ee6Z}lc;xA zJpS&`2rcB-bTW#FgMzavoG`=9f$a0C{6ch-5*5~InbwWBtK==lKh}Hm@DpU!t|Y7v z6$|T*!@HyU5-iWD^V?dxwy)$ZcJk*ajrS@y^%J6S*|~4ceO4;?wxV+Wj|-p`&G>}) zL@5SR2Bhn0vgk{`!QE|*8vEfv?A`gQu#gm-VA|H<%=KhkP_5T=hufr;y0N~q{r+pO zx$J#avBs~tjCmFKsL1YW8cDxL!e)2}RiT~r2z|&Ja^&z?A(a!qvC;>qt;|)baJevp zUiNIMa=V2E_FQ5H;(oEtV>?6pED@`MS1hzhFfbUWxH8f}o|R}QwmY_@O}%0-f`S@J z(N!Xa7Q7Yg0xenDvCX39WtkJp_>VdC8MKT5vLI7uS(|}!PlLnP2lY+JpUZw`Rs2a3 zEIYKA#t{?F2}G+a8YYAIiL|}#?Y^Ex3M%0YEF}=9TqR`d4lUxt{P)8(hK?WeGp+OF z&82Dn!Kqn#SQxmeulY-;(^`LEnk&dmwxzkNt}38a0pwlYg2~N-yHCBAbX4powFjGd z;4-b6M`xl5qd9R1lJmT-I9P`eB%TU9F*3(V*pmy(-tTg5hRMWx)`$1|uuWSV~BPoWU~DNd7VJg&0+O1dt?2pneo@Yd0CG4(`zwBwbj zhJITFB!P^_&uOqa5E70#HHSBYFc*j4V$8=LRGTL(I!C6;(FdQxfnMc;Ug)I(_9Nn5 z))u8rVAivkyyRKeAv-|^+YbX(=scgqD1BIXN38UAs6a#!5y_@Y8wFZI{Ax1Mk#L|^ zQz<0pJ=B%q84+)klII~oN)R!|a0ucNk~2PKgv;}b0k005s+`2ECNJi6E)aHly%czl zTJkf)qhrvXkgDKO>U}FPAxKE*FX4+@#ikBncKQIDGf6{$a?w4J5K^xJ55=D=w{9wp zwQz&(e^^Dl7F9ONfMd?-TJfJhUh#i3WWDIC8TdxV3vjE+EX%{P&A*|~Vy&^zaz*YV zN28Vt{xu<6&|SAq8NdrL00-xj&=J*w(*_*qXKgPk)!{0?Ldkw;KO2VzKZHrv#r2>@ zcTeeCC4aK@Vb9DTru7Qz)L6@q&b>tZ_Pu7TL;uZXt$BZPDN>hplb_-@|C*L2GlR{A zl2aXQE+M=RIeUz21g+5i%5S=tRz4I{*vc*kE*D($MX-s$GYLJH3ka1Q){mpHR84}j z=tG^Yw)5-!&3rEKRhw8FfY;Q5=B37?RV>2CrKmVQo5apgrHG2D|^z=qdNgvHi zU_yi>yf|~JGt~gmHrJUxG9+>33~pVdf_VE;<5y>qy0sq?2~j`svd@Xj#B{;({jAN% zr%(o)7MYIW)jbOpxq}`WOF5S7_20ByPVg0`*erJa))MwTNc+?Xx~$dNrZ}5#_gOy+ zU?^2@%>}TEwoxhBjXy)n^l#FD@Zp#*9}baggSh!*zEwM0@2oy(fltk+C1$|Hs#H>F z78d)qAG`T`1J)r~(B&({wr+OJ6*$=slBvL#4+6L7pW1>u5@XhIkB~x*pQEzF{f=Le zT$!FZbmjiws(yjUI@w6MvBQg6boas?S-R0I*2YMOosSr(A&Qf22{t?9KQQImb>}MJ zQ4HCbJ3R~?lw~-@iuW^a@8gIZq@>ZGzBL7J_?(0~k5WmfFx&IUZ%%+(NndBpTi+sL z?h;obj=~lUiw|z)At=(&+R*bxL?fDh_pO4_D^{X!f2%Dw^35yn`DvpvJU^*}2C5FR zcT{xJHc-Hvp?y zq>G6AQmyp;@UWzKawMA}ds7P@6Ivaif00{c0@*MJ%3gN6UHf9bBl0YnI3+cFkpd;9 zco6;74DJ8Tc7xF|2Z}=d&pmu4|DNL}F?d;XG3am9)&oyxL6}#+er<$SwM*A9Q+BBX zX1Hqy)3t|iTc_B(#+rN)R?)_ycmf>1e;mYPI)aknjFtnj$jO<{HcrLcB3wRst7*pT z_4T%YD}ME)luhD|ls*2y8`~~Q-Z?Es)FDhL8$Qk#NJ(=`&QQ@!eSH;muam4IwWQgt z9w2d(h8`~@y&->*5b(@SYGB^$=8g8xGjf9cs)6T`XO}tpbanB=d zM>Io$$tW+*1f^Hg2}Qj~H25Kk%r)C?mw)7wdKui1SlsRMYT zAu8o#r-zFJ`s8D9ft`KtzHluPX%XR!CYv?HRnm6U_nBgL=IapWTE>nscFa+W^=m9* zzs%!8tXsjyXz+$!=mQNabY;LIMQQ7{B7%hIzcUI-5=E`{Ph1+b>!MC0)UD)J8nh#akEe^ktR26yk4iV3>I{X2)0kb!X$5{ ztji#E5S1w3aDQisI>U66wxXPHHdj zbfSBLETOi$@~%N4l0*Y!8|eM>Venm*H+Zx-?`T;r!|(GtM{V<>5I^nvk4V#{5p}W= zczqC`7K)qrcBYz=F&_z{oPbNdvvfNzKaGphr?n>x5Bis|#&K2=Y%ojzo&(07CbNlrNG0ts4c8Z_{LhB`N)4*OPwpk=5wa+%@B)Qi zB#Mpf83C+{3lk<&EYGDZOZ_Sr=k=pphDl;d#$>v$@ExGA2|`su85f4gqq24)2*m z=e@43Sx?i_8Qn?Ys&)G-6YHCDCytU}Ob*0No|zIHuoBL7EW_S5*%#lP(ZZDS0ltOZ zM8leV94OY1wPcE#R3cVbYM2p}Rw^0e0=#rC_&vI2Z-i5!X)t>MO(c2z+V1Btm_4WQazH*Dvi@i0}+9-9; z;s$a^9MJ?XlS75;_(HzDaMelpv&II(055&xYxr1T#STr&w--UDVF69seOC1ke@1ZK zX^kzTk%TEAXgk>W)l_eXK%jB+OH@p*X=tBtn1=oZHupzXag#>V)l4GBMp8kvu@U!= z94xjgY!$)>zC{syRn2(kdlinrf?&~q0EA9+KAOlQYA0_f8SXH>>;icT?a}r99*yKg z1!WJ(o|2xwcMELaEO7#HVgw@h3;wrMnDpqxqm35xr!?2AsV|*I;0xGyYL|pX2)tu= zAGm|BeLgV|VD#e2K~$Bsug`&QZtj9Q8TI9D)u~nQuyy({c1{BThc(_R6)bT;xm*Xo zUj)oD8ge&}Nty(a)-^O|Pd&m^VeLF3eJo|4>LMPZ!*-;K`ZdixeFTo^-XNh#Iww{{ zQo@2w+%lS{g?R^rv~n)V0mUsUtnY8p&$hg_;6d{e#^i^ryHFf=C+T9Q`GXZ-vBTt# zcfm3pu#JHl`!FMI=PLWwA4-2i%LpTKO#TQu;LYUZ=oocy{0p_1cZui)+)D3~wijj6 zf-ud$%166cn_oHyUR~uUC#E9~ewbQf`Wa*d_%&Q^Y#{LCEGXjFR~IiDAuu8VdM$69 zz^JAaQ|%|JvuWr@(O;NMt||Pu0bIziLM)vhQ1374%M`b-3`7CEkPAhV^eIKkP<0MLM(7WM>1A0a7 zYEz#N*C#27XPk^(fL#ik1&~dP&3CQgiS&x4>Hp-4`i@SRq(%{+$X4a84tQ|gH#cVq zwK_;vI13LWzh1?6T=eh%(E>tbe(uPjSDhEq|LHRd|MHp#Qoj7qt8YlCH{)<}Au2WjJWf5WA8j^|=eY+2d;}D~5C_-tnvHg6 z0d2?eq?dNG^nQChGjk7IJ)s_1G!=8ttcR+dZ!`H{;CBj#bJ18m8<8?^{m7H2F!i$@ z=Bh%#p5IwF|+?KLWLCtvzb>LwsYiVxD3> zBb2*-5_yor-ic=swbm&P*$*CoOkguzL5Hs74$Pvn(&1^uRjxCVtS=-<`d*eh$>{G>m8+!!<&ZNLOTK^c$~+TZHxBXPwZp>YAwD=nb6O{ z@G(7bPtE>bHSC}-SM*b7`D$c)6LeM!+`l}IZ2pf6xK6lmMB%?wH<}%_uGg=s&N=-= z$tO0>w-8ZyHX!Q9lWrimYfWUuV2_K4u4ijXSR)^rrr0+&RASy!UW7L`{Z#d)t;N(- ztOO{M*huP%nO{Riwii%lPwg^G*4O5`;OWSM_`D}g zO1Cgcs7`?9`Uv+(AhutGl6U6oFg|i*Mf} z#*fFk6ytxToe494_#IbY7^IXut_ExE0Ol6X%cnAw4AzW#>OHplIhTAWV5Dz0JDy5% z8wom)Z!k~_roTUZ=%0okd`c@3_`SfSUjw|Ki|-d_o!<}T-4jo&l4mAnedj+2c6ND! zYqR&(+2q@F;a;ix`nDe{6)i__fnGVrzOjYreL{@nwnM#%_LfErYH^_u7lW6H) zvTTBb;;0h76kY_8VwYy%C_)L%dTS4X#zJ4;NMdnJN3$B5ZWZC5kq-A*=9aBoLe)9( z=_iBJAq(FUc}R5KLY|E-Ojy+kJ$yb+Of$!DPBZr2@s!~F(Kfg$23m%!c4zW-LG|8y z?7y@!y4!kZH$DEcep!N_{j970_i`g9WYt=T<_4{r&`M2h>!Nr<5_j%-Lyp0KqG#e3vSS#&W*sr1H zi!k-Ac|o?I;$ql?Z-1^Bvrt}L`z?yosPNfKK)-#0uoDJ`$&2)_&L5fAg}SSZZ(GhU zc`Pq1kp_$cb7GrWMnXZt1L#*}l|G;^7#}d!zc#M0V*MShK6oSm8~BkoAG`aS@< zkpq~)6uzEG_U<<$>IfbV-Y={`KPEJv29dN?eVdFMfE{A}RV<~tkM%;XPvr=_73q9N z0{F|z5K%=V##p%Ujw^@14+XGeRVnmuE9C^@Fj0k~ZgTr@B`yt!xGdbQaC9~$PZ)*GFr0YC_F=-y zbS>a{*-B7JPs~r7)EBhAGS*o9!C%#+%Tfq$@aH_I2VSU%8#Qn1#l=VIPpGEVs-#R+ zA2jHtWA#;_facnQLYFd)z_hg7EGR@y;-9-JhM*2OmUhRLv_;|SV0}oW1R%@(S>6c0 z=cjqKerL72Vytem;EkNQ#0to?f9iQ+Rt9$$sNBFhV)GRN8H>jNKeF>@A?Pu28PnZ1-OKhQ-?~`#&l} zMV@3|7GW$ixE%41xM;F2+(sn3N~40#%nToxx(_)lFL-bVjC^&yB*xQ&xuv0Q^3eG3 zPacv&JY@nVw6b?Fwx`>vM8+FY7D-N~-K4LdQ*z9|ZAu%$S5dIk1e>@T49r|;=~m@# z4}G~4RJ%op{M}bmgZD?Q2E;lmdo6ldBUlhx8mylnFkig!gUIu?O52ePg)g7Yd6PxGA9{imsXNU}VdB;rw)t6(-@M$1%@kz;=$} zjsryzgcPwubhU3fM1W7DH#@GNWzONv_#roe54pW=0-{NZHB|~cEb|#CBq1r>{?tOp zM3g>T#n%UrnNY2+b)wq=%qdBPW5FLUaQwpNL}UyW)){&TqjhrgaZ$2~$k+~F{n69K zmPQ;&tii=pkhnQhNFGilzKL;6qCzW9x1xrn6L!MQ4gI%#%zwVYA0{T z#|tpdg#NeqcXK<`)6M@lp);~+)suRsg7N1-L0SG$@r>&QEpa!`v6Y8tT)Mq^+vM!) zp_TQq2}v-8Hg56R1^+C30OPTdpK=W3d+(Jgq`qBBHNsvB z{=0qek-a1ejs9z=k|>-~(iv8Z4FaJJqBBL;}ZqtoLCADsjdHc^`?T9!xac zlkofC*LG~)dcp99;uv-Co)TayERDJp4}CdKfIc25KR`xKD{_jjc)Ce>+w+66FNJdZ zQ!$k754#*gGA_!$(gwi5IL%}@e9IH++Fbe1 zzXJ6S9`H!+ZrMf=V!p_Nru7p#Dg)Yo-EPnf2Gf7AE({KJO@H4K08!?r0&{`OjDh!G zsDhU*f5h3~AMqrxs)2qEn3}(%z5sJ-hM9VD#CINUo;(bsAqs?duz(OppM}wiGComm z_ufV97)8J4_uQYW%7bZpt##_df+whL%$%#{mdYT3^~RvhPr+JC{1e&5``?RCS!oKn zSehA9D&y205k1TX@@%G6t~Sg$ymqqd2zEIG8)PK6Y~Z(ceT~-;70>doi98hd8Dta( zA4)YIUfMaKlJQXpM)O)f+ZtejdV_F2AfZa9eF1Ql9+0*7i)iWgmz z9j=~Cw1@jdhy<1DmEWp*wBc>L4PdVg zmIL2K`!`kX1Hv@>+KA1IVfdAF!`KynjEQKe+>$gt6Q!|?DoXz8dIL-W0%n$zO z)(6y?aV7ZZn(wJ_@dBI$18zG%40_ybx+60C2@^m!D_~~*s2~67!_cJ`WSW}`mkwHr zt1^ssbN?gZwg^w})BA%G&y95eO>3A4cEBNnGcMBrN%i(kf}WIVkh`e_d-nF-y@q~qd$iIKLD(|JW6OXlW;X9T zlr_La?R6>(q*!AkV%wrumv2L{dJv_vhJmsLw^ z&-|ZX0H39!celd-!w^;d^D+IKAHZwT_j-PrtWU1k!8%QokawWNKVBRBh@|e=wWb47 zPukJ~{G?{kr+qh(AJk3gjOa+_%M4S@Uss?FadUc*-Lo#a|1hD3U?jzDAU)Wvu{RCK z@BAUtA<=9l(P|mjOwR#Q^rIB$Ie*Ug*9p{&%YN)kvUOvClQ1p-<@RU;DYY$6yob|% zHvx-us#<@Z$kumgNoX13cuO1cgSq-_T10?p?VTOy=>|0;X(8YC7HT?0>(9If#VV<$ z91z{e&O635nNl5WDEj1(oEUug+n5$FZ*U-H$rmR->#17lS4g~XSl!piFa<&fnEuY& z*~y-0^b&r%RB?as7+EsR&M%y7q(RBjXYneX(sYW7w^634)=;dfrDgflp^3;l@YSK= z^Nw6=-vI3w0ZjG4qg1bXFpeXL09UonhI{^c1Tn~qht24JE#SMi9@tMbq!ctg!;WE4 z6)w@FQYTsU!A(iJ*q=}4v7|T^cEY-@H~E;GXTH_8LeY;L(yqsC<-js;!WF#h@L&11 z{3@7uv<0%s?)yvmKFOH2bLGLT#3uhqdcCK%OdXI9q+GEYpob- zd?dd9Ud-}W0T9yAk3e`d+f>uOhMN$7X_CO$UF`{B@|ENr!@!2ii&Qh!Nm9G2miq>r z`=V}*pUu21V92OHALO>|gF4{xmwoE}eBo+fy7zn9Df#bewY8$4T$PkL(DyN`eJcXW zd56^0Fa*VrrfSmJTL>)XRv?vdTlP%`@H*>vAXo6MCuf%bp>*)v?=9}$t9tq@iaKAs zIwSN)-+W+@Dxhxf-NDS{2L2Um0d9^B6j;@;Uq;-qmr7k11xd(|ZuixsrT&i#Ft$9m zEeRNA8QY7%j9pcrzFx&|KB6&%3BoxvUcdYEzDPFcO`h9T3>> z;pdApPBjqjLKvn-D|g)uN9%%fKbtZEa=!}a2dP?jbmYj?iStSrVrp`<-f|~icG|Ei z7q?f<0QkgE(|?VneqXa0=I8RN39NFQuKuF_fWXz8WX6ftW@5YhuF8t)O-Va#rFc|zy8OE$n5(>nBvv+yB_FY=dAtD6{S$70g-y7NAwWkjz|9m7)XYnsDtq9L?Ccaou;mG%$ ziO7E!l*#GYWs#FEi`66-O4gJrl}QFEk|HMgf@x2aj_y2=_s{eT=!^MH4^tK+L`i8s z8aO(~#80;Ib^WOGw%_XvS)6HpGyykcXuXf8b8zv*rv+i~ePT$1XAyd+1KOP1)dE*5 z+@F4&%X7>5w`7Iun6)<ge^%M~2_c?Ae*gQn8?B9l%D1>FTnlhpV z1_`{O05^Jokvi~rc@&R0S1+=X-N>+|;0GwzPoJY1D|-igg!bFYus>}HJNW6PD=gK{ ztW8iJ#(6Tir0ho2AO1n}Si!L9VH39CRsMR3UBi>TLZ^^Yo0l0~tFp>3?_|!3Rb10*=4JUUK-FAG zEVrj6d!tPO5n0MlbferoH6Zt-Si-K+Q0J1sZ_h)04qq>RB9bgQ+EkswH_ilkD@pa= z9(DaA#eVgsfB(!OiZf;zpSMb`DWnTkFuy0o*OVMbM=bLjG~zO@g6zqDnZ%B>r;}hH zzJAvAqPqRKJ}EcMUpCgU*D`_!wt5ud&n;^5pA>)Zu1e+`6y)nDjg0B1dQL)@8l#wg z@2ob)H`-B89{e>nBu3*h4_S;13oXZ9n#BXZj{s{Dz-oS~l@2`XJU_tRXx0&RzBzff zqN6VKJIC{db2-36mUeQSO>3JpqG4J zM$yC(f-6$;;M_s}eF^rS3YioB09I~SA`50gOZ=ilY_BUcEp zc~_!;ZdPOtygU9cDGY8D=JjB`bsAneU??9f+n@T)6&PE{qR8m9Gy!Bwc7cO!4)VQ9 zO*oG4To2}JNOhne~yTdCjbEZQ_-o}CBu6bSO(fGCgzgJ7{D`5 zK97T@B`+lc$w?#GY>5AalMK*dYgM$mCxSBHE1sgt*W?>meqmADzz zwL`XT&(5<+GREXxsHJefWG_4k2olRuaFd5#ex!5&3>6im_?g}{~% z6kl!U%8I4ee9j7!dlD775AX>#A(E@S80rq=a1|%nyL78Im`INmnA=(-xa&||2u#2F zHFs8x53vVTDSk}onSPr|QRn84<%_RWw7*ky6=nyGeCl|tk~W5@?D|XmV94UZZHZ%S z9g3B2r;RAFV@&(ligNxi>9(z=<0V3zJ6dp$6>k2M=XW!Z6VRdoUh>Tq8{KQqrqw9Y zOE<`fl;0*?0KU{Yfy8g-gCN(PNw}Cf4}ZJNqrT5M=C*(1zwKdNReTFC={L{@A4p_s z(#@;2UZRenC3kw(CN)2r8WN}m<#uz%^z>(+EF8wvSvi4TlqbT0Wf+uTlI@>=K8D_L ziK+dDW8g8Ck?4eRq<$rtzWDV$Y4$_@ z$eX`Q5h)!^mJba1coYs4F$8-)ETXRICfppzpNpQaAl&sGblJR%GoI}VUit&9O^u1r zZ_!XmKNzR=6Wu%O+0^lVy0ln+4QvE`0j}8N*Tm#ALUhyVz2QuLVPE9p6r%CikuosC zr{FJb;CTd9e?H(ZMqVWtt3=A(^WPkP+6I#J9{Rnf@Hg@j6@g~>?o@)s2>(x-1?rxm z+z(pdZ4-I$win&}v}Vu9mMDh7D*o8W5iH@6&3Z^s$d>|tO2?bJ`9gxn5qP5q=RiN~ z2xq-OL7EPUFjC{w#G4@g-}U}|NE3_R>hufqbk4-;Vlw+)@RV&L^_;Rqb!D)dFaR%`kxmy}e(;P;Y^iNA z;MLl(i0vZ__^QWvwK|&B_T1$CS+B7xE|VlFOi~uSh!Lw15h;cuiK$`PSw2~gD>0W$ z=vbu6+F)U`30g=SU_YHO_joU}a_12tDtQ}!qE=YHLKiNKzCp`OEk{4~i<pDZ^B2Cv9ulbBf@k}Bucv-n zTX3o)Jl37p_2uBSGIrJ>Tpf@w(Kq2_oMUpsR7qN`7WIBgn(0V`&Ed$&OZN*466zwukv;d-S|)ahIpkwCmti z4z&3crv`s!okMbO4_0K27nsFHj@Y3b$m=+i_}5tm6VNX|cJ|xUtN$HTMY=66jTIqH zX|H4EZTNO*9C4Xx$UL8(nI)wxOnMXNunWa%s_rEUHoVu3AiK4IyvELrntI0@BpZfD z<~VN_Q@OMTUj|O}l*q=u(~x0ho;Q4zR0fb$ig*B! zWt*Db5ca5o?Wz~3hY4ECMrzRazyPV;j=!Eh965U)V}9PRV%g+;*)K&G58lseNp+OwR?WA~#O&m|)WJ!_3Ke7j zKGD{IbsNoC{D3dhnsfELt6!cvZ|$?{*g=#Mm9mie(}hWo?l@4Xh`rt(Xu|LYr;*1x zOBFV^%P4qt;7s}zlf#t;Jso&^kia--i>6FUkyb!UH5xAK5s3A+xIviS&40f(F3>$; z;=Z)B^;kyOngt#W@mpGrLU*w)Xpgj;ey<2jM8ccB+KAfaRcYJLrXb&6vzTBecn8Ex z>cX)@!Yi7xBKdcfI4LI5)A$UxLgNyj_Vu)mgb z)q$s@F^#4u39G7m^=u|>{~9$lA{cOM#wkv8aM6V`IGF4%S@b`)r(|GV*g^YqU@K%V zzvB1<&r$r^dak11iH{+`%%x^&`}fV(vMSd%mZuGw7jjZ;yx;tkqkl^Ed?oN_u9Xa+ z{!ajCpl&i`bgAR75rpXHYH`QEwU(gn>;={%mXV#kN)Q;f5DbHL0*ZM0_1|inzDd4UzkT#r6|1)g z_%@dgkpS-{ljL~e(;WnC{_oax0jy;jldR|l*+Lxz!W|jo=6c8f{3j+}c5hZ}D5BYw zlcz|*f`{L1J`z(&a6tZje1KvcWMT+qp}H1Db{9u)757cwZImZI{giazdNvAr&93&$ zc1XR-W=FY?dM+-!P;Ex6rkdPAySlzS;(PPXK9^8%?OkufWIgK$0@0yViCuNWjvJP4?R!UqeSlkt3mMbMl1F zyD{g>XvjHXB}1+Gl$?;X{_)bXCRaRM8%K$;G-0(RE1K!!2z&3V?YzeI^lkERs%Bc0h~?%wl4BW zzEh)X1CRbAWEnhuY6D^W22}ZAx5X+IS*}yAf|h)7*7QFCBM030^nMN`f5gsqEjHn_ zxSyJW3cOI;t%TUz+hNA=Ckq;7(wR?1RTn^?~!#zhl^q_n2PS=!_2 zFn#r9b7B!_SLnRDWF8p<7Gc7^U?pMks?U$URZZu4F;o$h%~5P(J(nb95~x^lr|LfV z7X%(?>O?FGTGdXhy-_#ov}9i~t*Gq&@TZ9-w>Dytgz>4L+2_( zW_wjmcPr~ClY)Cb;=HPC{>ld{TuYX&C|I`g;^wg8hxN^LLP6gDflN*F*deywda{Z2 zoPi@jGD1va4rPlczJblJ+?ncH>a?cHl326dZX;+F6>IK->tQ<|6y7J}Ia0GAlD!LU zV%dP`JVVS`Ch1J`@eA;7E=>3MdKTH%wZgJkH<1M^?<31xQ};#XKWMDg$dd`=fAm(R zYz|MR-N7c-a|((TdyY*!-9%R7#8pjm|7yzW(ixuRT@h<{#La>SWLa|^r{{Qy7i?lZ zed*0c^`U}#DSV#T|pI(HukvA-fwamMCzUyri z%W35u=M^`RcR$u*|7R1+Mw>+|%F-2?BPVqqeGLik!*rKl6tU#u?`fWQ5#?6ks=|ft zr3+mOoLAoU5BwnS3k?q<1Dqf?;59NtDMha?l)>1kDSMUbZ9_ zEeD@6@3%EU^;fPzLo-?DV-w2;!Fc1j74$6TwBfUh5O%0$#>%2uE=ngz#airHfZdn;}_w`XE4Pz zZ?1dUd@>Sj6>FjUU1YXb(G-{4Zb8NWlM7lM=*h-bHnD7|ps705D=|Yr5>mMjeuCA( zTBp^e&R!K{k5IP)6LUTDXSiHj>3S+5xu~$Pis!)~@Qm1ILvmK)2qGYK5r<8z=Z=^x zK`j=Bs}n!Mj`yl2EV^Y3SEXvPCN2BbHKZyt2deAHbt6yykuRW4tmh7TJNZyLfle+Y zWV1+Yt^tY6kq%_?iK%cHUzx$Y`4t@d6y|!~a&pg~>3Y-I{UO;FW>w*w<#*@0eB~B& z<wMkh**VzXDz1?hL^--~+^C0#dDSnUn98$8lcfSXh;PM$Rf6Vi`H{0#@ zT-VasD~p|8zO%xo^59Y3Z-vHZ2C?$ua+_E-j8h4%Wy;Yq@i|;#o)DLVeWG;oE7%lS zGh@}Q1>PkKyb5N!++46K+r{^ryk>mO+cS#ndF|;W^7g#h*`b&xxpJ}5r ztQE_&fFdzlO34wEvjxnk$RgEjvZUu9=iul+^;1{*fcnD4o>|DOE;WdytZh^A;|a!3TuF6 zfH8oc=g3Men^?~e)<&#DX^@f%htB9zPU{YTUjEAIs<*StPMoXy`lrI83gOZp73aRW zscUKhPM{l+um+95>B$561eQm^$RbmD(_8h{BkDz3PpR3&ddgxYora^C@XnX;;dhXG z4ZbOWYjsd)0G&iYO>{I2aq@o2m{F0rbAjhcy8agxi#(o*7Jps3r~4q|@`;?15AsS+ zWS5)e{TvJAmYK)W>#e1?f4-IcBd^jdADt=qB)$0ayn@s(@1Og*=-WTaFBer_FK?)- ztZr;*W(=HO$FK~~b0TfVtI+8tC}z}o(gMAnYF!emMu6f*xK#&t8X>>lJhvMx&}acm zueX-o{`ppt-)NqSW~kCYEej2TvS|_01d1Y}fKf<7MA?LATP(RLW%kORVB|1K&e#^{ z97~Vre(XQ&BE9|dt;PIqM#h^bbgL##RTNCmbE{!b60tfyP`w8n=~nBKSa|RTf$W+@ zMj{;B4v}vKGs+GaEMWG<-JrLBzI7;zKRZChqGw4?gE)~lU>Z&ziL31ECp-sYDaYI~ zG<1x$oqt>{HS-^A>*?*E-$T&lpu9*C37C$neX@yV6U!#nV_TQHLJk-c#>dUIe*?Tl zI16<0PpqQ1e}2zEHwL0IDPqYe%_f#jESp$eX^#`j)@-d?%_f$u^-8meWoy0CY+~73 yuQZ!jw$>}nCYG)BO0$V&YrWEJV%b`+wEqt_J`0|0000 Date: Tue, 14 Apr 2026 18:54:44 +0000 Subject: [PATCH 51/58] feat(providers): add Nanonets OCR2 support --- docs/guides/advanced-customization.md | 1 + docs/guides/providers.md | 7 +- src/churro_ocr/providers/_ocr_processing.py | 25 ++++ src/churro_ocr/providers/specs.py | 30 +++++ src/churro_ocr/templates/__init__.py | 8 ++ src/churro_ocr/templates/presets.py | 20 ++++ tests/test_cli.py | 22 ++++ tests/test_hf_ocr.py | 72 +++++++++++ tests/test_providers.py | 125 ++++++++++++++++++++ tests/test_tooling_benchmark.py | 50 ++++++++ 10 files changed, 357 insertions(+), 3 deletions(-) diff --git a/docs/guides/advanced-customization.md b/docs/guides/advanced-customization.md index 82bae68..3d35e01 100644 --- a/docs/guides/advanced-customization.md +++ b/docs/guides/advanced-customization.md @@ -46,6 +46,7 @@ Use the [templates API](../api/templates.md) for exact signatures. | `CHANDRA_OCR_2_OCR_TEMPLATE` | Built-in template for `datalab-to/chandra-ocr-2`. | | `DEEPSEEK_OCR_2_OCR_TEMPLATE` | Built-in template for `deepseek-ai/DeepSeek-OCR-2`. | | `FIRERED_OCR_OCR_TEMPLATE` | Built-in markdown-conversion template for `FireRedTeam/FireRed-OCR`. | +| `NANONETS_OCR2_3B_OCR_TEMPLATE` | Built-in structured-markdown template for `nanonets/Nanonets-OCR2-3B`. | | `DOTS_OCR_1_5_OCR_TEMPLATE` | Built-in template for `kristaller486/dots.ocr-1.5`. | | `DOTS_MOCR_OCR_TEMPLATE` | Built-in template for `rednote-hilab/dots.mocr`. | | `INFINITY_PARSER_7B_OCR_TEMPLATE` | Built-in markdown-conversion template for `infly/Infinity-Parser-7B`. | diff --git a/docs/guides/providers.md b/docs/guides/providers.md index f3e7c07..0c7a501 100644 --- a/docs/guides/providers.md +++ b/docs/guides/providers.md @@ -59,17 +59,18 @@ backend = build_ocr_backend( ) ``` -Built-in model-specific profiles are resolved automatically for known models such as `stanford-oval/churro-3B`, `datalab-to/chandra-ocr-2`, `deepseek-ai/DeepSeek-OCR-2`, `FireRedTeam/FireRed-OCR`, `zai-org/GLM-OCR`, `kristaller486/dots.ocr-1.5`, `rednote-hilab/dots.mocr`, `infly/Infinity-Parser-7B`, `opendatalab/MinerU2.5-2509-1.2B`, `PaddlePaddle/PaddleOCR-VL-1.5`, `LiquidAI/LFM2.5-VL-1.6B`, and the supported `olmOCR` checkpoints. +Built-in model-specific profiles are resolved automatically for known models such as `stanford-oval/churro-3B`, `datalab-to/chandra-ocr-2`, `deepseek-ai/DeepSeek-OCR-2`, `FireRedTeam/FireRed-OCR`, `nanonets/Nanonets-OCR2-3B`, `zai-org/GLM-OCR`, `kristaller486/dots.ocr-1.5`, `rednote-hilab/dots.mocr`, `infly/Infinity-Parser-7B`, `opendatalab/MinerU2.5-2509-1.2B`, `PaddlePaddle/PaddleOCR-VL-1.5`, `LiquidAI/LFM2.5-VL-1.6B`, and the supported `olmOCR` checkpoints. For `FireRedTeam/FireRed-OCR`, the built-in `hf` and `openai-compatible` backends use the model's published Markdown-conversion prompt. The OCR result preserves the raw markdown in metadata, and repo-local benchmark evaluation normalizes that markdown or embedded HTML back to plain text before metrics are computed. -For `zai-org/GLM-OCR`, the built-in `hf` and `openai-compatible` backends both use the model's documented `Text Recognition:` prompt so local Transformers inference and served vLLM inference stay aligned. +For `nanonets/Nanonets-OCR2-3B`, the built-in `hf` and `openai-compatible` backends use the model's published structured-markdown OCR prompt. The OCR result preserves the raw markdown in metadata, and tagged markdown or embedded HTML is normalized back to plain text for evaluation-friendly output. + +For `zai-org/GLM-OCR`, the built-in `hf` and `openai-compatible` backends both use the model's documented `Text Recognition:` prompt For `infly/Infinity-Parser-7B`, the built-in `hf` and `openai-compatible` backends use the documented markdown-conversion prompt and treat the response as markdown or embedded HTML. The OCR result preserves the raw markdown in metadata, and repo-local benchmark evaluation normalizes that markdown or HTML back to plain text before metrics are computed. For `opendatalab/MinerU2.5-2509-1.2B`, the built-in `hf` and `openai-compatible` backends both run the model's two-step layout-plus-block pipeline and return markdown with embedded HTML tables when needed. Repo-local benchmark evaluation normalizes that markdown or HTML back to plain text before metrics are computed. -Use `provider="openai-compatible"` when you want to point Churro at a served OpenAI-style endpoint such as vLLM. The generic `litellm` provider is intentionally not supported for this model because MinerU2.5 needs multiple prompt stages instead of a single OCR call. ### LiteLLM diff --git a/src/churro_ocr/providers/_ocr_processing.py b/src/churro_ocr/providers/_ocr_processing.py index caee61e..4e66df3 100644 --- a/src/churro_ocr/providers/_ocr_processing.py +++ b/src/churro_ocr/providers/_ocr_processing.py @@ -23,6 +23,8 @@ INFINITY_PARSER_7B_OCR_PROMPT, INFINITY_PARSER_7B_SYSTEM_PROMPT, LFM2_5_VL_1_6B_OCR_TEMPLATE, + NANONETS_OCR2_3B_OCR_PROMPT, + NANONETS_OCR2_3B_SYSTEM_PROMPT, PADDLEOCR_VL_1_5_OCR_PROMPT, ) from churro_ocr.types import MetadataDict @@ -195,6 +197,29 @@ def firered_ocr_text_postprocessor(text: str) -> TextPostprocessorResult: } +def nanonets_ocr2_3b_text_postprocessor(text: str) -> TextPostprocessorResult: + """Normalize Nanonets-OCR2 markdown output to plain text and preserve raw markdown.""" + cleaned = strip_leading_chat_scaffold( + text, + prompts=[ + NANONETS_OCR2_3B_SYSTEM_PROMPT, + NANONETS_OCR2_3B_OCR_PROMPT, + ], + ) + for _ in range(8): + previous = cleaned + for token in ("<|im_end|>", "<|endoftext|>", "<|assistant|>", "<|user|>", "<|system|>"): + if cleaned.endswith(token): + cleaned = cleaned[: -len(token)].rstrip() + break + if cleaned == previous: + break + raw_markdown = strip_outer_fenced_code_block(cleaned) + return strip_rich_ocr_markup_to_plain_text(raw_markdown), { + "raw_markdown": raw_markdown, + } + + def deepseek_ocr_2_text_postprocessor(text: str) -> str: """Strip DeepSeek OCR 2 prompt echoes, chat scaffold, and trailing stop tokens.""" cleaned = text.strip() diff --git a/src/churro_ocr/providers/specs.py b/src/churro_ocr/providers/specs.py index 6b0f8a2..1daa81f 100644 --- a/src/churro_ocr/providers/specs.py +++ b/src/churro_ocr/providers/specs.py @@ -22,6 +22,7 @@ identity_text_postprocessor, infinity_parser_7b_text_postprocessor, lfm2_5_vl_text_postprocessor, + nanonets_ocr2_3b_text_postprocessor, olmocr_image_preprocessor, olmocr_text_postprocessor, paddleocr_vl_text_postprocessor, @@ -48,6 +49,8 @@ LFM2_5_VL_1_6B_OCR_TEMPLATE, MINERU2_5_2509_1_2B_MODEL_ID, MINERU2_5_2509_1_2B_OCR_TEMPLATE, + NANONETS_OCR2_3B_MODEL_ID, + NANONETS_OCR2_3B_OCR_TEMPLATE, OLMOCR_2_7B_1025_FP8_MODEL_ID, OLMOCR_2_7B_1025_MODEL_ID, OLMOCR_2_7B_1025_OCR_TEMPLATE, @@ -74,6 +77,7 @@ FIRERED_OCR_MAX_TOKENS = 4_096 GLM_OCR_MAX_TOKENS = 8_192 INFINITY_PARSER_7B_MAX_TOKENS = 8_192 +NANONETS_OCR2_3B_MAX_TOKENS = 15_000 OLMOCR_MAX_TOKENS = 8_000 PADDLEOCR_VL_MAX_TOKENS = 4_096 INFINITY_PARSER_7B_MIN_PIXELS = 256 * 28 * 28 @@ -316,6 +320,28 @@ def firered_ocr_profile() -> OCRModelProfile: ) +def nanonets_ocr2_3b_profile() -> OCRModelProfile: + """Return the built-in ``nanonets/Nanonets-OCR2-3B`` OCR profile.""" + return OCRModelProfile( + profile_name=NANONETS_OCR2_3B_MODEL_ID, + template=NANONETS_OCR2_3B_OCR_TEMPLATE, + text_postprocessor=nanonets_ocr2_3b_text_postprocessor, + display_name="Nanonets-OCR2-3B", + transport=LiteLLMTransportConfig( + completion_kwargs={ + "max_tokens": NANONETS_OCR2_3B_MAX_TOKENS, + "temperature": 0.0, + } + ), + huggingface=HuggingFaceOptions( + generation_kwargs={ + "max_new_tokens": NANONETS_OCR2_3B_MAX_TOKENS, + "do_sample": False, + }, + ), + ) + + def glm_ocr_profile() -> OCRModelProfile: """Return the built-in ``zai-org/GLM-OCR`` OCR profile.""" return OCRModelProfile( @@ -519,6 +545,7 @@ def _profile_registry() -> dict[str, OCRModelProfile]: infinity_parser_profile = infinity_parser_7b_profile() lfm2_5_vl_profile = lfm2_5_vl_1_6b_profile() mineru2_5_profile = mineru2_5_2509_1_2b_profile() + nanonets_ocr2_profile = nanonets_ocr2_3b_profile() olmocr_profile = olmocr_2_7b_1025_profile() olmocr_fp8_profile = olmocr_2_7b_1025_fp8_profile() paddleocr_vl_profile = paddleocr_vl_1_5_profile() @@ -534,6 +561,7 @@ def _profile_registry() -> dict[str, OCRModelProfile]: infinity_parser_profile.profile_name: infinity_parser_profile, lfm2_5_vl_profile.profile_name: lfm2_5_vl_profile, mineru2_5_profile.profile_name: mineru2_5_profile, + nanonets_ocr2_profile.profile_name: nanonets_ocr2_profile, olmocr_profile.profile_name: olmocr_profile, olmocr_fp8_profile.profile_name: olmocr_fp8_profile, paddleocr_vl_profile.profile_name: paddleocr_vl_profile, @@ -602,6 +630,8 @@ def resolve_ocr_profile( "lfm2_5_vl_1_6b_profile", "lfm2_5_vl_text_postprocessor", "mineru2_5_2509_1_2b_profile", + "nanonets_ocr2_3b_profile", + "nanonets_ocr2_3b_text_postprocessor", "olmocr_image_preprocessor", "olmocr_text_postprocessor", "paddleocr_vl_1_5_profile", diff --git a/src/churro_ocr/templates/__init__.py b/src/churro_ocr/templates/__init__.py index d60dbc6..843f4e2 100644 --- a/src/churro_ocr/templates/__init__.py +++ b/src/churro_ocr/templates/__init__.py @@ -47,6 +47,10 @@ MINERU2_5_2509_1_2B_SYSTEM_PROMPT, MINERU2_5_2509_1_2B_TABLE_PROMPT, MINERU2_5_2509_1_2B_TABLE_TEMPLATE, + NANONETS_OCR2_3B_MODEL_ID, + NANONETS_OCR2_3B_OCR_PROMPT, + NANONETS_OCR2_3B_OCR_TEMPLATE, + NANONETS_OCR2_3B_SYSTEM_PROMPT, OLMOCR_2_7B_1025_FP8_MODEL_ID, OLMOCR_2_7B_1025_MODEL_ID, OLMOCR_2_7B_1025_OCR_TEMPLATE, @@ -94,6 +98,10 @@ "MINERU2_5_2509_1_2B_SYSTEM_PROMPT", "MINERU2_5_2509_1_2B_TABLE_PROMPT", "MINERU2_5_2509_1_2B_TABLE_TEMPLATE", + "NANONETS_OCR2_3B_MODEL_ID", + "NANONETS_OCR2_3B_OCR_PROMPT", + "NANONETS_OCR2_3B_OCR_TEMPLATE", + "NANONETS_OCR2_3B_SYSTEM_PROMPT", "OLMOCR_2_7B_1025_FP8_MODEL_ID", "OLMOCR_2_7B_1025_MODEL_ID", "OLMOCR_2_7B_1025_OCR_TEMPLATE", diff --git a/src/churro_ocr/templates/presets.py b/src/churro_ocr/templates/presets.py index ea7eb33..08885fe 100644 --- a/src/churro_ocr/templates/presets.py +++ b/src/churro_ocr/templates/presets.py @@ -15,6 +15,7 @@ DEEPSEEK_OCR_2_MODEL_ID = "deepseek-ai/DeepSeek-OCR-2" GLM_OCR_MODEL_ID = "zai-org/GLM-OCR" FIRERED_OCR_MODEL_ID = "FireRedTeam/FireRed-OCR" +NANONETS_OCR2_3B_MODEL_ID = "nanonets/Nanonets-OCR2-3B" DOTS_OCR_1_5_MODEL_ID = "kristaller486/dots.ocr-1.5" DOTS_MOCR_MODEL_ID = "rednote-hilab/dots.mocr" INFINITY_PARSER_7B_MODEL_ID = "infly/Infinity-Parser-7B" @@ -75,6 +76,21 @@ system_message=None, user_prompt=FIRERED_OCR_OCR_PROMPT, ) +NANONETS_OCR2_3B_SYSTEM_PROMPT = "You are a helpful assistant." +NANONETS_OCR2_3B_OCR_PROMPT = ( + "Extract the text from the above document as if you were reading it naturally. " + "Return the tables in html format. Return the equations in LaTeX representation. " + "If there is an image in the document and image caption is not present, add a small " + "description of the image inside the tag; otherwise, add the image caption " + "inside . Watermarks should be wrapped in brackets. Ex: " + "OFFICIAL COPY. Page numbers should be wrapped in brackets.\n" + "Ex: 14 or 9/22. Prefer using " + "☐ and ☑ for check boxes." +) +NANONETS_OCR2_3B_OCR_TEMPLATE = HFChatTemplate( + system_message=NANONETS_OCR2_3B_SYSTEM_PROMPT, + user_prompt=NANONETS_OCR2_3B_OCR_PROMPT, +) DOTS_OCR_1_5_OCR_PROMPT = "Extract the text content from this image." DOTS_OCR_1_5_OCR_TEMPLATE = HFChatTemplate( system_message=None, @@ -181,6 +197,10 @@ "MINERU2_5_2509_1_2B_SYSTEM_PROMPT", "MINERU2_5_2509_1_2B_TABLE_PROMPT", "MINERU2_5_2509_1_2B_TABLE_TEMPLATE", + "NANONETS_OCR2_3B_MODEL_ID", + "NANONETS_OCR2_3B_OCR_PROMPT", + "NANONETS_OCR2_3B_OCR_TEMPLATE", + "NANONETS_OCR2_3B_SYSTEM_PROMPT", "OLMOCR_2_7B_1025_FP8_MODEL_ID", "OLMOCR_2_7B_1025_MODEL_ID", "OLMOCR_2_7B_1025_OCR_TEMPLATE", diff --git a/tests/test_cli.py b/tests/test_cli.py index a7f0e30..924de78 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -33,6 +33,8 @@ INFINITY_PARSER_7B_OCR_TEMPLATE, MINERU2_5_2509_1_2B_MODEL_ID, MINERU2_5_2509_1_2B_OCR_TEMPLATE, + NANONETS_OCR2_3B_MODEL_ID, + NANONETS_OCR2_3B_OCR_TEMPLATE, OLMOCR_2_7B_1025_MODEL_ID, OLMOCR_2_7B_1025_OCR_TEMPLATE, PADDLEOCR_VL_1_5_MODEL_ID, @@ -304,6 +306,26 @@ def test_build_ocr_backend_aligns_templates_for_firered_ocr() -> None: } +def test_build_ocr_backend_aligns_templates_for_nanonets_ocr2_3b() -> None: + litellm_backend = _build_litellm_backend(NANONETS_OCR2_3B_MODEL_ID) + hf_backend = _build_hf_backend(NANONETS_OCR2_3B_MODEL_ID) + openai_backend = _build_openai_compatible_backend(NANONETS_OCR2_3B_MODEL_ID) + + assert litellm_backend.template == NANONETS_OCR2_3B_OCR_TEMPLATE + assert litellm_backend.template == hf_backend.template == openai_backend.template + assert litellm_backend.model_name == "Nanonets-OCR2-3B" + assert hf_backend.model_name == "Nanonets-OCR2-3B" + assert openai_backend.model_name == "Nanonets-OCR2-3B" + assert litellm_backend.transport.config.completion_kwargs == { + "max_tokens": 15_000, + "temperature": 0.0, + } + assert openai_backend.transport.config.completion_kwargs == { + "max_tokens": 15_000, + "temperature": 0.0, + } + + def test_build_ocr_backend_aligns_templates_for_glm_ocr() -> None: litellm_backend = _build_litellm_backend(GLM_OCR_MODEL_ID) hf_backend = _build_hf_backend(GLM_OCR_MODEL_ID) diff --git a/tests/test_hf_ocr.py b/tests/test_hf_ocr.py index c467ee1..500c045 100644 --- a/tests/test_hf_ocr.py +++ b/tests/test_hf_ocr.py @@ -44,6 +44,7 @@ glm_ocr_text_postprocessor, infinity_parser_7b_text_postprocessor, lfm2_5_vl_text_postprocessor, + nanonets_ocr2_3b_text_postprocessor, ) from churro_ocr.templates import ( CHANDRA_OCR_2_MODEL_ID, @@ -82,6 +83,10 @@ MINERU2_5_2509_1_2B_SYSTEM_PROMPT, MINERU2_5_2509_1_2B_TABLE_PROMPT, MINERU2_5_2509_1_2B_TABLE_TEMPLATE, + NANONETS_OCR2_3B_MODEL_ID, + NANONETS_OCR2_3B_OCR_PROMPT, + NANONETS_OCR2_3B_OCR_TEMPLATE, + NANONETS_OCR2_3B_SYSTEM_PROMPT, OLMOCR_2_7B_1025_MODEL_ID, OLMOCR_2_7B_1025_OCR_TEMPLATE, PADDLEOCR_VL_1_5_MODEL_ID, @@ -148,6 +153,18 @@ def test_firered_ocr_template_matches_documented_prompt_shape() -> None: assert conversation[0]["content"][1]["text"] == FIRERED_OCR_OCR_PROMPT +def test_nanonets_ocr2_3b_template_matches_documented_prompt_shape() -> None: + page = DocumentPage.from_image(Image.new("RGB", (20, 20), color="white")) + + conversation = NANONETS_OCR2_3B_OCR_TEMPLATE.build_conversation(page) + + assert conversation[0]["role"] == "system" + assert conversation[0]["content"][0]["text"] == NANONETS_OCR2_3B_SYSTEM_PROMPT + assert conversation[1]["role"] == "user" + assert conversation[1]["content"][0]["type"] == "image" + assert conversation[1]["content"][1]["text"] == NANONETS_OCR2_3B_OCR_PROMPT + + def test_infinity_parser_template_matches_documented_prompt_shape() -> None: page = DocumentPage.from_image(Image.new("RGB", (20, 20), color="white")) @@ -379,6 +396,35 @@ def test_firered_ocr_text_postprocessor_strips_prompt_echo_and_preserves_raw_mar } +def test_nanonets_ocr2_3b_text_postprocessor_strips_prompt_echo_and_preserves_raw_markdown() -> None: + processed = nanonets_ocr2_3b_text_postprocessor( + f"{NANONETS_OCR2_3B_SYSTEM_PROMPT}\n" + f"{NANONETS_OCR2_3B_OCR_PROMPT}\n" + "assistant:\n" + "```markdown\n" + "# Heading\n\n" + "OFFICIAL COPY\n\n" + "9/22\n\n" + "
YearValue
190042
\n\n" + "Paragraph with [note](https://example.test).\n" + "```\n" + "<|im_end|>" + ) + assert isinstance(processed, tuple) + text, metadata = processed + + assert text == "Heading\n\nOFFICIAL COPY\n\n9/22\n\nYear | Value\n1900 | 42\n\nParagraph with note." + assert metadata == { + "raw_markdown": ( + "# Heading\n\n" + "OFFICIAL COPY\n\n" + "9/22\n\n" + "
YearValue
190042
\n\n" + "Paragraph with [note](https://example.test)." + ), + } + + def test_deepseek_ocr_2_text_postprocessor_strips_prompt_echo_and_stop_token() -> None: assert ( deepseek_ocr_2_text_postprocessor( @@ -464,6 +510,32 @@ def test_build_ocr_backend_uses_firered_ocr_profile_defaults_for_hf() -> None: assert preprocessed_image.mode == "RGB" +def test_build_ocr_backend_uses_nanonets_ocr2_3b_profile_defaults_for_hf() -> None: + backend = cast( + "HuggingFaceVisionOCRBackend", + build_ocr_backend( + OCRBackendSpec( + provider="hf", + model=NANONETS_OCR2_3B_MODEL_ID, + ) + ), + ) + + assert type(backend) is HuggingFaceVisionOCRBackend + assert backend.template == NANONETS_OCR2_3B_OCR_TEMPLATE + assert backend.model_name == "Nanonets-OCR2-3B" + assert backend.generation_kwargs == { + "max_new_tokens": 15_000, + "do_sample": False, + } + assert backend.trust_remote_code is False + assert backend.processor_kwargs == {} + assert backend.model_kwargs == {} + preprocessed_image = backend.image_preprocessor(Image.new("RGBA", (32, 16), color=(255, 255, 255, 255))) + assert preprocessed_image.size == (32, 16) + assert preprocessed_image.mode == "RGB" + + def test_build_ocr_backend_uses_glm_ocr_profile_defaults_for_hf() -> None: backend = cast( "GlmOCROCRBackend", diff --git a/tests/test_providers.py b/tests/test_providers.py index 95d942a..3417637 100644 --- a/tests/test_providers.py +++ b/tests/test_providers.py @@ -62,6 +62,10 @@ MINERU2_5_2509_1_2B_MODEL_ID, MINERU2_5_2509_1_2B_OCR_PROMPT, MINERU2_5_2509_1_2B_OCR_TEMPLATE, + NANONETS_OCR2_3B_MODEL_ID, + NANONETS_OCR2_3B_OCR_PROMPT, + NANONETS_OCR2_3B_OCR_TEMPLATE, + NANONETS_OCR2_3B_SYSTEM_PROMPT, OLMOCR_2_7B_1025_FP8_MODEL_ID, OLMOCR_2_7B_1025_MODEL_ID, OLMOCR_2_7B_1025_OCR_TEMPLATE, @@ -929,6 +933,30 @@ def test_build_ocr_backend_uses_firered_ocr_profile_defaults_for_openai_compatib assert prompt_image.mode == "RGB" +def test_build_ocr_backend_uses_nanonets_ocr2_3b_profile_defaults_for_openai_compatible() -> None: + backend = cast( + "OpenAICompatibleOCRBackend", + build_ocr_backend( + OCRBackendSpec( + provider="openai-compatible", + model=NANONETS_OCR2_3B_MODEL_ID, + transport=LiteLLMTransportConfig(api_base="http://127.0.0.1:8000/v1"), + ) + ), + ) + + assert type(backend) is OpenAICompatibleOCRBackend + assert backend.template == NANONETS_OCR2_3B_OCR_TEMPLATE + assert backend.model_name == "Nanonets-OCR2-3B" + assert backend.transport.config.completion_kwargs == { + "max_tokens": 15_000, + "temperature": 0.0, + } + prompt_image = backend.image_preprocessor(Image.new("RGBA", (32, 16), color=(255, 255, 255, 255))) + assert prompt_image.size == (32, 16) + assert prompt_image.mode == "RGB" + + @pytest.mark.asyncio async def test_openai_compatible_backend_uses_deepseek_ocr_2_prompt_and_postprocessing( monkeypatch: pytest.MonkeyPatch, @@ -1080,6 +1108,103 @@ async def _fake_complete_text( assert prompt_image.mode == "RGB" +@pytest.mark.asyncio +async def test_openai_compatible_backend_uses_nanonets_prompt_and_markdown_postprocessing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured: dict[str, object] = {} + + def _fake_prepare_messages_from_conversation( + self: LiteLLMTransport, + conversation: list[dict[str, object]], + ) -> list[dict[str, object]]: + captured["conversation"] = conversation + captured["completion_kwargs"] = dict(self.config.completion_kwargs) + return [{"role": "user", "content": [{"type": "text", "text": "prompt"}]}] + + async def _fake_complete_text( + _transport: LiteLLMTransport, + *, + model: str, + messages: list[dict[str, object]], + timeout_seconds: int = 600, + output_json: bool = False, + allow_empty: bool = False, + ) -> str: + captured["model"] = model + captured["messages"] = messages + captured["timeout_seconds"] = timeout_seconds + captured["output_json"] = output_json + captured["allow_empty"] = allow_empty + captured["completion_kwargs"] = dict(_transport.config.completion_kwargs) + return ( + f"{NANONETS_OCR2_3B_SYSTEM_PROMPT}\n" + f"{NANONETS_OCR2_3B_OCR_PROMPT}\n" + "assistant:\n" + "```markdown\n" + "# Ledger\n\n" + "OFFICIAL COPY\n\n" + "9/22\n\n" + "" + "
YearValue
190042
\n\n" + "Paragraph with [note](https://example.test).\n" + "```\n" + "<|im_end|>" + ) + + monkeypatch.setattr( + LiteLLMTransport, + "prepare_messages_from_conversation", + _fake_prepare_messages_from_conversation, + ) + monkeypatch.setattr(LiteLLMTransport, "complete_text", _fake_complete_text) + + backend = cast( + "OpenAICompatibleOCRBackend", + build_ocr_backend( + OCRBackendSpec( + provider="openai-compatible", + model=NANONETS_OCR2_3B_MODEL_ID, + transport=LiteLLMTransportConfig(api_base="http://127.0.0.1:8000/v1"), + ) + ), + ) + result = await backend.ocr( + DocumentPage.from_image(Image.new("RGBA", (32, 16), color=(255, 255, 255, 255))) + ) + + assert result.text == "Ledger\n\nOFFICIAL COPY\n\n9/22\n\nYear | Value\n1900 | 42\n\nParagraph with note." + assert result.metadata == { + "raw_markdown": ( + "# Ledger\n\n" + "OFFICIAL COPY\n\n" + "9/22\n\n" + "
YearValue
190042
\n\n" + "Paragraph with [note](https://example.test)." + ), + } + assert captured["model"] == f"openai/{NANONETS_OCR2_3B_MODEL_ID}" + assert captured["messages"] == [{"role": "user", "content": [{"type": "text", "text": "prompt"}]}] + assert captured["timeout_seconds"] == 600 + assert captured["output_json"] is False + assert captured["allow_empty"] is True + assert captured["completion_kwargs"] == { + "max_tokens": 15_000, + "temperature": 0.0, + } + conversation = cast("list[dict[str, object]]", captured["conversation"]) + assert conversation[0]["role"] == "system" + system_content = cast("list[dict[str, object]]", conversation[0]["content"]) + assert system_content[0] == {"type": "text", "text": NANONETS_OCR2_3B_SYSTEM_PROMPT} + assert conversation[1]["role"] == "user" + user_content = cast("list[dict[str, object]]", conversation[1]["content"]) + assert user_content[0]["type"] == "image" + assert user_content[1] == {"type": "text", "text": NANONETS_OCR2_3B_OCR_PROMPT} + prompt_image = cast("Image.Image", user_content[0]["image"]) + assert prompt_image.size == (32, 16) + assert prompt_image.mode == "RGB" + + @pytest.mark.asyncio async def test_openai_compatible_backend_uses_olmocr_prompt_and_plain_text_postprocessing( monkeypatch: pytest.MonkeyPatch, diff --git a/tests/test_tooling_benchmark.py b/tests/test_tooling_benchmark.py index bb0f127..5b33e73 100644 --- a/tests/test_tooling_benchmark.py +++ b/tests/test_tooling_benchmark.py @@ -19,6 +19,7 @@ GLM_OCR_OCR_TEMPLATE, INFINITY_PARSER_7B_OCR_TEMPLATE, MINERU2_5_2509_1_2B_OCR_TEMPLATE, + NANONETS_OCR2_3B_OCR_TEMPLATE, PADDLEOCR_VL_1_5_OCR_TEMPLATE, ) from tooling.benchmarking import benchmark @@ -505,6 +506,55 @@ def test_build_ocr_backend_uses_firered_ocr_preset_for_openai_compatible() -> No } +def test_build_ocr_backend_uses_nanonets_ocr2_3b_preset_for_hf() -> None: + backend = cast( + "HuggingFaceVisionOCRBackend", + benchmark._build_ocr_backend( + benchmark.BenchmarkOptions( + backend="hf", + dataset_split="dev", + model="nanonets/Nanonets-OCR2-3B", + ) + ), + ) + + assert type(backend) is HuggingFaceVisionOCRBackend + assert backend.model_name == "Nanonets-OCR2-3B" + assert backend.template == NANONETS_OCR2_3B_OCR_TEMPLATE + assert backend.trust_remote_code is False + assert backend.processor_kwargs == {} + assert backend.model_kwargs == { + "device_map": "auto", + "torch_dtype": "auto", + } + assert backend.generation_kwargs == { + "max_new_tokens": 15_000, + "do_sample": False, + } + + +def test_build_ocr_backend_uses_nanonets_ocr2_3b_preset_for_openai_compatible() -> None: + backend = cast( + "LiteLLMVisionOCRBackend", + benchmark._build_ocr_backend( + benchmark.BenchmarkOptions( + backend="openai-compatible", + dataset_split="dev", + model="nanonets/Nanonets-OCR2-3B", + base_url="http://127.0.0.1:8000/v1", + ) + ), + ) + + assert backend.provider_name == "openai-compatible" + assert backend.model_name == "Nanonets-OCR2-3B" + assert backend.template == NANONETS_OCR2_3B_OCR_TEMPLATE + assert backend.transport.config.completion_kwargs == { + "max_tokens": 15_000, + "temperature": 0.0, + } + + def test_build_ocr_backend_uses_glm_ocr_preset_for_hf() -> None: backend = cast( "HuggingFaceVisionOCRBackend", From ddd5dc70d500a740cd6c64995866b97bd9b5d680 Mon Sep 17 00:00:00 2001 From: Sina Date: Tue, 14 Apr 2026 18:54:48 +0000 Subject: [PATCH 52/58] docs(leaderboard): add Nanonets OCR 2 benchmark result --- benchmark_results.json | 54 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/benchmark_results.json b/benchmark_results.json index bde2e55..69aef40 100644 --- a/benchmark_results.json +++ b/benchmark_results.json @@ -3076,5 +3076,59 @@ "Khmer": 8.8 } } + }, + { + "modelName": "Nanonets OCR 2", + "modelId": "nanonets/Nanonets-OCR2-3B", + "modelUrl": "https://huggingface.co/nanonets/Nanonets-OCR2-3B", + "iconPath": "_static/img/nanonets-logo.png", + "printed": 70.7, + "handwritten": 38.2, + "total": 53.2, + "main_language_and_type_metrics": { + "print": { + "Czech": 94.4, + "German": 72.2, + "Latin": 81.1, + "French": 80.4, + "English": 87.6, + "Hindi": 63.1, + "Sanskrit": 65.2, + "Swedish": 74.4, + "Chinese": 5.3, + "Dutch": 88.0, + "Finnish": 47.1, + "Polish": 74.9, + "Spanish": 85.7, + "Slovenian": 94.6, + "Bulgarian": 81.4, + "Japanese": 43.2, + "Bangla": 75.9, + "Romanian": 58.6 + }, + "handwriting": { + "Spanish": 43.6, + "Dutch": 56.2, + "French": 56.2, + "Latin": 53.0, + "Hebrew": 9.5, + "Japanese": 2.4, + "Italian": 66.1, + "German": 48.5, + "Greek": 13.9, + "Swedish": 57.6, + "Arabic": 47.0, + "Chinese": 43.9, + "English": 62.4, + "Catalan": 65.5, + "Portuguese": 25.1, + "Sanskrit": 2.4, + "Turkish": 7.4, + "Persian": 18.7, + "Vietnamese": 57.2, + "Norwegian": 64.7, + "Khmer": 1.4 + } + } } ] From 59b36a53e278eae0367de0635a4a886a54584d60 Mon Sep 17 00:00:00 2001 From: Sina Date: Tue, 14 Apr 2026 22:25:54 +0000 Subject: [PATCH 53/58] feat(providers): add Qianfan OCR support --- docs/guides/advanced-customization.md | 1 + docs/guides/providers.md | 4 +- src/churro_ocr/providers/_ocr_processing.py | 18 ++ src/churro_ocr/providers/builder.py | 3 + src/churro_ocr/providers/hf.py | 300 +++++++++++++++++++- src/churro_ocr/providers/specs.py | 33 +++ src/churro_ocr/templates/__init__.py | 6 + src/churro_ocr/templates/presets.py | 9 + tests/test_cli.py | 22 ++ tests/test_hf_ocr.py | 213 ++++++++++++++ tests/test_providers.py | 116 ++++++++ tests/test_tooling_benchmark.py | 56 +++- 12 files changed, 777 insertions(+), 4 deletions(-) diff --git a/docs/guides/advanced-customization.md b/docs/guides/advanced-customization.md index 3d35e01..6341c37 100644 --- a/docs/guides/advanced-customization.md +++ b/docs/guides/advanced-customization.md @@ -47,6 +47,7 @@ Use the [templates API](../api/templates.md) for exact signatures. | `DEEPSEEK_OCR_2_OCR_TEMPLATE` | Built-in template for `deepseek-ai/DeepSeek-OCR-2`. | | `FIRERED_OCR_OCR_TEMPLATE` | Built-in markdown-conversion template for `FireRedTeam/FireRed-OCR`. | | `NANONETS_OCR2_3B_OCR_TEMPLATE` | Built-in structured-markdown template for `nanonets/Nanonets-OCR2-3B`. | +| `QIANFAN_OCR_OCR_TEMPLATE` | Built-in markdown-conversion template for `baidu/Qianfan-OCR`. | | `DOTS_OCR_1_5_OCR_TEMPLATE` | Built-in template for `kristaller486/dots.ocr-1.5`. | | `DOTS_MOCR_OCR_TEMPLATE` | Built-in template for `rednote-hilab/dots.mocr`. | | `INFINITY_PARSER_7B_OCR_TEMPLATE` | Built-in markdown-conversion template for `infly/Infinity-Parser-7B`. | diff --git a/docs/guides/providers.md b/docs/guides/providers.md index 0c7a501..d5adc49 100644 --- a/docs/guides/providers.md +++ b/docs/guides/providers.md @@ -59,12 +59,14 @@ backend = build_ocr_backend( ) ``` -Built-in model-specific profiles are resolved automatically for known models such as `stanford-oval/churro-3B`, `datalab-to/chandra-ocr-2`, `deepseek-ai/DeepSeek-OCR-2`, `FireRedTeam/FireRed-OCR`, `nanonets/Nanonets-OCR2-3B`, `zai-org/GLM-OCR`, `kristaller486/dots.ocr-1.5`, `rednote-hilab/dots.mocr`, `infly/Infinity-Parser-7B`, `opendatalab/MinerU2.5-2509-1.2B`, `PaddlePaddle/PaddleOCR-VL-1.5`, `LiquidAI/LFM2.5-VL-1.6B`, and the supported `olmOCR` checkpoints. +Built-in model-specific profiles are resolved automatically for known models such as `stanford-oval/churro-3B`, `datalab-to/chandra-ocr-2`, `deepseek-ai/DeepSeek-OCR-2`, `FireRedTeam/FireRed-OCR`, `nanonets/Nanonets-OCR2-3B`, `baidu/Qianfan-OCR`, `zai-org/GLM-OCR`, `kristaller486/dots.ocr-1.5`, `rednote-hilab/dots.mocr`, `infly/Infinity-Parser-7B`, `opendatalab/MinerU2.5-2509-1.2B`, `PaddlePaddle/PaddleOCR-VL-1.5`, `LiquidAI/LFM2.5-VL-1.6B`, and the supported `olmOCR` checkpoints. For `FireRedTeam/FireRed-OCR`, the built-in `hf` and `openai-compatible` backends use the model's published Markdown-conversion prompt. The OCR result preserves the raw markdown in metadata, and repo-local benchmark evaluation normalizes that markdown or embedded HTML back to plain text before metrics are computed. For `nanonets/Nanonets-OCR2-3B`, the built-in `hf` and `openai-compatible` backends use the model's published structured-markdown OCR prompt. The OCR result preserves the raw markdown in metadata, and tagged markdown or embedded HTML is normalized back to plain text for evaluation-friendly output. +For `baidu/Qianfan-OCR`, the built-in `hf` and `openai-compatible` backends use the published `Parse this document to Markdown.` prompt. The OCR result preserves the raw markdown in metadata, and repo-local benchmark evaluation normalizes that markdown or embedded HTML back to plain text before metrics are computed. + For `zai-org/GLM-OCR`, the built-in `hf` and `openai-compatible` backends both use the model's documented `Text Recognition:` prompt For `infly/Infinity-Parser-7B`, the built-in `hf` and `openai-compatible` backends use the documented markdown-conversion prompt and treat the response as markdown or embedded HTML. The OCR result preserves the raw markdown in metadata, and repo-local benchmark evaluation normalizes that markdown or HTML back to plain text before metrics are computed. diff --git a/src/churro_ocr/providers/_ocr_processing.py b/src/churro_ocr/providers/_ocr_processing.py index 4e66df3..0fbbbfe 100644 --- a/src/churro_ocr/providers/_ocr_processing.py +++ b/src/churro_ocr/providers/_ocr_processing.py @@ -26,6 +26,7 @@ NANONETS_OCR2_3B_OCR_PROMPT, NANONETS_OCR2_3B_SYSTEM_PROMPT, PADDLEOCR_VL_1_5_OCR_PROMPT, + QIANFAN_OCR_OCR_PROMPT, ) from churro_ocr.types import MetadataDict @@ -220,6 +221,23 @@ def nanonets_ocr2_3b_text_postprocessor(text: str) -> TextPostprocessorResult: } +def qianfan_ocr_text_postprocessor(text: str) -> TextPostprocessorResult: + """Normalize Qianfan-OCR markdown output to plain text and preserve raw markdown.""" + cleaned = strip_leading_chat_scaffold(text, prompts=[QIANFAN_OCR_OCR_PROMPT]) + for _ in range(8): + previous = cleaned + for token in ("<|im_end|>", "<|endoftext|>", "<|assistant|>", "<|user|>", "<|system|>"): + if cleaned.endswith(token): + cleaned = cleaned[: -len(token)].rstrip() + break + if cleaned == previous: + break + raw_markdown = strip_outer_fenced_code_block(cleaned) + return strip_rich_ocr_markup_to_plain_text(raw_markdown), { + "raw_markdown": raw_markdown, + } + + def deepseek_ocr_2_text_postprocessor(text: str) -> str: """Strip DeepSeek OCR 2 prompt echoes, chat scaffold, and trailing stop tokens.""" cleaned = text.strip() diff --git a/src/churro_ocr/providers/builder.py b/src/churro_ocr/providers/builder.py index f42e8dc..0e20367 100644 --- a/src/churro_ocr/providers/builder.py +++ b/src/churro_ocr/providers/builder.py @@ -16,6 +16,7 @@ LFM25VLOCRBackend, MinerU25OCRBackend, PaddleOCRVL15OCRBackend, + QianfanOCROCRBackend, _default_dots_ocr_1_5_model_kwargs, ) from churro_ocr.providers.ocr import ( @@ -209,6 +210,8 @@ def _build_huggingface_backend(spec: OCRBackendSpec, profile: OCRModelProfile) - backend_cls = PaddleOCRVL15OCRBackend elif options.backend_variant == "lfm2.5-vl": backend_cls = LFM25VLOCRBackend + elif options.backend_variant == "qianfan-ocr": + backend_cls = QianfanOCROCRBackend return backend_cls( model_id=spec.model, template=profile.template, diff --git a/src/churro_ocr/providers/hf.py b/src/churro_ocr/providers/hf.py index 7877fe5..0811619 100644 --- a/src/churro_ocr/providers/hf.py +++ b/src/churro_ocr/providers/hf.py @@ -4,12 +4,16 @@ import asyncio import threading +from contextlib import nullcontext from dataclasses import dataclass, field from importlib import import_module from pathlib import Path from tempfile import TemporaryDirectory from typing import TYPE_CHECKING, Any, cast +import numpy as np +from PIL import Image + from churro_ocr._internal.image import ensure_rgb from churro_ocr._internal.prompt_logging import log_prompt_payload_once from churro_ocr.ocr import OCRBackend, OCRResult @@ -55,14 +59,14 @@ MINERU2_5_2509_1_2B_TABLE_TEMPLATE, PADDLEOCR_VL_1_5_MODEL_ID, PADDLEOCR_VL_1_5_OCR_TEMPLATE, + QIANFAN_OCR_MODEL_ID, + QIANFAN_OCR_OCR_TEMPLATE, OCRConversation, OCRPromptTemplateLike, build_ocr_conversation, ) if TYPE_CHECKING: - from PIL import Image - from churro_ocr.providers._mineru25 import MinerU25PipelineHelper _HF_EXTRA_INSTALL_HINT = _runtime._HF_EXTRA_INSTALL_HINT @@ -80,6 +84,21 @@ _paddleocr_vl_processor_kwargs = _helpers._paddleocr_vl_processor_kwargs _provider_error = _runtime._provider_error _resolve_model_max_length = _helpers._resolve_model_max_length +_QIANFAN_IMAGE_SIZE = 448 +_QIANFAN_MAX_TILES = 12 +_QIANFAN_MAX_NEW_TOKENS = 4_096 +_QIANFAN_IMAGENET_MEAN = np.asarray((0.485, 0.456, 0.406), dtype=np.float32)[:, None, None] +_QIANFAN_IMAGENET_STD = np.asarray((0.229, 0.224, 0.225), dtype=np.float32)[:, None, None] +_QIANFAN_TARGET_RATIOS = sorted( + { + (width, height) + for num_tiles in range(1, _QIANFAN_MAX_TILES + 1) + for width in range(1, num_tiles + 1) + for height in range(1, num_tiles + 1) + if 1 <= width * height <= _QIANFAN_MAX_TILES + }, + key=lambda ratio: ratio[0] * ratio[1], +) def _load_torch_module() -> _runtime._TorchModuleLike: @@ -135,6 +154,168 @@ def _default_dots_ocr_1_5_model_kwargs() -> dict[str, object]: return _dots._default_dots_ocr_1_5_model_kwargs(load_torch_module=_load_torch_module) +def _qianfan_find_closest_aspect_ratio( + aspect_ratio: float, + *, + width: int, + height: int, + image_size: int, +) -> tuple[int, int]: + best_ratio = (1, 1) + best_ratio_diff = float("inf") + area = width * height + for ratio in _QIANFAN_TARGET_RATIOS: + target_aspect_ratio = ratio[0] / ratio[1] + ratio_diff = abs(aspect_ratio - target_aspect_ratio) + if ratio_diff < best_ratio_diff: + best_ratio_diff = ratio_diff + best_ratio = ratio + continue + if ratio_diff == best_ratio_diff and area > 0.5 * image_size * image_size * ratio[0] * ratio[1]: + best_ratio = ratio + return best_ratio + + +def _qianfan_dynamic_preprocess( + image: Image.Image, + *, + image_size: int = _QIANFAN_IMAGE_SIZE, + use_thumbnail: bool = True, +) -> list[Image.Image]: + rgb_image = ensure_rgb(image) + width, height = rgb_image.size + aspect_ratio = width / max(height, 1) + target_ratio = _qianfan_find_closest_aspect_ratio( + aspect_ratio, + width=width, + height=height, + image_size=image_size, + ) + target_width = image_size * target_ratio[0] + target_height = image_size * target_ratio[1] + blocks = target_ratio[0] * target_ratio[1] + bicubic = Image.Resampling.BICUBIC + resized = rgb_image.resize((target_width, target_height), resample=bicubic) + processed_images: list[Image.Image] = [] + grid_width = target_width // image_size + for index in range(blocks): + left = (index % grid_width) * image_size + top = (index // grid_width) * image_size + processed_images.append( + resized.crop( + ( + left, + top, + left + image_size, + top + image_size, + ) + ) + ) + if use_thumbnail and len(processed_images) != 1: + processed_images.append(rgb_image.resize((image_size, image_size), resample=bicubic)) + return processed_images + + +def _qianfan_image_to_tensor(image: Image.Image, *, torch: object) -> object: + from_numpy = getattr(torch, "from_numpy", None) + if not callable(from_numpy): + message = "Qianfan-OCR HF backend requires `torch.from_numpy(...)` support." + raise _configuration_error(message) + array = np.asarray(ensure_rgb(image), dtype=np.float32) / 255.0 + array = np.ascontiguousarray(array.transpose(2, 0, 1)) + array = (array - _QIANFAN_IMAGENET_MEAN) / _QIANFAN_IMAGENET_STD + return from_numpy(array) + + +def _qianfan_load_pixel_values( + image: Image.Image, + *, + torch: object, +) -> object: + stack = getattr(torch, "stack", None) + if not callable(stack): + message = "Qianfan-OCR HF backend requires `torch.stack(...)` support." + raise _configuration_error(message) + images = _qianfan_dynamic_preprocess(image) + return stack([_qianfan_image_to_tensor(processed_image, torch=torch) for processed_image in images]) + + +def _qianfan_prompt_from_conversation(conversation: OCRConversation) -> str: + prompt_parts: list[str] = [] + for message in conversation: + if message.get("role") == "system": + continue + content = cast("list[dict[str, object]]", message.get("content", [])) + for item in content: + text = item.get("text") + if item.get("type") == "text" and isinstance(text, str) and text.strip(): + prompt_parts.append(text) + prompt = "\n".join(prompt_parts).strip() + if not prompt: + message = "Qianfan-OCR requires a text prompt." + raise _configuration_error(message) + return prompt + + +def _qianfan_model_dtype(model: object, *, torch: object) -> object: + model_dtype = getattr(model, "dtype", None) + if model_dtype is not None: + return model_dtype + parameters = getattr(model, "parameters", None) + if callable(parameters): + try: + first_parameter = next(parameters()) + except (StopIteration, TypeError): + first_parameter = None + if first_parameter is not None: + parameter_dtype = getattr(first_parameter, "dtype", None) + if parameter_dtype is not None: + return parameter_dtype + return getattr(torch, "bfloat16", None) + + +def _qianfan_model_device(model: object) -> object | None: + model_device = getattr(model, "device", None) + if model_device is not None: + return model_device + parameters = getattr(model, "parameters", None) + if callable(parameters): + try: + first_parameter = next(parameters()) + except (StopIteration, TypeError): + first_parameter = None + if first_parameter is not None: + return getattr(first_parameter, "device", None) + return None + + +def _qianfan_move_tensor(tensor: object, target: object) -> object: + if target is None: + return tensor + to_method = getattr(tensor, "to", None) + if not callable(to_method): + return tensor + moved = to_method(target) + return tensor if moved is None else moved + + +def _qianfan_response_to_text(response: object) -> str: + if isinstance(response, str): + return response + if isinstance(response, (tuple, list)): + for item in response: + if isinstance(item, str): + return item + if isinstance(response, dict): + response_dict = cast("dict[str, object]", response) + for key in ("text", "response", "output"): + value = response_dict.get(key) + if isinstance(value, str): + return value + message = "Qianfan-OCR returned no OCR text." + raise _provider_error(message) + + @dataclass(slots=True) class HuggingFaceVisionOCRBackend(OCRBackend): """OCR backend for local Hugging Face multimodal models with custom templates. @@ -800,6 +981,120 @@ def _ocr_batch_sync(self, pages: list[DocumentPage]) -> list[OCRResult]: return results +@dataclass(slots=True) +class QianfanOCROCRBackend(HuggingFaceVisionOCRBackend): + """Preset OCR backend for ``baidu/Qianfan-OCR``.""" + + model_id: str = QIANFAN_OCR_MODEL_ID + template: OCRPromptTemplateLike = QIANFAN_OCR_OCR_TEMPLATE + model_name: str | None = "Qianfan-OCR" + trust_remote_code: bool = True + image_preprocessor: ImagePreprocessor = ensure_rgb + generation_kwargs: dict[str, object] = field( + default_factory=lambda: {"max_new_tokens": _QIANFAN_MAX_NEW_TOKENS, "do_sample": False} + ) + + def _load_runtime(self) -> _HFRuntime: + return _load_hf_auto_model_runtime() + + def _get_model(self, runtime: _HFRuntime) -> object: + if self._model is None: + with self._init_lock: + if self._model is None: + model = runtime.model_cls.from_pretrained( + self._get_model_source(), + trust_remote_code=self.trust_remote_code, + **self.model_kwargs, + ) + eval_method = getattr(model, "eval", None) + if callable(eval_method): + maybe_evaluated = eval_method() + if maybe_evaluated is not None: + model = maybe_evaluated + self._model = model + return self._model + + def _infer_qianfan_page( + self, + *, + tokenizer: object, + model: object, + page: DocumentPage, + batch_size: int, + ) -> OCRResult: + torch = _load_torch_module() + conversation = build_ocr_conversation(self.template, page) + prompt = _qianfan_prompt_from_conversation(conversation) + self._log_prompt_payload( + rendered_prompt=prompt, + conversation=conversation, + batch_size=batch_size, + ) + pixel_values = _qianfan_load_pixel_values(page.image, torch=torch) + pixel_values = _qianfan_move_tensor(pixel_values, _qianfan_model_dtype(model, torch=torch)) + pixel_values = _qianfan_move_tensor(pixel_values, _qianfan_model_device(model)) + + chat_method = getattr(model, "chat", None) + if not callable(chat_method): + message = "Qianfan-OCR requires a model object with `chat(...)` support." + raise _configuration_error(message) + + no_grad = getattr(torch, "no_grad", None) + inference_context = no_grad() if callable(no_grad) else nullcontext() + with inference_context: + response = chat_method( + tokenizer, + pixel_values=pixel_values, + question=prompt, + generation_config=dict(self.generation_kwargs), + ) + return build_ocr_result( + _qianfan_response_to_text(response), + provider_name=self.provider_name, + model_name=self.model_name or self.model_id, + text_postprocessor=self.text_postprocessor, + ) + + def _ocr_sync(self, page: DocumentPage) -> OCRResult: + prepared_page = preprocess_backend_page( + page, + image_preprocessor=self.image_preprocessor, + ) + runtime = self._load_runtime() + tokenizer = self._get_processor(runtime) + model = self._get_model(runtime) + return self._infer_qianfan_page( + tokenizer=tokenizer, + model=model, + page=prepared_page, + batch_size=1, + ) + + def _ocr_batch_sync(self, pages: list[DocumentPage]) -> list[OCRResult]: + if not pages: + return [] + + runtime = self._load_runtime() + tokenizer = self._get_processor(runtime) + model = self._get_model(runtime) + batch_size = len(pages) + results: list[OCRResult] = [] + for page in pages: + prepared_page = preprocess_backend_page( + page, + image_preprocessor=self.image_preprocessor, + ) + results.append( + self._infer_qianfan_page( + tokenizer=tokenizer, + model=model, + page=prepared_page, + batch_size=batch_size, + ) + ) + return results + + @dataclass(slots=True) class DotsOCR15OCRBackend(HuggingFaceVisionOCRBackend): """Preset OCR backend for ``kristaller486/dots.ocr-1.5``. @@ -1315,4 +1610,5 @@ def _ocr_batch_sync(self, pages: list[DocumentPage]) -> list[OCRResult]: "LFM25VLOCRBackend", "MinerU25OCRBackend", "PaddleOCRVL15OCRBackend", + "QianfanOCROCRBackend", ] diff --git a/src/churro_ocr/providers/specs.py b/src/churro_ocr/providers/specs.py index 1daa81f..ee1e33b 100644 --- a/src/churro_ocr/providers/specs.py +++ b/src/churro_ocr/providers/specs.py @@ -26,6 +26,7 @@ olmocr_image_preprocessor, olmocr_text_postprocessor, paddleocr_vl_text_postprocessor, + qianfan_ocr_text_postprocessor, ) from churro_ocr.templates import ( CHANDRA_OCR_2_MODEL_ID, @@ -56,6 +57,8 @@ OLMOCR_2_7B_1025_OCR_TEMPLATE, PADDLEOCR_VL_1_5_MODEL_ID, PADDLEOCR_VL_1_5_OCR_TEMPLATE, + QIANFAN_OCR_MODEL_ID, + QIANFAN_OCR_OCR_TEMPLATE, OCRConversation, OCRPromptTemplateLike, ) @@ -80,6 +83,7 @@ NANONETS_OCR2_3B_MAX_TOKENS = 15_000 OLMOCR_MAX_TOKENS = 8_000 PADDLEOCR_VL_MAX_TOKENS = 4_096 +QIANFAN_OCR_MAX_TOKENS = 4_096 INFINITY_PARSER_7B_MIN_PIXELS = 256 * 28 * 28 INFINITY_PARSER_7B_MAX_PIXELS = 2304 * 28 * 28 MISTRAL_OCR_MODEL_IDS: tuple[MistralOCRModel, ...] = ( @@ -342,6 +346,31 @@ def nanonets_ocr2_3b_profile() -> OCRModelProfile: ) +def qianfan_ocr_profile() -> OCRModelProfile: + """Return the built-in ``baidu/Qianfan-OCR`` OCR profile.""" + return OCRModelProfile( + profile_name=QIANFAN_OCR_MODEL_ID, + template=QIANFAN_OCR_OCR_TEMPLATE, + image_preprocessor=ensure_rgb, + text_postprocessor=qianfan_ocr_text_postprocessor, + display_name="Qianfan-OCR", + transport=LiteLLMTransportConfig( + completion_kwargs={ + "max_tokens": QIANFAN_OCR_MAX_TOKENS, + "temperature": 0.0, + } + ), + huggingface=HuggingFaceOptions( + generation_kwargs={ + "max_new_tokens": QIANFAN_OCR_MAX_TOKENS, + "do_sample": False, + }, + trust_remote_code=True, + backend_variant="qianfan-ocr", + ), + ) + + def glm_ocr_profile() -> OCRModelProfile: """Return the built-in ``zai-org/GLM-OCR`` OCR profile.""" return OCRModelProfile( @@ -549,6 +578,7 @@ def _profile_registry() -> dict[str, OCRModelProfile]: olmocr_profile = olmocr_2_7b_1025_profile() olmocr_fp8_profile = olmocr_2_7b_1025_fp8_profile() paddleocr_vl_profile = paddleocr_vl_1_5_profile() + qianfan_profile = qianfan_ocr_profile() return { default_profile.profile_name: default_profile, churro_profile.profile_name: churro_profile, @@ -565,6 +595,7 @@ def _profile_registry() -> dict[str, OCRModelProfile]: olmocr_profile.profile_name: olmocr_profile, olmocr_fp8_profile.profile_name: olmocr_fp8_profile, paddleocr_vl_profile.profile_name: paddleocr_vl_profile, + qianfan_profile.profile_name: qianfan_profile, } @@ -636,6 +667,8 @@ def resolve_ocr_profile( "olmocr_text_postprocessor", "paddleocr_vl_1_5_profile", "paddleocr_vl_text_postprocessor", + "qianfan_ocr_profile", + "qianfan_ocr_text_postprocessor", "resolve_ocr_profile", "validate_mistral_ocr_model", ] diff --git a/src/churro_ocr/templates/__init__.py b/src/churro_ocr/templates/__init__.py index 843f4e2..2a5cc9a 100644 --- a/src/churro_ocr/templates/__init__.py +++ b/src/churro_ocr/templates/__init__.py @@ -57,6 +57,9 @@ PADDLEOCR_VL_1_5_MODEL_ID, PADDLEOCR_VL_1_5_OCR_PROMPT, PADDLEOCR_VL_1_5_OCR_TEMPLATE, + QIANFAN_OCR_MODEL_ID, + QIANFAN_OCR_OCR_PROMPT, + QIANFAN_OCR_OCR_TEMPLATE, ) __all__ = [ @@ -108,6 +111,9 @@ "PADDLEOCR_VL_1_5_MODEL_ID", "PADDLEOCR_VL_1_5_OCR_PROMPT", "PADDLEOCR_VL_1_5_OCR_TEMPLATE", + "QIANFAN_OCR_MODEL_ID", + "QIANFAN_OCR_OCR_PROMPT", + "QIANFAN_OCR_OCR_TEMPLATE", "HFChatTemplate", "OCRConversation", "OCRPromptTemplate", diff --git a/src/churro_ocr/templates/presets.py b/src/churro_ocr/templates/presets.py index 08885fe..85ae8c8 100644 --- a/src/churro_ocr/templates/presets.py +++ b/src/churro_ocr/templates/presets.py @@ -16,6 +16,7 @@ GLM_OCR_MODEL_ID = "zai-org/GLM-OCR" FIRERED_OCR_MODEL_ID = "FireRedTeam/FireRed-OCR" NANONETS_OCR2_3B_MODEL_ID = "nanonets/Nanonets-OCR2-3B" +QIANFAN_OCR_MODEL_ID = "baidu/Qianfan-OCR" DOTS_OCR_1_5_MODEL_ID = "kristaller486/dots.ocr-1.5" DOTS_MOCR_MODEL_ID = "rednote-hilab/dots.mocr" INFINITY_PARSER_7B_MODEL_ID = "infly/Infinity-Parser-7B" @@ -91,6 +92,11 @@ system_message=NANONETS_OCR2_3B_SYSTEM_PROMPT, user_prompt=NANONETS_OCR2_3B_OCR_PROMPT, ) +QIANFAN_OCR_OCR_PROMPT = "Parse this document to Markdown." +QIANFAN_OCR_OCR_TEMPLATE = HFChatTemplate( + system_message=None, + user_prompt=QIANFAN_OCR_OCR_PROMPT, +) DOTS_OCR_1_5_OCR_PROMPT = "Extract the text content from this image." DOTS_OCR_1_5_OCR_TEMPLATE = HFChatTemplate( system_message=None, @@ -207,4 +213,7 @@ "PADDLEOCR_VL_1_5_MODEL_ID", "PADDLEOCR_VL_1_5_OCR_PROMPT", "PADDLEOCR_VL_1_5_OCR_TEMPLATE", + "QIANFAN_OCR_MODEL_ID", + "QIANFAN_OCR_OCR_PROMPT", + "QIANFAN_OCR_OCR_TEMPLATE", ] diff --git a/tests/test_cli.py b/tests/test_cli.py index 924de78..7108375 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -39,6 +39,8 @@ OLMOCR_2_7B_1025_OCR_TEMPLATE, PADDLEOCR_VL_1_5_MODEL_ID, PADDLEOCR_VL_1_5_OCR_TEMPLATE, + QIANFAN_OCR_MODEL_ID, + QIANFAN_OCR_OCR_TEMPLATE, ) if TYPE_CHECKING: @@ -326,6 +328,26 @@ def test_build_ocr_backend_aligns_templates_for_nanonets_ocr2_3b() -> None: } +def test_build_ocr_backend_aligns_templates_for_qianfan_ocr() -> None: + litellm_backend = _build_litellm_backend(QIANFAN_OCR_MODEL_ID) + hf_backend = _build_hf_backend(QIANFAN_OCR_MODEL_ID) + openai_backend = _build_openai_compatible_backend(QIANFAN_OCR_MODEL_ID) + + assert litellm_backend.template == QIANFAN_OCR_OCR_TEMPLATE + assert litellm_backend.template == hf_backend.template == openai_backend.template + assert litellm_backend.model_name == "Qianfan-OCR" + assert hf_backend.model_name == "Qianfan-OCR" + assert openai_backend.model_name == "Qianfan-OCR" + assert litellm_backend.transport.config.completion_kwargs == { + "max_tokens": 4_096, + "temperature": 0.0, + } + assert openai_backend.transport.config.completion_kwargs == { + "max_tokens": 4_096, + "temperature": 0.0, + } + + def test_build_ocr_backend_aligns_templates_for_glm_ocr() -> None: litellm_backend = _build_litellm_backend(GLM_OCR_MODEL_ID) hf_backend = _build_hf_backend(GLM_OCR_MODEL_ID) diff --git a/tests/test_hf_ocr.py b/tests/test_hf_ocr.py index 500c045..ee74fdf 100644 --- a/tests/test_hf_ocr.py +++ b/tests/test_hf_ocr.py @@ -36,6 +36,7 @@ LFM25VLOCRBackend, MinerU25OCRBackend, PaddleOCRVL15OCRBackend, + QianfanOCROCRBackend, ) from churro_ocr.providers.specs import ( DEFAULT_OCR_MAX_TOKENS, @@ -45,6 +46,7 @@ infinity_parser_7b_text_postprocessor, lfm2_5_vl_text_postprocessor, nanonets_ocr2_3b_text_postprocessor, + qianfan_ocr_text_postprocessor, ) from churro_ocr.templates import ( CHANDRA_OCR_2_MODEL_ID, @@ -92,6 +94,9 @@ PADDLEOCR_VL_1_5_MODEL_ID, PADDLEOCR_VL_1_5_OCR_PROMPT, PADDLEOCR_VL_1_5_OCR_TEMPLATE, + QIANFAN_OCR_MODEL_ID, + QIANFAN_OCR_OCR_PROMPT, + QIANFAN_OCR_OCR_TEMPLATE, HFChatTemplate, OCRConversation, ) @@ -165,6 +170,16 @@ def test_nanonets_ocr2_3b_template_matches_documented_prompt_shape() -> None: assert conversation[1]["content"][1]["text"] == NANONETS_OCR2_3B_OCR_PROMPT +def test_qianfan_ocr_template_matches_documented_prompt_shape() -> None: + page = DocumentPage.from_image(Image.new("RGB", (20, 20), color="white")) + + conversation = QIANFAN_OCR_OCR_TEMPLATE.build_conversation(page) + + assert conversation[0]["role"] == "user" + assert conversation[0]["content"][0]["type"] == "image" + assert conversation[0]["content"][1]["text"] == QIANFAN_OCR_OCR_PROMPT + + def test_infinity_parser_template_matches_documented_prompt_shape() -> None: page = DocumentPage.from_image(Image.new("RGB", (20, 20), color="white")) @@ -425,6 +440,30 @@ def test_nanonets_ocr2_3b_text_postprocessor_strips_prompt_echo_and_preserves_ra } +def test_qianfan_ocr_text_postprocessor_strips_prompt_echo_and_preserves_raw_markdown() -> None: + processed = qianfan_ocr_text_postprocessor( + f"{QIANFAN_OCR_OCR_PROMPT}\n" + "assistant:\n" + "```markdown\n" + "# Heading\n\n" + "
YearValue
190042
\n\n" + "Paragraph with [note](https://example.test).\n" + "```\n" + "<|im_end|>" + ) + assert isinstance(processed, tuple) + text, metadata = processed + + assert text == "Heading\n\nYear | Value\n1900 | 42\n\nParagraph with note." + assert metadata == { + "raw_markdown": ( + "# Heading\n\n" + "
YearValue
190042
\n\n" + "Paragraph with [note](https://example.test)." + ), + } + + def test_deepseek_ocr_2_text_postprocessor_strips_prompt_echo_and_stop_token() -> None: assert ( deepseek_ocr_2_text_postprocessor( @@ -536,6 +575,32 @@ def test_build_ocr_backend_uses_nanonets_ocr2_3b_profile_defaults_for_hf() -> No assert preprocessed_image.mode == "RGB" +def test_build_ocr_backend_uses_qianfan_ocr_profile_defaults_for_hf() -> None: + backend = cast( + "QianfanOCROCRBackend", + build_ocr_backend( + OCRBackendSpec( + provider="hf", + model=QIANFAN_OCR_MODEL_ID, + ) + ), + ) + + assert isinstance(backend, QianfanOCROCRBackend) + assert backend.template == QIANFAN_OCR_OCR_TEMPLATE + assert backend.model_name == "Qianfan-OCR" + assert backend.generation_kwargs == { + "max_new_tokens": 4_096, + "do_sample": False, + } + assert backend.trust_remote_code is True + assert backend.processor_kwargs == {} + assert backend.model_kwargs == {} + preprocessed_image = backend.image_preprocessor(Image.new("RGBA", (32, 16), color=(255, 255, 255, 255))) + assert preprocessed_image.size == (32, 16) + assert preprocessed_image.mode == "RGB" + + def test_build_ocr_backend_uses_glm_ocr_profile_defaults_for_hf() -> None: backend = cast( "GlmOCROCRBackend", @@ -953,6 +1018,138 @@ def from_pretrained(model_id: str, **kwargs: object) -> FakeModel: assert captured["output_dir_exists"] is True +@pytest.mark.asyncio +async def test_qianfan_ocr_huggingface_backend_uses_model_chat_and_profile_defaults( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured: dict[str, Any] = {} + + class FakeTokenizer: + pass + + class FakeTokenizerCls: + @staticmethod + def from_pretrained(model_id: str, **kwargs: object) -> FakeTokenizer: + captured["tokenizer_model_id"] = model_id + captured["tokenizer_from_pretrained_kwargs"] = kwargs + return FakeTokenizer() + + class FakeTensor: + def __init__(self) -> None: + self.to_calls: list[object] = [] + + def to(self, target: object) -> FakeTensor: + self.to_calls.append(target) + return self + + class FakeNoGrad: + def __enter__(self) -> None: + captured["no_grad_entered"] = True + return None + + def __exit__(self, exc_type: object, exc: object, tb: object) -> bool: + captured["no_grad_exited"] = True + return False + + class FakeTorch: + bfloat16 = "fake-bfloat16" + + def from_numpy(self, array: object) -> FakeTensor: + captured.setdefault("tile_shapes", []).append(cast("Any", array).shape) + return FakeTensor() + + def stack(self, tensors: list[FakeTensor]) -> FakeTensor: + captured["stack_input_count"] = len(tensors) + stacked = FakeTensor() + captured["stacked_tensor"] = stacked + return stacked + + def no_grad(self) -> FakeNoGrad: + captured["no_grad_called"] = True + return FakeNoGrad() + + class FakeModel: + device = "fake-device" + dtype = "fake-model-dtype" + + def eval(self) -> FakeModel: + captured["eval_called"] = True + return self + + def chat( + self, + tokenizer: object, + *, + pixel_values: object, + question: str, + generation_config: dict[str, object], + ) -> str: + captured["chat_tokenizer"] = tokenizer + captured["chat_pixel_values"] = pixel_values + captured["chat_question"] = question + captured["chat_generation_config"] = generation_config + return ( + f"{QIANFAN_OCR_OCR_PROMPT}\nassistant:\n```markdown\n# Heading\n\nParagraph.\n```\n<|im_end|>" + ) + + class FakeModelCls: + @staticmethod + def from_pretrained(model_id: str, **kwargs: object) -> FakeModel: + captured["model_model_id"] = model_id + captured["model_from_pretrained_kwargs"] = kwargs + return FakeModel() + + monkeypatch.setattr( + "churro_ocr.providers.hf._load_hf_auto_model_runtime", + lambda: SimpleNamespace( + processor_cls=FakeTokenizerCls, + model_cls=FakeModelCls, + process_vision_info=None, + ), + ) + monkeypatch.setattr( + "churro_ocr.providers.hf._load_torch_module", + lambda: FakeTorch(), + ) + + backend = cast( + "QianfanOCROCRBackend", + build_ocr_backend( + OCRBackendSpec( + provider="hf", + model=QIANFAN_OCR_MODEL_ID, + ) + ), + ) + result = await backend.ocr( + DocumentPage.from_image(Image.new("RGBA", (32, 16), color=(255, 255, 255, 255))) + ) + + assert result.text == "Heading\n\nParagraph." + assert result.metadata == { + "raw_markdown": "# Heading\n\nParagraph.", + } + assert captured["tokenizer_model_id"] == QIANFAN_OCR_MODEL_ID + assert captured["tokenizer_from_pretrained_kwargs"] == {"trust_remote_code": True} + assert captured["model_model_id"] == QIANFAN_OCR_MODEL_ID + assert captured["model_from_pretrained_kwargs"] == {"trust_remote_code": True} + assert captured["eval_called"] is True + assert captured["stack_input_count"] == 3 + assert captured["tile_shapes"] == [(3, 448, 448), (3, 448, 448), (3, 448, 448)] + stacked_tensor = cast("FakeTensor", captured["stacked_tensor"]) + assert stacked_tensor.to_calls == ["fake-model-dtype", "fake-device"] + assert captured["chat_tokenizer"].__class__ is FakeTokenizer + assert captured["chat_pixel_values"] is stacked_tensor + assert captured["chat_question"] == QIANFAN_OCR_OCR_PROMPT + assert captured["chat_generation_config"] == { + "max_new_tokens": 4_096, + "do_sample": False, + } + assert captured["no_grad_called"] is True + assert captured["no_grad_entered"] is True + assert captured["no_grad_exited"] is True + + @pytest.mark.asyncio async def test_glm_ocr_huggingface_backend_uses_tokenized_chat_template_and_profile_defaults( monkeypatch: pytest.MonkeyPatch, @@ -1717,6 +1914,22 @@ def test_deepseek_ocr_2_backend_uses_expected_defaults() -> None: assert backend.crop_mode is True +def test_qianfan_ocr_backend_uses_expected_defaults() -> None: + backend = QianfanOCROCRBackend() + + assert backend.model_id == QIANFAN_OCR_MODEL_ID + assert backend.template == QIANFAN_OCR_OCR_TEMPLATE + assert backend.model_name == "Qianfan-OCR" + assert backend.trust_remote_code is True + assert backend.processor_kwargs == {} + assert backend.model_kwargs == {} + assert backend.generation_kwargs == { + "max_new_tokens": 4_096, + "do_sample": False, + } + assert backend.image_preprocessor(Image.new("RGBA", (10, 10), color=(255, 255, 255, 255))).mode == "RGB" + + @pytest.mark.asyncio async def test_mineru2_5_huggingface_backend_uses_two_step_generation_contract( monkeypatch: pytest.MonkeyPatch, diff --git a/tests/test_providers.py b/tests/test_providers.py index 3417637..b5cf15c 100644 --- a/tests/test_providers.py +++ b/tests/test_providers.py @@ -72,6 +72,9 @@ PADDLEOCR_VL_1_5_MODEL_ID, PADDLEOCR_VL_1_5_OCR_PROMPT, PADDLEOCR_VL_1_5_OCR_TEMPLATE, + QIANFAN_OCR_MODEL_ID, + QIANFAN_OCR_OCR_PROMPT, + QIANFAN_OCR_OCR_TEMPLATE, ) if TYPE_CHECKING: @@ -957,6 +960,30 @@ def test_build_ocr_backend_uses_nanonets_ocr2_3b_profile_defaults_for_openai_com assert prompt_image.mode == "RGB" +def test_build_ocr_backend_uses_qianfan_ocr_profile_defaults_for_openai_compatible() -> None: + backend = cast( + "OpenAICompatibleOCRBackend", + build_ocr_backend( + OCRBackendSpec( + provider="openai-compatible", + model=QIANFAN_OCR_MODEL_ID, + transport=LiteLLMTransportConfig(api_base="http://127.0.0.1:8000/v1"), + ) + ), + ) + + assert type(backend) is OpenAICompatibleOCRBackend + assert backend.template == QIANFAN_OCR_OCR_TEMPLATE + assert backend.model_name == "Qianfan-OCR" + assert backend.transport.config.completion_kwargs == { + "max_tokens": 4_096, + "temperature": 0.0, + } + prompt_image = backend.image_preprocessor(Image.new("RGBA", (32, 16), color=(255, 255, 255, 255))) + assert prompt_image.size == (32, 16) + assert prompt_image.mode == "RGB" + + @pytest.mark.asyncio async def test_openai_compatible_backend_uses_deepseek_ocr_2_prompt_and_postprocessing( monkeypatch: pytest.MonkeyPatch, @@ -1205,6 +1232,95 @@ async def _fake_complete_text( assert prompt_image.mode == "RGB" +@pytest.mark.asyncio +async def test_openai_compatible_backend_uses_qianfan_prompt_and_markdown_postprocessing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured: dict[str, object] = {} + + def _fake_prepare_messages_from_conversation( + self: LiteLLMTransport, + conversation: list[dict[str, object]], + ) -> list[dict[str, object]]: + captured["conversation"] = conversation + captured["completion_kwargs"] = dict(self.config.completion_kwargs) + return [{"role": "user", "content": [{"type": "text", "text": "prompt"}]}] + + async def _fake_complete_text( + _transport: LiteLLMTransport, + *, + model: str, + messages: list[dict[str, object]], + timeout_seconds: int = 600, + output_json: bool = False, + allow_empty: bool = False, + ) -> str: + captured["model"] = model + captured["messages"] = messages + captured["timeout_seconds"] = timeout_seconds + captured["output_json"] = output_json + captured["allow_empty"] = allow_empty + captured["completion_kwargs"] = dict(_transport.config.completion_kwargs) + return ( + f"{QIANFAN_OCR_OCR_PROMPT}\n" + "assistant:\n" + "```markdown\n" + "# Ledger\n\n" + "" + "
YearValue
190042
\n\n" + "Paragraph with [note](https://example.test).\n" + "```\n" + "<|im_end|>" + ) + + monkeypatch.setattr( + LiteLLMTransport, + "prepare_messages_from_conversation", + _fake_prepare_messages_from_conversation, + ) + monkeypatch.setattr(LiteLLMTransport, "complete_text", _fake_complete_text) + + backend = cast( + "OpenAICompatibleOCRBackend", + build_ocr_backend( + OCRBackendSpec( + provider="openai-compatible", + model=QIANFAN_OCR_MODEL_ID, + transport=LiteLLMTransportConfig(api_base="http://127.0.0.1:8000/v1"), + ) + ), + ) + result = await backend.ocr( + DocumentPage.from_image(Image.new("RGBA", (32, 16), color=(255, 255, 255, 255))) + ) + + assert result.text == "Ledger\n\nYear | Value\n1900 | 42\n\nParagraph with note." + assert result.metadata == { + "raw_markdown": ( + "# Ledger\n\n" + "
YearValue
190042
\n\n" + "Paragraph with [note](https://example.test)." + ), + } + assert captured["model"] == f"openai/{QIANFAN_OCR_MODEL_ID}" + assert captured["messages"] == [{"role": "user", "content": [{"type": "text", "text": "prompt"}]}] + assert captured["timeout_seconds"] == 600 + assert captured["output_json"] is False + assert captured["allow_empty"] is True + assert captured["completion_kwargs"] == { + "max_tokens": 4_096, + "temperature": 0.0, + } + conversation = cast("list[dict[str, object]]", captured["conversation"]) + assert conversation[0]["role"] == "user" + user_content = cast("list[dict[str, object]]", conversation[0]["content"]) + assert user_content[0]["type"] == "image" + assert user_content[1] == {"type": "text", "text": QIANFAN_OCR_OCR_PROMPT} + prompt_image = cast("Image.Image", user_content[0]["image"]) + assert prompt_image.size == (32, 16) + assert prompt_image.mode == "RGB" + + @pytest.mark.asyncio async def test_openai_compatible_backend_uses_olmocr_prompt_and_plain_text_postprocessing( monkeypatch: pytest.MonkeyPatch, diff --git a/tests/test_tooling_benchmark.py b/tests/test_tooling_benchmark.py index 5b33e73..586f6c4 100644 --- a/tests/test_tooling_benchmark.py +++ b/tests/test_tooling_benchmark.py @@ -8,7 +8,11 @@ from PIL import Image from churro_ocr.ocr import OCRResult -from churro_ocr.providers.hf import GlmOCROCRBackend, HuggingFaceVisionOCRBackend +from churro_ocr.providers.hf import ( + GlmOCROCRBackend, + HuggingFaceVisionOCRBackend, + QianfanOCROCRBackend, +) from churro_ocr.providers.specs import DEFAULT_OCR_MAX_TOKENS from churro_ocr.templates import ( CHURRO_3B_XML_TEMPLATE, @@ -21,6 +25,7 @@ MINERU2_5_2509_1_2B_OCR_TEMPLATE, NANONETS_OCR2_3B_OCR_TEMPLATE, PADDLEOCR_VL_1_5_OCR_TEMPLATE, + QIANFAN_OCR_OCR_TEMPLATE, ) from tooling.benchmarking import benchmark @@ -555,6 +560,55 @@ def test_build_ocr_backend_uses_nanonets_ocr2_3b_preset_for_openai_compatible() } +def test_build_ocr_backend_uses_qianfan_ocr_preset_for_hf() -> None: + backend = cast( + "HuggingFaceVisionOCRBackend", + benchmark._build_ocr_backend( + benchmark.BenchmarkOptions( + backend="hf", + dataset_split="dev", + model="baidu/Qianfan-OCR", + ) + ), + ) + + assert isinstance(backend, QianfanOCROCRBackend) + assert backend.model_name == "Qianfan-OCR" + assert backend.template == QIANFAN_OCR_OCR_TEMPLATE + assert backend.trust_remote_code is True + assert backend.processor_kwargs == {} + assert backend.model_kwargs == { + "device_map": "auto", + "torch_dtype": "auto", + } + assert backend.generation_kwargs == { + "max_new_tokens": 4_096, + "do_sample": False, + } + + +def test_build_ocr_backend_uses_qianfan_ocr_preset_for_openai_compatible() -> None: + backend = cast( + "LiteLLMVisionOCRBackend", + benchmark._build_ocr_backend( + benchmark.BenchmarkOptions( + backend="openai-compatible", + dataset_split="dev", + model="baidu/Qianfan-OCR", + base_url="http://127.0.0.1:8000/v1", + ) + ), + ) + + assert backend.provider_name == "openai-compatible" + assert backend.model_name == "Qianfan-OCR" + assert backend.template == QIANFAN_OCR_OCR_TEMPLATE + assert backend.transport.config.completion_kwargs == { + "max_tokens": 4_096, + "temperature": 0.0, + } + + def test_build_ocr_backend_uses_glm_ocr_preset_for_hf() -> None: backend = cast( "HuggingFaceVisionOCRBackend", From 51a5c3d79aabf679bc88b266133f20cfa624ec91 Mon Sep 17 00:00:00 2001 From: Sina Date: Tue, 14 Apr 2026 22:25:57 +0000 Subject: [PATCH 54/58] docs(leaderboard): add Qianfan OCR benchmark result --- benchmark_results.json | 54 ++++++++++++++++++++++++++ docs/_static/img/qianfan-ocr-logo.png | Bin 0 -> 36796 bytes 2 files changed, 54 insertions(+) create mode 100644 docs/_static/img/qianfan-ocr-logo.png diff --git a/benchmark_results.json b/benchmark_results.json index 69aef40..83b0506 100644 --- a/benchmark_results.json +++ b/benchmark_results.json @@ -3130,5 +3130,59 @@ "Khmer": 1.4 } } + }, + { + "modelName": "Qianfan OCR", + "modelId": "baidu/Qianfan-OCR", + "modelUrl": "https://huggingface.co/baidu/Qianfan-OCR", + "iconPath": "_static/img/qianfan-ocr-logo.png", + "printed": 65.0, + "handwritten": 35.3, + "total": 49.0, + "main_language_and_type_metrics": { + "print": { + "Czech": 85.7, + "German": 59.4, + "Latin": 76.8, + "French": 81.3, + "English": 83.7, + "Hindi": 71.1, + "Sanskrit": 68.4, + "Swedish": 65.4, + "Chinese": 6.4, + "Dutch": 75.0, + "Finnish": 28.7, + "Polish": 64.0, + "Spanish": 88.5, + "Slovenian": 92.4, + "Bulgarian": 87.0, + "Japanese": 67.6, + "Bangla": 20.5, + "Romanian": 49.0 + }, + "handwriting": { + "Spanish": 37.7, + "Dutch": 40.5, + "French": 43.4, + "Latin": 38.5, + "Hebrew": 7.1, + "Japanese": 10.3, + "Italian": 69.0, + "German": 32.0, + "Greek": 16.3, + "Swedish": 27.2, + "Arabic": 40.2, + "Chinese": 72.1, + "English": 55.7, + "Catalan": 56.4, + "Portuguese": 17.3, + "Sanskrit": 3.6, + "Turkish": 19.0, + "Persian": 18.1, + "Vietnamese": 73.0, + "Norwegian": 58.1, + "Khmer": 6.0 + } + } } ] diff --git a/docs/_static/img/qianfan-ocr-logo.png b/docs/_static/img/qianfan-ocr-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..42c6e98467e12904df5bb43024127b61218c84f9 GIT binary patch literal 36796 zcmV({K+?a7P)VF-tUj@84R$y0Pe7Rz@GK}eEJTvGt*O5 zU9Vn#k8q2F!85!oxc_DM1LOcm_IU}jZ)x_rwNymxQfL?=NflNjumXex$_`!$LVyxL z3XpgN#y}llZHNoQ7a;^d84Q6@2Y}&GfPnGL(~kip*B_$3(0OP0hKC*klp(}WFhBx= zYs@dS5ug_3i5Ov6fU*ITf;ff3L)1f60cEHvlmxJVf}vh8HZlrUfb;c}{FhBW?x&x9 z2A~-R`U2;-SYQ`=051xZQQuNRK%H0sLDkieN*mJ3dRS9{>S=(+005*6*8B-;_FXlK z*cD;@2E>j4AA?P~PJY>?oNXGCnTg{DfE3xL27@3_2$ZrJl6D}j^?^A=RJs7@Xg(hS zi$c{yvu|Yo*LHZf0!TaI*??piDtj91Q3nHHo#|kD>EIv3<;nN&w|td4r?$r_6PzsfE$Gg_XC^}5Nikk5Xv^{ z%>*Go*@ZzQ$hLld`V){QtC3E;87c(R_75l)M_@%@oP+TmfFdDidw6e+%Md z5S}b-bO7T1H%zcMpIb&8Bn*;&A&{2{>fFOZAlYfF1xUIQDN2A1ktADicz=P`uIklV z%K(G~Ml4tXXaa^n!0-$RDQa#IhmVwqJz;I{7WK@iaR_h)1v&>qV>xl$T<(!UK&(*l z02Se-6DeBZsDxCufoL<19XmpsZ`-1kSYX8=@`4~Anj@bP2V11+0KD6RqlZf5*ka@0 z7L8>a#0MY=5uk#QPr*_dAd_?Bmo{GV1j5RV2dM(s?FUP}Hh}+qU|D`s7EYw|9rGT;Ho9S)4q+p2sHI zuw>H?3Bprg`!SSE8Qv!dLvz#SRVTfgk5MRw&^STryWn&)CS(A6j|dIk`AUw{ald+V zkB--Nz@Q&zhmO;oJ9n7Tkx{OeCrBv8id8H9-lulUiWOUgkz(TjYn%#>ojAd+whEoS zcSw}1Knso`Ed3=o8iun5YI$b;<0XKXJb_GZB?m|eF71T+LA>VSf5UqZKCL4oeIAsM zh;K8Iy*+iSA5=0s4;eej_O1(=2EHGht??FDbs`vH#$=J`Plzu4+gVQY?+ea$C zOf7A9*powF_Qmcy)rJb$NX|f86O>o$RSHAikym$YjdgW5PU$?52 z%a_l!#}mu9DVJW=xpeKWvsa?+2EAQWk|Ilji&yB}>eVZc6WN0*@);Xco`iA7)^F%d z4xRAS(RHUi@!Ysy-O;65ID=ZkNc0>6Q@!c)TWGw*2}Hnf0DzPk;yRF4cH!VYGHX{1 zV|8DPcwD+|?b@zyj>x_ps=LCFCGk~OQLrH>M@SQgh;0NXOIo7N+mz{@(5kPd$umor zF8#f(uFj90yLkBA!0H{r@x2A#w`x#YT0R78_T2Ot#&HZu1|gO2MK#%g{imPcuH~b& zaqTLJtlV~aX!PyIw!Ep>5^jU2SQ9d{0c(g_tqKYPD14tSgnguVIl+D}R7uEvATZ_= zgBv&g^OegN&fdObO>l5e)eC=%*k~;Z=0jk)`SV+7ykrO@v)};In34c!N9+dSwH=-; zyjz8RdkSW7@SFUhqvw{g;ddKOevob0omOfpWPFm;$QD?XJb^8ofWn{vRaJmCM6_+0%1pI&|hqTDRs-4M_k!0C4$*n>*_w%(cEx0C5D* zov0>jaO^~p*Yw{bCr@2oT4eifiN}7hwb<4@QLF02*n|&5CMHr+k7^PF%w&k*yr_eR zc&~v8wU#7Y41;e|mUlw^Q#~D}-@9<(>|Yk!OX-DC;_mLPQowz1i2`~Az}QPx)Vk%T zwPVL^4gZ6J;cN?H=MlhzX)7%ME5zwm44t@0ahhOoTfc(bD+T-;fZXQc34jCGu!4mI z%mh)NTfnY1Y34eO0-_}9h$$qti7I{2aOvGHc9)JFI6#A&Cp2jNAcAl&B=#^Mcqo{% zOGNP1uRMPJ$q{hp{da>EFp`;cO=wZ8)xf5JSb&y}PE@K}aA5b{)YtdTDna!@Q1ZJ* zWd|!*V4bQOB4Utah6eydQ}rXFd{Ww=#LV2s6hdv4B>ZfgxbFkW*%QZ(QUFH(#)^&& z|27K6!|*I%&U`7P9wOQ6{reBrSM7yAP#|}VSwIO%fA6L_Ddis0OA3d{F(zU^-^ zV|p7>=>d^}YR5@r#VGne z3|YboPZC-dYcPL@o)uLisvc?w2!)S>+;?smEJ_UCP{Br zLR{2%Y3LxD2SN}4@lbRiNd_@dT~D4T=@BcF0e9 z%n1-AF?qv$yQlK|Z5Y1gnP)DX?CHGA=y=svDhW&^U`k<_ui_3f4MB*Z(vC`PCDL>= z9JZrp=`IVi*+GN1LHWJv1PoGDGL!u8Mr=+YkQf0ZilTz|$?uESWMluPKR3|c?_%qy2ZmxfR_LVnFMECgd(KE7_HVQY75l+b5Oqg3&j54#&Im!hH(T+ za<6*!-4}KNQ#PPlkhT~|DK%W$Wqjd}+Tx|1$=MeF?313=Y=%%PlnG)qyJnga0SS2| ztnf-mleI`{-->GT4)pZiX--`_>8N0LiJ{-}@C8yYR+s`M7$^*R5G#ljL^S1^4pnW| zKbppnM;~wn;0mWM77=QJ^6!VH_)Tqv4nKUUjVH=)MrGnHaLHQ0GvEOdx#c(5tv>$j z0MGwcX@i%Ys3x1hHXyYl*syiQ_m$f8J;pi^YxBo*$ULs*do;aL3ILn1+J}p1`-vdw z#P$uZ6kq89q!kU_iDqAZC2r_L&=Bz5IDb0kvu7`O6HrTf~n z3uDJ?6u1IdLa^A`1O5~UyoHf$!x_GW14sTBtywuFoL)xA=BI_-%RNg9wU)(#u1Os8 zc+ARxT0p|=%I;+Vb^taQWBz1x_!M^(E^AA09zBs^D>jVOFShl1S)=r?UCF(B(g&$*OWzBTSt7D^@MZx)^ zDN;Fu6@n(tedCThcK(#3QH+j{nZ$2E&G#ck3xsZU^0?)dbV2))PI$Kk0FTm7de4C&2njRtFn7oGG1N0S z=0_lt-=4PFHRBLzKya$>THe3$?`pNxdyWrn;`JLIknXm>M{95p2M-*bC1YoC59Ito~wZ z0$FJc-s<86B((w!I2jy)T;?J}uY4}1yTbS|O99TUMGJ+@5+^1XF&}A*I(DsH*Y+~SB{&ca}2L&{K+1xpPTWR5-|CRR9*u!!4c1vvcNQ9LE}U~9PWbJ7J*HnOjr3k766mhBxVu@v%SZdaOKASK_9lSAWS#L zPVx1ZP^qlKox9$^Kr5`J9RT{W{P@Y}+}1={P{=oPRWUfQ8PelOAL0Tu-xqJUjQfxQk!J+^PJVn zk_6VAd$@b~QwLV9-mk;QiwI~CX>NkfI660Ki5)+9U{Tl3y2Uw&MUZwNNC8Prw|DYD zZu-oM`YZxZZe6_NOi)jY#rBSrd3h}mRJcfSItZU^hDv+(sXVF4X!idT$Ij4mdk#6a z1vtSL&0Ukt_g*NFGPqIxCyyQ=0l4-6n=61Bfp7xH*>li5*r!nvX%Rf1*>H;!2mm;w z4lpKISBP3V0P!G4;5=G)5j+IpNuk2S!>J$iHpGFa$9=W@4phqTMC#UMiAD+-b+GC{ zYEY>%Hui5-$HjJpg;ky%yh+Fz2fqj`Sk@H~rUe9&RhEP@y!5;(soh(s#rL2_ok&a; z;0vG;ibHxc0x54iZgCMy(6_c%y#n6(T}mEcp{WbM@Ml0hjta^I2n@JCOv$WXd#^wJ zgi;M1YV=EB;w;!uv(s4#aG}ryLj#tT@bM2YU5P(1gCXZVZcBDy+ zR^c`66;o)d27oUhD@9CKxTkXeo;nUc|V?2_%7uMsf1=agK_*MSLT# zaM2+1bxS?`oN@)cJ8~Qb-U7s#LP3*+t;BE)c=lMesuj>NM9gj01f5V8fQc4$&IL2n z-mcmT*bSs+1Ck`Hzt`X#w?f9@Elwb1cykDM+}Fm+!^_!7b|Pv0VJy6iJ4KPEdTu$E zrfkEy#VXU490-|D!4tz70i%Y>$>Y@H6gIXcP#aPz`IJIZ%4_p3@FuHUq%!N2fYpM; zf`KLA3WSfN5)1)R6-iQ?Bn~8CsseB`|G)y{7AFt~;SpRKKFT7s-b}>eMa4m;p{^g5 zgc=UTx^O$Doa@N`qA}_NS)*c zbutmLs8#{x5>oOo?DA7OQ{tgmSSuaLHGCKiLSG{6z@3b)!V+|)UFejR00mhA3Qj-@ z>_2$;#UJncHvm|Sn8K_lqm=4}+)(fJkr7I66GB6OApkI0-UqmD zHClX_9GCaRs98eF7M|*QN0vqDvuJ>9UW@HaYr-u~AdTg{73VRBZQ%e@2J;v!3IKuz zC-R)-dz-~uIfN7;Qf)zkZYT<-yaBc_-beAGSDtb{whgL?UL~dkh;D zwgL?uBk;T!iKIS@vq9R4D_;w=X2* zsUO`Bro5`(qMcSoAZjl`S`&uA1_xlny&xWI`dB`GDd4HE9so%<6om@FqX6e~v{($@ zbYYnij0x2JBRIw?2p!Po0rClqp+Q~S&07YfDw~c%dq62= zT?tL*Gst3n7K&h|$Az&PHb~RLQRBN3V=ltQf1oaY9F_4iFc^pC2XOYSm1rsTDU|~d z6)(l8o(w5i#|ou7%UKj>!DL>FkN`{yOM5lF43fw6!0x9J1fN1!JOd{dDw;3&O9#I?@=cn&tIAW4!e8vz1C9)|s*-++sK ze6wH`4(z>)%ENDhq-(w=sz{6=wgOKUqKiPrRhwU|P&RQ=1#x;%IK~^lG*fxQ50>$-)we=8oE2mk_X8vM6xjj zT3kK}9)k(W3b=;dXVMQ+Cz24O!BQlxeL}VsKGEGu!5QIzl}WGF=i@~M3506^XGG3o zm5c>Z=_8(7@a(>Ea%n3d1rVZ1Mcv4-pAAEruu&3h>hINtVe4AWYfytR=WycLkBiVay1Lt+0{D1CWTS;vas|Q; zl11ezz{Y!=^Z|C41HcukaO4}GodjdD{lwzlf{S}}v%;da(t<-@zMwt<5|yVsV~-dO z#CWfqENtP1$y^V$AqOHdN<{4U#x#Xv1<2UH80V3*6Fmp8^qz_$ygb=6h1{f0c8lO9jI_>1a}WS60q8+i zMX-K)l$t&}nFlc`lZODx08SyZ@LX#%lo82ZZ0+AJYqoTmL&xKz2KXRIevu%$C|ri( z!9-%1OoDV@?8=`H!wUB7dDL&&lmeKT;mc)|vTR_08Wtj1jf`;J{a)rkmUU+>kgb8djywVgp$M` zZHw&pL3xdHsRRqykC+vJS6IuWA|NYIjk_zA&pxbGp*6-t zrQ4r8?Rn{n>ZjV;gL6^X(&kc+=E(U4giDzNqw<0MPkr^DHf+8FqsLUb2RGo3P4}WD zF!iDpP=pXM5GAlR2&_P5Ig;{iaJm`^5ssf2CzXvz;v$FvbqXSb{(*HWG>j-LBB{0_ zijITuCunIsm^m!{Wc%(qQGW5uU!B*)=FLH-hDR_=V4yxinzjLGK~igh<$mnnZ+Y|J z9sKpjpVIy0QxjBbjg< z??+&=`56$LDg+*6+L6ZHfNuk%0GBMo@e@mE$Ie%nPkrHY(!b@k;#?on)S#sW@E(9> z`{T`T@8$pbn}k)jS=M#ByDR+NAPWC$lDLH<5M_XwJf&WB{1@TRn6*8w1PrKJwd-@C0;Qb23@tugrUWYVUh7&`7%Y!>^ z|A+~SZvuFsHj$8F_Q}ZK?tkV3Z`r(Ktp?#97<&?iuYjq7$G`fu8KuCx@4FjHS*mI+ zz6<*gznQwaUZyLSR9C2uJq9*?9@=c>crl@ITBA}SeRohh^*;~n`#5je6^XH@5YQE< zPQVEu;t&LY>Karg?#7WL1*-aP^sT)^L({bxCU~c@$-@M950pay3UUMzsAwJ@ZvnO0 z{vdZ5lzf%pK5mshHBsY(&Mvbj4j%QreM_{p_#9fp51~{zgfD#YbMu>2G>9{qVQcr{d(DH}i`xmSsSEMiJXv$WaF>s;rBC6K(4 z6>qpQGWPw&)|J1ss(0I`4jvq(qHV>R^;?v%8S2In@F>_^2HUeBBnXS^apd$6Z5p^s zROnp*ZvwC#FaAj@06tVl6!R`0!rE{OA4Qo%T!6|r5T5@K&O|Ew_jD%RxxlvD_ zc3h(9TzhB7&#KnmN9;uPT0P)zDnv*)Y777JxvzZuy~CG(j9&FkXT_M$!rC)=F}Voh z69mChq|$?0yaCTV$K1d971Gw-*;%Q%?}O?O1IY%V1ghSX+BBPo5>zRVK~N~yV}BLO z-2lG<;Mcp<9cP0-CmR2)mFs$kPaPhiVpPQ7z@R3sg3KCLdBKyM=c%&_)!JJzQGG2= zpF3?S)6jstEmAbAppyO0mQHhv%;`i!(szg4)WAe z2e4X%-ylilZ#r7b@7+AeUA=2#Jvns9)M~dOsoe{aB`}&*FAVH46x%b{j%dePLFhU>W2C zy>Qyn_;AOcr7rpd0G8-R)zF#M`KZX&kpcPS_}G;nTfg(Qy1aKUs%|H|wr9i6TUk}?U>j;~ zGftlp?%lY~x3tUpsHOV8Fi3wYFx-_@0fYdsfEYk{P6`tV0c-(Knu@mp6^*IBD=d~j z(9#*NTD4}C-@N%%#{1il*6xCjcR+k6m@4q}G=ks>Y}_#6`_`~c4IZy?;T@j%Y&H(0 z0Mvl4nM=`Jeka4VH%#MSr^FNqGR7eyBo^SGtBm6P=Z>7WmC*wl7DlrmL)0s{&%P9# z=X{VNE8f7nv3^}molhu9fFzb*_Rc@9gaAxQCbKq&k_$Mm6hHl%X?}p@@l#XYuM>x5 z#iv>EuUT>F*q*1&#{PD7dKLyrUPwX!DIiW)!0T2F4!p(mt$T%Q?O6Htuu%Mh3f(2D zBB}zwP*!`ji3488%T7Ys$pQ;7$Eo+urzl1=z*>DQjHD(M|6ddq2a;OsjSAOlH3|z+qa;6PMP&hGdEi25 z6gOkBjd2QPRYd^6_%PcHd0xT-9 zJ_M9;^z4ZlkBoJFz1e!Hh1YgeWjSi`y-3F23GY_%+5r_?s_d+~+WVs@*k)YfLTfG9 zz&Q^?n){6`vW{&G6>a2haKrqV#YIgfKLM(~(LsvE3X#zu2uzaHK91CVzmxd%p+kF2 zYsZ*xZ{LT4IgA#221tj&Hbl*>LFdx9qjTvi%<0qcsT6;dtouy>LA|P1vkofFfs8pC zf7So(D$}hXS7a3c2SETY{x`=)%RjSuV9+wL<1?)O z95@Ima#4UG(h2WwgYyr-%U$T{xyvkHvD~SS?-Fr;ZG&hdNE}Fr6hzG(%9}I&8k1}~ zCF=PN*RWj+frP+9kgyQlX`}E1sh71!jtt9{OChTtM4H}>G~N#H+hC{+8yrFqK8srX zs2@N3Wfrb}PMF?0UM+u-vMxIkhM}(qY_JL#u1TbszBH3bL0Qq7d=pV#!UpgHCT44h z=)a{d{dZyD5%MW5m_r)aqcG+i2uVHseYT~~kVgb(hM+N+XfJ}|V=$#J!xWyClcygg z(eY93n2gX;$d8HQo2hOJm;W`#)-_v@tmZ=9=~ zKD6(P+|ho;x3?WbA$$^Hv=822Mye6gbS1nWz}~}#kDe5t2);{E{T+q`oN>9ULS^cE zWO`Q2l>Kq_-hdF35VH_#>cxxFPqenT{pQH950-S@C#O$$QtFmMxgE|Mc&~8cAu_v@ zb;hT}0E~n%zL1@G$$E^Bz7^@nJ5YVbvpIgrw^P^`1i>E#Ht6-vJItc^lg_L7Aty=V zRrRhg4F6A(UhMAZJ|?Fx?BVgTm!VdD3w+vF->@jq87%RChwTFgW&J?Oj3m|FHga!v ze*EWBl@YPl_`I3AQBk9!3~6k7&2t?Z+yhw|9*aZ+5e-CC*ufj89@YrLa_aHj!&k7F1!ie#}a}RH$-;ScM!w34}zIpq^O0 z8ZZ}>#IiX5v?S>}F9bKh3HWPW;B~yWRV2RkjTq$YG|Wb z*LRN?Ywu>m|04)2h)8Ch@w`A@gaM>B2!5nIG4dnvnNP=FN9hlY(d1J zIoPOXJ;oRYOeWhTt{XnyhBHUnXw&MgV&gP)N%AX1bXy$9X`xWK=}~BG*r;e?jeXc! zeUB|vp>VizrH3k&^$=~%H@JtPakSZ^D9G1w=GZ^@<;%(j>G0+Ljf21HrR8qtO1~!H zYR)w>4J*?r6kwNM9$+u94zLc&4gg4)vJ+1fY9U(sQX zJaQ_lWDbTP;}}Ac(^vPVjLDXEc((&dx`Eo;&q_}ZzaGF3AosY7D)>bR9}}%dvqFN< zbO7|*DoY-ks4S&g#j7<5N~IB~8=LiBvk(Xa-k^+SxD-8vYH>4aK}@D_StFxA?>)Dp zZjE^H!H(c1Fx!g-{9AkF^e=XGjwrYS1Qt#lY#6{B2T{PP1LHDEEl&YBiS64%6@3IT%2~Hk^t1gh_!;26`cl$Piki{t|1@C(hZ`V;Pd>$TIXRNxa>xnT^ zEt4%+S+TskJB-GEG0nKfLPavL_ku7CU*6Ku{qI-kz5ip~@)h>nnSXW&J_}=KN;SwZ zI|osP_aP><8!a6hO>y0rFNOX!lBN&Yg>S`Sgn(Ky@gJ9TM}ILgtcQ>8zDyf#?^UJ? zFvbCJSq&xt#1&XN3!)1MjfYQ{w)1)`6u8+UE~F^BLZ__2t~r-cPl$ zRyzLd5s%hd(pie)y-z>GHfS+hcd}_Kq$*Kao_X|Bx_Z}6zkX*3Q{9c$;sq!+CS8Wp6L*@-*;9Lk%s9WPcbS@u z#%L~YRR2YWpdCOjfDlWTlpvgBma}*#s-usf5bmI~a1wrOm^d8Nz??zQ_IGN7gRps7 z9wh-$1s6|014aeXG=j!>2zdE7y#jG{E7J0}q8e|81cE|)!B!?lo(=-DEthu|ELfD) zf{2_NZ@o+ZvpD&cWlL7t3ui95FxZFZ_Pz_i-s|3C7Rqjg@=heR4Y+^DH<}~QKIg3M zeneCs$`@*(qDY&O2e9!YL9q1K*R6Qy@b1Sy#t+;_LcA(}U21!T`5fPmQ{ zH9*3J%=8)TgZOUs!6{|(khB1=mr#hF2HOKDIJYZ*lvS(r{vB6MX$y%$ee-=_8OnyV z#@q%RJ_;J#YBWtRetS>|x4G1TVQ69Vx&^9CDgqXK(I(KRG{E6e1HxFzPpmoM? z&q5%*D3{-c1BXH)J0`trR>6e)Mo}pVaSFQmR*9J)06cM4UXXZs1iZN@^c%0c=ezjS zl`%oM1;AHuy%R#gNIBC5JWHlbKn%pUy9sRBcFs z(42>9`#X3kA~54nOE9(rpeh1$CTl&-lIs+c)Rt)d+I9&-zX9s+ z&lr7{%DV713>i@N3O0Z|a_;Q8-y$u#qbE~ZyJ@v%`OepeC*&bxnZJP{5FUYzo&l@C zPn1DSFj2NtMg*G2M+q<5<~5UO1UfQ=M___cL?+9&OMLbZJVWuK;R>F8YYiT*81+=&zaMAP#vrehWY zxq`I)%UH8(8@aJ@gw%ZxV75W6Q-TGupn-+jBn%)g3c>_|XWZ7cq4(8^Nk6vz?iLf* z_B!DDNizb0H%aa^npA+Tx=AY$aTaQmhb3eI*p$a!E%f2=JU}iBjwEULEdm$>j7{ca0j<|%p$x!yo8>T)%fr_#T$ZK-Ccx+7 zhS4ngH8lcH4CAxkn@vVvC`^fht@mdq3uqz#=Ohn(sn1}N5R3qU<~b{vNlGfCRy>5h z+jlZ0kyn|x-7_|7zD0-xkOf|!sm7746{-ndQ*-#@+BF+=_ko$eKFiok2&#QBaS^Us z=u(ee$RrwGEPXgQClc1YK|0f2sn>oJMyMvBjD^~UQa#BlCSykH!IRfJE$h9&vj;yJ z#XGn0<%>MM7VV7R=WATE{=yVY&Em+4TL4)WqoUs9a58eYZmMUkuY=5?_lSdyCcwcM zD^0*edKXC6X15hu=-wp&rms5zfFiwn==6C~9>?`_=(6>il?NK)OiByF^_Cc#C%8bv z`~kSD!F`rwCGfP)X}p{YBYR)}jtA-U|F{$_rWb}%!1%1^9|(cZ7Hw$g z#AG5EFf}efr~Mz8+>3c=Y+vuHoWQ0Mj!>o;53K74>Ty^EP>@w|Pka`)CetsQB&I|wqfr0ZLHZl+qIH~7qC<=uF(nMSb;9s4fD}SDJKv_c*`_?n=}(oe@m|wsv=Ju-05Ta$-UK1QqlBc^1v2gM0G!Kw z?Ma-1DMA>uAc=MIUgY4_K@r2%JVY|fkRpbOMiHl#{A{Xokz8C71*={sVzr4drq3-O z|KxvX$NqyyY0dgA?$YH-uwq5;y1Z@~QEm_ry;fU?7ZFpRLp~DM3LaY}gu|zfN7TFS zHg)MylnOP7o0=ms3xNo1popsgBi$s@6v|HtGBeu3idl^kmXtXs;y6WUOLreTH1y}K z{T+T{tVFi7Qp5HtydQ^BfXU4?OEm2qq-QtfxIpGR$vKJuNV%SY=#aWjxa4JkZbX6z zM-CsO+UNx`Q5Pc93rGnfx$l((Fr%nd4x+Qi=$hVc4NPu(3NFISV+ey3K8>^NOi^TM z{&mhOjRsUV^AJl4MAZP=y|ibQOJ%^)-LSFbR1Rc#)O%Gp>cG$ zODIUCF&y6x}&gFK20l02sssVcwn%K0xXv)z}~3w0_f3V{8AoX=Cq)$G?8^EQTs{sqYRwb*_it zmtplP(iQd-VA+3FK;w-@t1zl!0GNo4fu*EBjZF;t*wnrfj?lsHa_$k-?<_55k1Lr*;`orPfl1x%?R-{d%)KNx`ou-?KOV(_HJigOR+LibR-2I?d7LW0LW zyB8?M5R+#+3Z|^ltX3@XWk4oS2)a>C(%ewO*;ng5=UzlTWI9PUEcUrsk#et`k`f^W zLt#*_L6li~NDYj-#{FgrIlTR@T>uy?7Dj;rF=AIkjXk_>MQibUux}sehu{4Q`iFlw z=$YF?0pBUy^4~!2A&3`Zu#26di6Wpr%t%giLr>kJCi&~^IR++#PdzL%02Sv`K^78b zRduDM@F&U1_jOeGFI!CEx2TT}e(rtmC)hT7`&|#YJ0D;GLooTzdNFwkof;Ux6JLGm zc^R6fK4}YZ8$?T4R0T5c*FKv5uW%rzlbf#DBb||hsVmpBEMffwBqpfqKpd}vXe+$& zs3q%g_#k-m=2w`%{`+HT7@0dnYX1q~cV={&eHD{zecF2G`rO1lh zbU4ygs6Pv~-s0|W+=9m+y#N-Qy-|-q&h_nCMXWSc6rz%P_z`02Y*=|I4+NZEfb2zu z09@slW&uzfJ$!xnaisfl_W^X23ZW7Moi)+=ySra|L zRQQ|CJ0AYQ_O83MmJHy^#nt3$Ti~mA!^c}8z7v^_IiD>oNNo+y)}ZXFtbie6R_G#D z((5?J%CJ;|O#@i6Ak$=kp{!uvEUR+~rH-#)aNB8tJHMZN7Q^d|AT>a2pO2~ZW`F@H zWLK_`2Q~l`T@){gh(l}9AY=zfnuTf93EU)5PO^>+*S|YLF1G zo#!?u?#jo4z`RK$QIVR05?k-bcWo7Ku0e=%m^+YLhmot`eGd$t#WnZHJB*B3EuU>&7t(>%n*dVZCY-3C`&o5R3~7B*@(>pJl(=}8NLSc zI&Gq&`-B7(dhUbg}raZe5)Oio@I}j&Z;QVHsyEJT<_m;G6 ziN2P?@~1(`Ed5jDnB=KolXtdeq`?$Fbx0nU1BaTV3x>2Sr`16lO{YuHr(AN6K;X_l3@4&(gkB_8&Q5)V~Rp zv2OveY93>0#hJZDjV3n2rmdqSdftFQ%F1d%Vi!e9L=)5o7C{hsuz9T> zIQA88UwK)J#RGs`LKsfAFldN4S1FRsnG_|Yt`CVDz_H`))V1Ue-`Y`nz3SMXs`?%x zIY09!z$zquMO8V9+SxYi&NQtzxd?+T@smpe^9XbZLW)$3OIZd=) zxo`l6k4XatymyzKPfiN~3MdQ1E{eS|6(Ryc@YtynBQ7gXdkB%)1IFl8pJkmj+`R^4 z<~RAjVt)@FW|42w8)eEOc}sbr71|%q&&`u(5&~F721r1h0GhHu~rv9|!0P2!v~O zgneMwJph2r<|MET_%uamA~@d%&#%O(BPAZ#yiSNJw}H|>hxiT_%?grnGa+0{>AlgL z^G4BZ#iia5y&?*We_ARQzN57ztezhFGTnRkJ<3$b7@+z_l%a3{CE#mc`7*9= z?W$qZ&XU@&YW#S$OwdD)JcLQC3P`iM&I=yyKDkfSfGCaWV{2CJlC*XNM-QC_@zLy~ zpeE0z%;ks>tlYFp4N11C9 z2O~QrpiKoEtbuSFMr0jEu@g^zt-^hM4+tCTb?(Za5G9=i5dzG$pZkqc!fzC~`44Y{ z=*=ST&)HT{9v`8{9zRTmH$n31FHDq6iw=r(7Eb7(eiV|9F{uZU*_U}S#pZ1k7T(G^ zIsUw=lS{1$KL!Dg9lk=1q76zQz53bBs-%wvt=RO+*Hc*%2*qFZoQyhlfzo0N^v_c1 zRCSM}=A^0g;N*cZ0ZP}b5MPI<%q?w%5qOKZmSP+$amu_J2Tu(W+BfNvB}+o_{)4S; zZFgW{+b+)s1i-{`tZewrBL3g+xc$}gnpgk6sl^XKeK&|TPwetg#FM_R>w?kZ3)r%H zxdd7Q)IOuCXN814i@HT{K1kk4OERwp5$RU~OBVq^G0z4%Kn*W`Ei)2vrpjh!Qi1@!9ci)yNqiTQbSrj!^V_I* z{dOH49sM~XzTZ2SXC>XbC>4uJL}Y{DXOkp&_`qRtU0n~`IKCT6yamp8 zxC){PfD(AW0=0>6MKbypZ0^`>kA3B7X|;GGfPZHULqwE`(0sj+F;BT(WH|3xRh_DK zn$Y}GOK0nfLnqZ8JMa+K#=i|BYd}O^*k!TlLeU` zTsi0X($OK;+tI$<3VsS%0oT0s@k;{rZol_Cbw8@M6>TebiG&;Bv<0NOg>qH`u7K?^ zfREwu;e$SP7Nq=Pf?OtmjAp&M77}?i8lNlbe`<``y?l9S**xnD#RCA3WuLo_7(VL* zPg9S`IBe{N?%LkPJ9pftAp3vfG>Nm8wF{_=Og45j&`_cFAK57GdvFzRzU{cSc7GAT zx%|V;=G*%BfrKL@93eFT>=jV(RV1~)RpwEs(2oLm0D0@<7j0fI4lDpA_5Z;XEBBRK zj$_}&&y%;+Jg=S`#qzET60mUyY=pkvU2^1bg<0&=R^8_VadRj*e*_E%1B*#-$J9)8A%>T3B*{+1 z>1LcbdzLot*rMIbmkv7N|JaC;f9Zh$C=rFm;=N=h?2^8fTGiF4$vQY~$CS#5&83k5 z)K}1u!uw$u9zxMvP*w({^s7*tE|?7~-z!QCC==8IQu}xGu4DQ+)`Ih83+WeKg z3~L=S?V7`>=+gv@VK{mkY8a#sXajnx|ram*YgOwXbx9s*t45@ z`vH(lHvlR9kFk+UNm?7>jT;B0eC3NUnqbrN%>V>xP=+@SimX&nJpv#CL#ZDV)wiLN zZa_^A;>b|K{hL~UD-4UFq!R+n7xXJJWvuICDTEgR zKLPa~S0{93uUQ4~t&rLvB)$vI--hEuMP9Y$F6~`^$Gg}RUz;Wg7D3L>O@siHwZ68V zf7-g%T^5_%iNhtX)wY0xZSZ;5U;qHsBe=6Gi(as>s3EckVeJu=%ViQ*go^pKB#EyO zve1T`yNq(JRBB}fQ;IWMp&tp*fdGL9d=7y*1%t|*O0Ud3Y6S?AUk=6|N2%*?5Ox0< zY}ePYclSqrM@{Yb2!RD<`48XL`=1`aGW^WgFyd*rh{NQ1ELBhYGd&1;5o^PG}EN+;t5F63a9-zc*ygnfsNAMN!^i= z|BoeI%946=ZgJF?y@|-@#~|kz?!guc-fev=oc7VvPY+XCyB99m-pC(mu2IYlA%ycZ zfDzbW4_b>SaC!I)KlAjHzoSZjK$fv`xu*;>R|D9sVq5o{jKfnTSP|u!u>2eTO25A9ILf*g-oU-6&Y+%2# zvTxZx4V}E##ABgHupQL?S5W(GP7*m`D8nO#l7}OJgHz}%{WT)JL)m{bs&Nm}H2rZ3 z%WW#=JB$fE5s|7kt?divB1Ad0Dg#`eWI+r42+U zp?n^y27sRvwa!W)LI6>2#vuU*ArPl1av6QgSIE)*C#Y}T`mvVM(ti~vt6Uk0 z0r{T1__+tJ69xS|AU+Q;1jLxoGjmX_R!6Ok{xehC@5Pm2yJj7`u)y&AD57u-a0Vio z?o{`l&Gd9md4?KNE1C1 zg)M;>^|So`Y)g<|P5=>*3y9Mr;`!S&>3Q$T1E(pdJmy-=qo8mEW8Nbs1CJ2EWaFE_ z*PFp)1fTjfs7|~IwTW+`jT`Rsfpzz=q`$@D^Ce+&bd9>ZFELPF!`H~9sHGNSTf_USAn9v35QPK`*U(dy1!4#ko<^bjBWUgVq^bu-FSfdPqWHbu z;lC04agZ}mE09p<=?YL}B9#IXfZP*aYi}enzjt_Vg<4CiowY+?dkCdMnyV#}XO&t& zvwboXp7-cP9IPFKvClx|ODGi&V8_-r9XdWvhYyzdz=5;>!-;+U*jV|0hhZ4!ztLP1 zT>)rTf1Deyk$J;a0QGr^+FY#6%AJ!-0ePQG-6Pf{A3SyPQQof;qj8aRM z+aNw0re zjrq}}vDCwJHHn2pzqzGk{->HUJPCX0B}vx<(uQWe!!zPOTVrd^rEnmmR z^<_PF#tshw9cq_Y~J^5rxN4`4#}0%inC4y24gidjl^pu z$iW2zI8}KWhMpE)Gw_YFvTH@DJTdYCkb4BvqI4%4%FJ@Y%-|xCJf&Az8X`%(?vkj_ zMZbmRPIV(hbMAOfE}~)>Qu#`-%vt(JxdVT}>^cSMYZ1p~c3A6NVk*w%;mYV_?Xr7Xg z^;dyWh$xs9qIya-{q1sf;=`xU4jsqHLEQE7`^@EYPdegbD1>LR_ql%rXl!!Ab)9#b z)A@RspQn?6EjzYDlI^ICy%n|EdY~9%cx;@-b?B0%ZQ9m;5uW0W-t@eo5OzNjB3z#& zz6}(VoTfoQ_417Dm5_@1Dp<#YAlye%{v6BYzp9K^hmy1h)*4*6^bD_AeNjsM6`0@z z#?vyCBom>8Ve;}bpae=lYsb>8u$fE zdZ1!`<6oz`PjlrL@_Ajxd;`V(h*+3-FI#`hmVduBvgE3BJO3z z*wqX~q$of+P*vv4#pa2Enuo2V2+D@SuUOE>(^~wAN_lkH(`DRw-!lH^Py8wRmKVh6 zaTHn(Bd}NS)Durloz--s&@7tzJf99@%WbzoTnB=r56o@w#-UPMj|*oXqOK)v+OzCq zNcjr(JONs^>svMOVO!A_`dD~?m^xIbKnev()rgF6njDY9;(>~{vBxoZ{U?M$JB}Z^ zkCrZ7s-@D05d=>lpi2k}4r3E}s$||0uKu710#88ek`8#^i==!HjvQ^F6@xD~t?#^ti?0}VS?l)c4o@BQxlqlE9&FO{^s0bCs0-Ryur;PCJ?Bo2r+x5 zGf?|)82HrjLx;@Z=Bkg{{|44PhmIhGk-F)ZQLj!)!v2XXDX_I<>1Tyacyh2z+D&sJAH-f;x zlLs(??vC{eCnyAm@bqJUPf_2uV&(GGJoD8H<&`To?J3yu9ul8-8U=`IuwYoU!#Kf- zBVXW3{3swF)or&wpvzZmQuP-Qm;(rdAy_g{r%fWxWLQeQ{h3NDoZE&p-Gn6Fit^YM zauszg0lwcdzb9Xw8HnTBsGFtN|lG`YlHU?A|48HYRsu~h&SJoMTD{?aGD#8gw+ zzG+aS;4<2m4{L!AA~eS!#$<<>k5MQT=Dh~<<{-On+XZnB!~!xvA`a@i;N3bv8SUL8 z5KiFGj#{z{`<}appeyMuZyumCrw>xV%VBMVT5SkxRuAYW{{Fab>~9we{|d#nXHcme z0Ko9B7XX9#Gz^V4WcOMv(>3Zcv<*X~X#(dw2)Dz_ci_m80F2(C#HtXoS+6f7}Rfw0((C%%3f2M$>{ z?QD4U7b65fl5se1Rg4iFvQ+riIF6qjIx|G;H;n1Zp3fo*4?t3vl~u~kz8Pm_oE;#^EdWOr7vgbT0`%;m zFX_&;BT~XSAh(>23=hMaaacVAW5(fgiQVLWuP2`Q`pmohW^xdQKqZThQsd#_!Nd?A zg$?&)Vh?qIAqY^0MF+O_-=W^edU)SQpl%F-U74Le<}ywkJAnSdEdcL_DI93BPLNxI*lor6OG`=2G6ogAa`}5m@%u&*WeEa?Ux;m~ z&Ow+e_`&aAisj3)gkNKTW{F92kG)FPT3H1k3;f|97P0@}9;kFIY|)E3Ah+`vg7XCQ zr+)OqLDcoul`HOlYTy1Zn9|+be34UFIRhgj2-t(5`9{tDuR<_HAQ}S)7cv5p5DZ2b zoIt@BfMhSWZd|WM5ofqi`|-;`vrKyt!axa(*s0 z83m_N)V{t}OFt6Yv2R(waqzh_Bb`Avb*A0L&m+PFjPrmh5|df*YQ`gAf)x(~LBb9K zM%DtCk@y5rVF;kVgb#`+ScPa6B~}P|I2A;pMJqfEDk$hE2nnnJK-SflSm7BEqAc1& z843qsnfC!SZyltrXo@JD6Bur4&6eB6fy^8#JchuIz*+{S2~-F{Sj% zQB4yNQ={rgUDU~{hYP%Bc6ZN{_g85LVJ*^hJBBay(ZJS+?KwJ^icP=EJNcbbp%^(= zZNxWZ-t~FS9ze!9R5f$hOtWTDzDCvp>VY%?tjYSUEcP%jZg?fKIpjW94`4l+>W9)e z`PQgo!_#5dX_MrP6E}vo@B)a+XvAkx&O#xLwV8aT8B)1|Vrdkr3UMjm9bf`jGGGFn z2s#2ly)@c5G{%S~CtK6f*K0`67xN~N=bn4!y6;^}xR-0Zcg>09lU>uhwm~3DfN+xQ z8`&M!uU`uTLcv5xtJ`q-@*^ z%B3G3t5rXDcsKaYUH6FS=Rtfqa|JVF;_^tI`>(mykoA&e&2-0S_dboe1$)SpS67WS zALCTd{QODerh2KSdywTi?_Cp9e5Oqiq>9D`0OA#7yD(B)g>g54gGU02Wf@kjUndm0 zpR~lkYt^){W_JC_%)}ev*XFCndF@Y0mc7%=To$Nk!}EpEG%lMRDh4E;A)8AD`vIon ztG8@>t?WHOT)`%cyFpA;Z$}bufk-=u6pkj#gx9;sKEL(Pqam)kn(i{?zBk73vFM+W zl0eykkX=Rvfn>Zsa)9-WT5So2{X=;2SP%P_6utdm4r{;7q`v`@qGnk1bG^l21ng1` zR&ZGObIDzlz;uhF6jetc= zSxgd$mcZBu@3x{+8?eiI?~}fPwQZ*^?E5ng(;xHjs-jse*T3Lqz*=UnUX}(Se^9ja zeJhr~OwjTQKC)Lip>>E|Z`R}#1t{v=XmJxoGZy{Esdp_>&Z0$qZaVcWNYIIq%02Mu z%Y!iOaoxq%Zi4v>nBdzz)T>bt6K8dvXo>}DG2j+xGBYEr3QQ84&^z@W?Uq~r@XE>Z zuibXXJ?P!qChG>5nmAsCYPBC~y5X4t3IK|r6o4r6l`Ll5Ql)G}+TmRvL^|ME;N2>m zI^DxP%T}fZ8{Q?A`1jfJ-5~Z*n8lI}D443I)ODx@uUmTqkhiF#+>qh}D*WWcXtgaY zq(6P+$f4nQyo;mu?UGblK->bhm%w!WT4jq3i=IFx)oDiR`jC#j9Z9;a-UNp?ZdfD6 z@V5Z;J78Yp6%NWa5CLgsL%MYs`mF`2=T+pdNWnD`35u;h=uz1o8vYSmSodsFi%r1| zc#lKSwl711qX-)QtHu3v7M#B|7516(y7BK>Iwd6SgHH$H(n0uSGsWpvG zy4sGFmr$DQfKN9eMe7Xh*{1A(1;qk#kStW>fqU-?A)d|5#YEzcoM&mjn^U`bzCYl)kH1?BGyz^KdY z=^}YW@c^mzpc38(^gL)>G=Y`vljpfIL|WX5r881s8xh4_VF> zyb{XETGVt0xNw_2cZL0yJ9o53#q{^Bkw1tEK`S5v5(|J-X^|4gf-mbLLRN4A+4*1r zctGw2S3U(Edt1+nP>B>h%XZm`>NcdweQ?QkcoZ+^+=*|6YGQownNtRU4M>^sv> z2Tl%}@}-jNVrh3SY6aZxsWo0SefEWa7N~p>re*f>Q(hF_y_pIEZ2@aE1g^VIP}m-*7j?K;>$sGHVRQEJ%_ z=VKUyED#Tw-%JSDzi0pRTj1x3n@%8vEE&6=GhQeak-7-r6-Z?e-fzO5y`Hyhd%c`H zJ5=ae68)axi06c5+7_afDo15(9pJA1}4lkgraFT$LAW0?=Mv9OM>>W2b=`AKLPz7Na#T>E7dpso{dU?+5el(rFlb706?Hgo_`RaTpmYYM$x+>z?Ok_JaF$O z+;dke`udRdk0Gf4Ff;~BZSb&A%n~u%2+6VeNuMH4N733jfpzOTVCnjsLy|xWvxO0TfmA4mNMf5CX#{vZ7si42>Q*TeHUd(cdJH087+lcX5B(ot>5H@J8mYK zya+l-HhfY5+K$ApftM{v{dR2M@oKa0;9l>+-G)o=3&OV7JI8tc1{0o_qbQeAm9r)} zRD(eU&|U>+nb8m*armlRkfI`iltNa1e$SA<#n;Hd8E&sSS0^luKR+N zp`vi&KtxE=GE@O30ULn%>OrY@AW;M+01+Swz>*>`fI5Mnt-WIXbXUoBDFWcYHijp_ zYF)p%29#k85Lf^LsE#4)0#@hhbLFOjrY{h){{@ejVjunDE5G@T=3^iIYgxPE9!=6V z5DkIz&a^KEZYqIf9&!WA3?qWLwhXa*6Jptn(?dtO=AZNJ?d?6r@}EHM>%BOi2@9B1 zz^}uir-zGE9zvd#AdL~Hm$9l>@B=@17hd=JrRZ6b5e8`{#+rJ6-X^JN%6@Ntw$pxp z@^gB%6>W|VuErhf#VzVDnP7~CixtKMAOmPl7}Fz@v+@N7L3W%V^+0F1YlvDu`FWa&5=1se8lS^!Ub79~^S#Tlt{(_O0NS*76;Np2RL9JG z&^2%17b;2R#H(}2U!U@|>G|N-xi^s6kQljNhyo}$D+)2!%0l&x3VRK`Ze|$CMO4jg z7CF~x6ab&oT{4RqQGs&5i2``fz_u+oy#Mrnv4u4{5f@ORe#EYfFuaVUdS%XIe4)6> z1Y#i?L0ms3#@~Q6?!$?bU*~n3`^C1l7Ky_@fV$U1;Q%ptW8$WVg_->v%8>kY4Fq5= zBh?dl_kVf>kGug0i&X#WR2=9 z^?pU30b*(N61cvpmyz|z0H*S$oIo_=4zq1=$_YQy0W%A&b70esI9-La7cFfZ z7?dR|N_HYPzpb!uMJn~m>e;hz5XbZfmVtu@*{ZY8#1Lf zu(=3;ta)O9Yc<}g$AVpN8{9Z1F4K4Tjli^f)}3@hb4=+~JIN)l%dKZxgXRHnRs;|^ zanH*Acv9JG>f=(sF)PT6hr<2B>UCXbyxIJ}Yt`kZN@Kp_M^K^wnV+P5jho*XkI6g< zgtNL;d3#_$rj`N)qts%NxG|JEFJWRR<*04FFPG)lAoxDOgOze~N2Ux=pqw!XCt)ENdO_RkQWJwt-4=d+q-&SrPlt%w&kt%oNjfF_F<)q z(T1aqd?ISxC)?VGkL<^Mh~$QdSbB|(A^=hWj(&^O`sJ2K+ndIc0^fTm4x%7)>l(|0;2nT;@>2VSUqr1Aah zD?!cfw|apKnu~bagn9+~Il4@)iRP|c^^sDplgy|`O>-dTcWeHOK_XCekn;Oli|vn$ z4D)k#*=?peEa~C zdtM`$E!3uAPY@EYaRYRasZo{y;H3je zx(%8P(7=FI@mIE~(9dT*-`N77$>x>M$5YPmPGKXDa`gm$^t-hAcR%y=8#-EU7j*zIA9_Hp56`Y+wCznEO1HwJx@l3xV0GLp%0-yZc7(Vzn&*H>skMpN%NNUlfSg(s#w_Myo%HzV@XoAVQ)S{cRC z9*Z}AQy*HIyu4J;U!LYT^U%EMO4huF7`QwPyyutp;mcncLM<)8m=2VRt57O5`9hkx zS`!DUw|h>K6m4yDObN}2yB_tCk@548k(+I&HyZVMQogCw=a*(alsWvS<|36|)9Lbj zP2!Vl!kA1gkbLz>LPP+80ak1bT6=!Dr6u}}9i7X~@bGC@Xk7x>WuP!bWugWg#k>xv z8=`|yF2_3*5c`!N+iT*=gpWdbrD*uz%)W)07Lcd>&2TUjWBJkoc5Z1_#;*?p%5)RT z;)SkU0siZMc^04k)CCk;24PGVaG0h%C*6Va&FZ~CoSk{`n>QFKxN#cH^<3i@iThk@ zJU2TGaBgVqoO)c>6EJ`gm?)P69@=)!1 z4I?WSrL>4*Tw-4-z);%{0o;x(9BTdsKX$OTg8T2=nkm|IJitvJhvgjb%fI*>KK;p4 zD3w-ZJ{9J?g|R}s%zjCB`TYO?f&~K$0ZHm_7Y<%69N?i>{t#atdmF0Bw<3;L!l%i+ z511R{+4Aj>cneV7j+SJlF%w~GQQQg*@0{B?KRYr{t%D6~*tyeYY<&)sPh5)F0Z9ye z^rKaL<;!PLEcF9YLYn4md)A<#IpA7jnKe}9rH8up>qTIE1RxC~Yiwj+8;i%8ixckP zDWS1s1(@3rnsC;e%!YZ3SfqeY*B}{x8`5+WG^%o--iy}<9J>urmMn369)mOnPJq-^ zQHVydeFu;_Ac|)Go~nnj8HtXJ1Aq0wr$Mv|&bL8W5Ljt!-?-w+O>rho~*3>p?RQuL+qP-J{3bV%00RRY!as~L*XMmF@JyNv7=YfedtE2K?Fjp+l zI&u)$gjiPsZmah-tY5zhA_C_G)?OW3@w|Uvz9TeDNazaUt|M5n1H67)p8|HP4fqwt z2r%7u_k0J~@RKl<9ojpatW|S>f>4NnC!aWrkuk~|+q3*XDgL|^kyJgy0fzLItzox4 z@?_%A9RRTukV=gCKI3^4Nb0gd2np6~=tJrq0Q53ZUy=#=^I)`1@leP_re(`Yh;q?# z_Ng-r0@P~2nX{vCJ`YOF!}FW7-flXkgv!o!+2!V#cCW&<#xwV=A$T#wscRz$0%jPH z=*xUfx(X7)j~77*;3b{+{pU>}-YMb<3wLQ5KL2-zG_4Xub$}5fvc*Oc-wPg~Soj2u zVhnBVMM$oo&o+1hK?JyX9+)UQ6oYzMpPYNlfr~V^ot}QxQFmz8o7dN)foJr@KcBSL zdK~-BKz(diMJPXiG*_CbuSIh>=(VpG4h8`6%$c`jGn47G9*Ee&5Frc-^SXcIH|Bc_ zqa~apU+uC&|l7sSqG`?2fQ_8a8+qLZfhtAy_eSfR`YzgaN3!4v*U1nQm_R#)t@$ zCvjZ!&_?Z(IkycW>WNW`xoP?f4V`QL?EhP+Cwegn#mq4FF+B|m$!`cwU3br+*t_tVVP=%Ie@819=I1fEgQ2>l9m#gqTi=_2#4l^g9Py`Ca zyg0yha@`jNt`hlPEKm9^4$V4~DZ8~nFagjo6ftD3p5z%Ye=8OUhGVEb4UR5A)fWlT z8n2`tN-BEoq@y_|ABigJnRki$EGxn3SQe#Ab12IEVqvZ# z=vDc!i(u@F0(lh_wSenc_2g$Qo7#gQ=|UJTxF1B|d;(@*Vq}tnomx$oQ<{XT0wRcpc(+zL?kaAH0Ol>zXk$8zO?*JZ~Bh} zw*fv0;rwFYAZ%c{6)i1W)K!-QQYzeN(WSWv!RSa8)rqY65YNHGLX>^(!H2rhy;K0M z1EOr_Qm{2!sNR28$yWD~@9%wT4rsyYa?6opDpl zsd-!Iu?fNX3wen39By3?RNQ$xu=BPhFjR(_BAZT%dnyE-W`>!`Ds%Hs>$yFiW|Q;t z(dC;fuQ%p$i#brUN+wyrtWo&ZmUi`Oz-mD=u(k$MI0LdL;d#P)Xu~f+ zOsH4>yXG|uhzQIIVh?45lv?rB?o7Ozn*l28pHPZ`AO8M3(NXYV@i1aR2p~Qa!@N>G zE;;)S5SY^t8G%Xy8!&=^VXesuH=C>TLYm!L zT6YHCkcu5Q2u*qPogF~mnjWan^N?pX0yJ}s$^YLHJVZ01hFT~wge2`*-J#yp;3>|F z1T1i<$~|LkQYZjxfNyyR@Z5ZWLqYMg^8Ez>$PtV4IV7ZYr*??ljFl&j?(#8d$Zmh@>)#AU{Q> zc=~B7aW_ij)WdF+QLjpPE3|(UFdSu^KQ%HuR6eQ`(J+`}7@BM4?(1XTjzVUJkpc>( zHL9)^2(1Ie0WkSid%@?dfWZv{%zOe-(1A;r&f)W4j`8Y;BNXaAK4uytCP_62fOov3 z1wEY)<9C1OD>#1kAb>WImj3_t&NRr5>$>m1bMEc#H*YqqBtQbhMi3-HOQa;*S}ZBD zsgfhBEZM1w{UND*aaBGzu`8)^DsiRac&SQdt;nvj9j78ERgP`Rt7M0wBukV=whu{O~m7U#;G$M9((dI4}SFS!3!MDhrZOqR6|v$Sl$iz;RoUV_q?AkKeE7= z9)6nrdruO&1uAu(rt~W%UAoaLvYNn-Q+QV;W92!^7)+zw8&w&!%0qZvFIlQGY(M&A zId9JV0H68aU!~ibL~U$|FI2m(s@Nuj6oA~HCzThllB#UT+f^i0P!m%V^ODETYwvns zEUCVT_X~8wmFg>An=DH~6tIvWI_OQ1iJ)%0t1^2kMRtV{&99`HHYKG*qq&~HdHM}r z+H*JWxsR(1qQ%XiShZZ~BTUw;fq(c9#`)xrf0$Dz;NbqM7U0zoEd7eMP9qcJRWTm3 zD=met)V9Sy=&(5Nn4O)c+XW`;>tE8Yi-;v8;ZexoAN`Z{eDv@CG_SnK6ywHA^Nipqx| zT?6L!3L=Y}#r}G~j3rsob24t%iq-XGaJ40oH=uXcB}tvTvll-7D2zLXRc~P zCa`8LeDtGNzF*&V+0gGA>w!^U@9isfvDUgccuX6fq4N{$+h_8Q?_01edt=#mSl0VY z0OAOAxNS#aM^7J=ZkQ=U3a_XNzHrOOQ;yUNNu(4hOBio%;-N1-!@hkGBE-wMl+wt* z7I|71os5YL!)jOXc*F3@=;1~W=c>=0V^I3+@}0PZ8}kwiaJe2*oXeQK(4y6Wm>8i% zz4Uvd*GLHxJ#yb0Oc6^RO289vBpg(#x`2K@~in9FOZe9hN*ig5ppdkV!r$wT$$q zk!#E37>=#zVC``8@<9O1EUB1A;*oB*J)25MWg?o;>AvL&Q;A-$OSA>@>@?kQ%$$o@ zNU!yJ4-BiKk#$6WoNqjSm|kxH_y<4-vE%+HY3RRxvlXd*fLA|8&l|O|SC{^z^43Uy z0!~jQ4Kq%`tOb%56@3}9{4KPLIEfUcvHu%SS~Ch+kk>qoZ#Ns$6=-n<#gY+gl%n&K zWR1J{+#kKnAN-Ng4i$m54V0rltQHp(OD=nsI^If=Nxtg3u=CP40i^hIH$y!jE}>UZXvh zLRj%vCn|X7QKxiEqqH%iyN&<)=`Zkyf7~Kir6h~usS4yQfy;TTmEa<&C_k$QFmgI! zn&1EI0aPGG9Wg$`o9pjUv4nac2eJtdnn6e zn9X5ypA4~_r5c$lYDESdR+0$TBP694Zs*g#{s@2Y*~EN{#jL@;*wD|uN9dZBx~D$lroGK-$>E7MMVU78-+$v_e*1UMaQ-}W zyAUI!vPS;i(0G39P)Q;gPMWmaV|@BIzQzkLfMylM$f6*F9oP4!sA0sCGPJ)Gw0&Z7 zZO?Do(oIFbro07x1wVXOR#V`EABRuwK<;Kkbjg^YL9j(bC ztI18fLsEj**qU>B=gBZOLpiaJ#;QL?^FJh;JdT^tWrJpyH*kkC*wnz$!Dp{?VE-dg z$cVk>UtoTQWv9+BAwwWSOd&N8R^jvxUV5p^zxl<7`PEf` z^IgefccWs9k$88LZQ2lw$dVZ;m5*BpU!0y=?_T=ipG4-90Ms>1>5s$ zStuv@?4MrX;V(bQd*8Q6|9HoG_xJ!=YHWYc;EZ(<0n74jUW0!SUnBhl8Wg?t7{mcBD%1g zYbYhWq+BflOMrG8no|%g;Z#Xc2K&UTOaV*(^)fZ%U z!?p*leeTaNKSyu2e;uy8*SyN}tq4(+S_Kt%GE44eNO3LQgjtv(MfufMyZvCJ(Qqn4 zh|7l{k4QbBMxp0QA_r@Lu3@1Q_wyjT^_+!5 z&XKJkbykHWuW%4SDvy0xg0T+a^=q8K`x{b#oH9=1^GUw`=o>u!)Db@Lo(=5Yy`F75 z*061dvS|~juh?c(D=QO+0?OALueh3m!~pqNg=P?)Mr3?2=vJDosGp$LPkA=dv0gd< ze=?op`6oUfZ(Va5PYWTT*0;~2K+x-zm1RmB$0rE~6+KqAKP zoYP(sLXU*e9KQvht>wVM8TRZwOqL}krV7@qYcM&HG2R@4jwu~v}tSzhJM*QNgpE9|92fgw4CPHT0|m$_4TuS%G zNCX;Vit{;{P9qf8@dB&!%Ce*=3SulV6LQx;L`bPeNL_kmiE3mtNDSPM$mvr^b#j=Dtks4h?8e z`pH%I+^1vO&jzQrI zh~UUTAjSUQ9ZniFYd%^VKy!wdXxA@GDM|l&gDk|s=XHcTRe{;dAUR)+A~d7l4>sy;|HN>+Y(cX7*>>Wra|OG!r0yK0{4aPaYJ>stl$i+LP_;H84O zfgM%5(g4=cHeG@hON-tvQ>?${9t3`Fe2H;23z9_9G-NIMbV|eRUP*2Oh)&g)q5N-I zmVGOf^Q@WbSfjWA5{RNmz3;J$6!%8uh6m{YI5+6T#hI?pnVKTJEp+iS! zRQ2DP*?qtUM)7wmE|<2eeyMgtkOh?f z@0Fq~a$T)Vzg@d0s=nW#e8LLOeKiw0u)i1L&lH8ddH2oieB5i?iV{x!HBKZ!iFt>ZlsneUpRGQ4^z%?JGaaXnPXraulhjo5asUBQZ&Y`m`uYarCbCq|OhL?y8U2kB0LNuats`I^W`uRlT)rpB&cl6+AVx}*! zckefV&dB$d=hz*U6{(ZH^rsyf#YtkEq-^h{$ktgSU(bbeyz#l2 z*wH84Sk{RXWBVwwlMKi;1{Q2(N6d=v0WLP~^Q{)5M;V{`Q?_n?+D;!kDRefo=h>Hk zB@lmE$Q+hO%*RV-k?(Hh_&d2484l*vOQVWOCW3mOH_nMR|LOef)Mpy5;0N!Yi4)l) zsGOl2Z~pkW$)2P{<^+)os6UI>#GPBW+ws?45*f>EYHH1|wdPt~6@Doh84w{=v;#66 zAp1M{o)Sr+&IUVD_UvcMUgxW4PVLv}trwD}=gIQJbW@LIP_A9G+|;PH0!|DiAg)BR zS7|hUpUqnyHNt5=_}I%FJu%;XGE_tTT9SINM@&R4#bl~# z-ns8WeDbJ>l@(~y)Ev%I3YKN&qs@L52|rSH`0D0c3%}#eXVO^n3y3?0S-`8}Zh&F` zI-sV5^KY6Y7T2#LQem}>pd+_huDe)HG=+{VYoER@BUR2m;|EwsN##Zf^`_K^54 z8UU8eELn;n#uQVqWSE)QVzlyF(zLE*GDSor&ofaG2{HDsXH;&`J{j$IO`oM0v1C!z zn|HZDvR>Ky)l8>9p~j)(N582v=e`yS|1^z!AI{H%Dk{37z|y7UrU&byK*HmruOJr! znLmg3U*oQ=@3zBxH>OV5?6z#5cs@z@Q$7#>0!cqh$~RRuG%bdT-+nSsVi8H=WRTZe zBbQBtRx;}XEnopS2^<361m=NLz*(y2i}mLls0Mr!SPe`A>wt}+3>(bSMiCu%-sNUC z#bVNZUt)dqC(5@&hW*82KvXRdaXv+jX5ky6_OE*#_r&o-ZE?J&_w0H-jW=IL?Ho>w z*q_tCLrd?Qt{|W_KBn?;bYl;fEs*OGNDX%Ec&D|?c06(NTN11tw%_%R|Cp@tIfZ|Q z=|5J|dMdJy64UC1OOlJrFki6+mkAnH-@DulG~`1ci3F$me>!a0e6?p95Jd#DcpUR5 z6aJ);y(sfn4WcML?Q!_sC?uK1f@UbRV!8*b1yoGBTmWghnMPpml%fwe;O3vINXV7yD@B(*b5{@jiWP z=P>m}(TJgBf9(0+4l(>z)bY;i$Bu{>=W6mDoK>{; zRSf&hCwR{Xei};?_>>6g7G62LMmKHwkZ321v1cDF8t!90%RY%@_l4@qs+OV<>RD06 z1cENY-mCCq{d@BDLtaXPX2YO5>RDfsB@-}DikK2i)G#$s5e3DIM>V6}+?Tc1EkB%RjId2P*ac0UyXba6aMXNohcfN1Ey!zaJqdNw!gw8?G%!u0{O(R;? zr5-%yT39?FWCdQg5Y)5%{kO=W=U+^DJ6R(pZogwArWMZ1B&{lnvHMMBS1*-!C*gjt z>=x(O`MfEy>>{Dpr|L{euwEHOokP7yN{Q6p`Cb&Cs5x*6+K(S4`g~;oJ~4tm4pOn- zyQJpD(DnS$S*CuD}OA{6he^m{Z2vSg`NsV;}oD{muXKdAWV#ozZ2Eh;7P? z@du;HPde{DqLwBY`NSR}c*j?;YmFs=#nO9f^M$H9e$0d$iY0 zGp`*yB0v0|ce#_VoeXoqGcj?L$>wJnYwY2#zy9bTrTB93@tbW&-tu+nhd)>;h)JDO zE^CP8E)KqYi`;kLC)`udJ)d^my(@J>1a;#+%hsr+2c4ylfxb85w3|ylESqa*9;k!}HIb)^~mAC*+McyY9r^WAV0k z?2M^6i$-Ijx1L>hY;vz2Jv51wg^R^T;LhaZR@CmXWhO;@uB6r?7ZLNi8 zLc9G{dHLnT?qB|^pSLf3{^UX#Y(aGPcQg0e?}9c&@7iT))%{7hGiumXX7f`@Sk(x+ zN~1~=B`VU&v>lc6kfMt^+w3+^IAZ~wKdtnRBE2&rtv4Et)`{&~H!nQ&mw%BqZ+XC! zjl?iXHwZB)WBDrHx%eyv&$%rp?L3x;zr2CIO^W4W@b?(OEJ7S{5b_ zV~rP5vZs$6dG@q(ud;dTCQE568kwJE)ofxcITI9^& ztjD?8dBnK^*?^hUaO~=NXXj?B&H4ZaKZM97k<~fCW`-A6L9PUn1d_nU&08Tg&@@i6 zCYB}$@*c2FODLz57orcV_%fm-Hn<`w>Zy+AIPQIXmjsvn^@y= zdU2k1-r%{jrzjd=dLB2Y9C_g-D;6TH?zdUJwr#!g6mzpr@!k*LZeC}QRONBR>@M!U zYqxo4l%YhUq3#gQA4c3>@@%1o(d(e%s!M&#sEBizO63nu)gH)6#c+yX=sXhV$;^Y~c!B78l@eM(v;x;W$d2uIn%4&4>83V~v4i+*A6d2^ z6^~0E=l1jJq2ppMC)ymX&Z|;P2f7Vo-Mx3p_}X>2c4YT$x6*8ND1D%pD+_*%6X#Aa zH+P<6-#%_d>vps|Vd5KX+c9f}-%p4oJMP$R5?^7=_LHXrsCPsGAygZ2ON&nFA)MCD zk9b^O-ts-j4}JJUgRDBOQ+LHG#W567A%kB9hp6$A+r!>I^=yMc1lWWhsbL#O_+0uBDqGX@+`FOlQcmqd1wZ?vW;ly^WBj zP?sYb@xDirKs%LCWEHNojEqHrZWr-_Z{%Dkd!QawkHb-ff=)XS6{4MIB7cJrN)p?V zWO$B}=Ut-I&!zQqTZtwe^v@%YedCJC@weN8)Gxf6eXE(Vg7wyeh{VD|hw*VBpCxw% zWvHswIxloOEyf$OIHzQ>dXz#+V$aAL1t|rxB7g*3X-IH*mm?xXUseMt1iTuNK#Yph zoGLyYz@H;?3nUT7n*{(dl#DeUz1YJ{`k?ut+3q6vH;I@MPO4>AauxTkCPp#=M*w>g z7O!U&{6C>p070uQv_>6?mj|gi!s`BXRas^g_f*14)T2i)2|~uI%6dF-M_7 z>0oeBP{{JXI5fB(2@hRiC_7mZLBKADce%VRgf!ZvX%Q07*qoM6N<$g2{Or A-2eap literal 0 HcmV?d00001 From 93be7c6360062db339ea733699e376a4ec2e5c9b Mon Sep 17 00:00:00 2001 From: Sina Date: Wed, 15 Apr 2026 02:16:02 +0000 Subject: [PATCH 55/58] docs(benchmark): add Step 3 VL benchmark results --- benchmark_results.json | 56 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/benchmark_results.json b/benchmark_results.json index 83b0506..21ac10a 100644 --- a/benchmark_results.json +++ b/benchmark_results.json @@ -1782,7 +1782,7 @@ } }, { - "modelName": "PaddleOCR-VL 1.5", + "modelName": "PaddleOCR VL 1.5", "modelId": "PaddlePaddle/PaddleOCR-VL-1.5", "modelUrl": "https://huggingface.co/PaddlePaddle/PaddleOCR-VL-1.5", "iconPath": "_static/img/paddleocr-logo.png", @@ -3184,5 +3184,59 @@ "Khmer": 6.0 } } + }, + { + "modelName": "Step 3 VL", + "modelId": "stepfun-ai/Step3-VL-10B", + "modelUrl": "https://huggingface.co/stepfun-ai/Step3-VL-10B", + "iconPath": null, + "printed": 55.9, + "handwritten": 38.6, + "total": 46.6, + "main_language_and_type_metrics": { + "print": { + "Czech": 81.8, + "German": 54.2, + "Latin": 76.1, + "French": 76.5, + "English": 76.9, + "Hindi": 52.7, + "Sanskrit": 39.7, + "Swedish": 57.8, + "Chinese": 3.1, + "Dutch": 59.5, + "Finnish": 18.2, + "Polish": 55.7, + "Spanish": 79.5, + "Slovenian": 87.7, + "Bulgarian": 68.2, + "Japanese": 30.7, + "Bangla": 42.2, + "Romanian": 46.1 + }, + "handwriting": { + "Spanish": 45.0, + "Dutch": 54.9, + "French": 55.4, + "Latin": 45.7, + "Hebrew": 6.2, + "Japanese": 9.3, + "Italian": 66.0, + "German": 43.6, + "Greek": 23.0, + "Swedish": 56.8, + "Arabic": 32.4, + "Chinese": 29.5, + "English": 68.0, + "Catalan": 58.2, + "Portuguese": 32.1, + "Sanskrit": 6.5, + "Turkish": 20.0, + "Persian": 26.4, + "Vietnamese": 43.5, + "Norwegian": 78.3, + "Khmer": 9.9 + } + } } ] From 7586317fd4b0f3039fc3cbc0adca6057aa2e7683 Mon Sep 17 00:00:00 2001 From: Sina Date: Wed, 15 Apr 2026 02:43:22 +0000 Subject: [PATCH 56/58] docs(benchmark): add icon for Step 3 VL model --- benchmark_results.json | 2 +- docs/_static/img/stepfun-logo.svg | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 docs/_static/img/stepfun-logo.svg diff --git a/benchmark_results.json b/benchmark_results.json index 21ac10a..584b72f 100644 --- a/benchmark_results.json +++ b/benchmark_results.json @@ -3189,7 +3189,7 @@ "modelName": "Step 3 VL", "modelId": "stepfun-ai/Step3-VL-10B", "modelUrl": "https://huggingface.co/stepfun-ai/Step3-VL-10B", - "iconPath": null, + "iconPath": "_static/img/stepfun-logo.svg", "printed": 55.9, "handwritten": 38.6, "total": 46.6, diff --git a/docs/_static/img/stepfun-logo.svg b/docs/_static/img/stepfun-logo.svg new file mode 100644 index 0000000..7098952 --- /dev/null +++ b/docs/_static/img/stepfun-logo.svg @@ -0,0 +1 @@ +Stepfun \ No newline at end of file From 926096fd4f3c0ddd306dfb2eba0ffd2a154ab728 Mon Sep 17 00:00:00 2001 From: Sina Date: Wed, 15 Apr 2026 04:24:13 +0000 Subject: [PATCH 57/58] docs(benchmark): add Gemma 4 (E2B) model results --- benchmark_results.json | 54 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/benchmark_results.json b/benchmark_results.json index 584b72f..352b2c9 100644 --- a/benchmark_results.json +++ b/benchmark_results.json @@ -3238,5 +3238,59 @@ "Khmer": 9.9 } } + }, + { + "modelName": "Gemma 4 (E2B)", + "modelId": "google/gemma-4-E2B-it", + "modelUrl": "https://huggingface.co/google/gemma-4-E2B-it", + "iconPath": "_static/img/gemma-icon.svg", + "printed": 45.2, + "handwritten": 24.8, + "total": 34.2, + "main_language_and_type_metrics": { + "print": { + "Czech": 62.3, + "German": 38.1, + "Latin": 64.7, + "French": 56.5, + "English": 70.9, + "Hindi": 67.1, + "Sanskrit": 46.7, + "Swedish": 35.7, + "Chinese": 2.2, + "Dutch": 30.4, + "Finnish": 14.4, + "Polish": 46.4, + "Spanish": 57.1, + "Slovenian": 84.9, + "Bulgarian": 44.4, + "Japanese": 3.5, + "Bangla": 45.0, + "Romanian": 42.8 + }, + "handwriting": { + "Spanish": 31.8, + "Dutch": 28.6, + "French": 35.8, + "Latin": 25.4, + "Hebrew": 2.3, + "Japanese": 0.6, + "Italian": 38.1, + "German": 26.7, + "Greek": 16.6, + "Swedish": 9.7, + "Arabic": 34.5, + "Chinese": 19.3, + "English": 56.4, + "Catalan": 40.0, + "Portuguese": 17.0, + "Sanskrit": 0.9, + "Turkish": 30.7, + "Persian": 28.2, + "Vietnamese": 19.4, + "Norwegian": 58.7, + "Khmer": 0.5 + } + } } ] From b1e9475d7925057da3a2c2a97cfa3e4bc4a7f4b4 Mon Sep 17 00:00:00 2001 From: Sina Date: Wed, 15 Apr 2026 06:47:36 +0000 Subject: [PATCH 58/58] docs(benchmark): add Gemma 4 (E4B) model results --- benchmark_results.json | 54 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/benchmark_results.json b/benchmark_results.json index 352b2c9..02c9507 100644 --- a/benchmark_results.json +++ b/benchmark_results.json @@ -3292,5 +3292,59 @@ "Khmer": 0.5 } } + }, + { + "modelName": "Gemma 4 (E4B)", + "modelId": "google/gemma-4-E4B-it", + "modelUrl": "https://huggingface.co/google/gemma-4-E4B-it", + "iconPath": "_static/img/gemma-icon.svg", + "printed": 56.9, + "handwritten": 32.2, + "total": 43.6, + "main_language_and_type_metrics": { + "print": { + "Czech": 82.7, + "German": 49.9, + "Latin": 77.4, + "French": 71.7, + "English": 76.5, + "Hindi": 69.3, + "Sanskrit": 58.6, + "Swedish": 49.6, + "Chinese": 2.5, + "Dutch": 49.6, + "Finnish": 19.0, + "Polish": 59.4, + "Spanish": 75.7, + "Slovenian": 94.5, + "Bulgarian": 67.6, + "Japanese": 11.5, + "Bangla": 58.2, + "Romanian": 49.9 + }, + "handwriting": { + "Spanish": 38.0, + "Dutch": 42.6, + "French": 37.2, + "Latin": 29.2, + "Hebrew": 7.4, + "Japanese": 4.1, + "Italian": 50.2, + "German": 31.5, + "Greek": 26.1, + "Swedish": 16.5, + "Arabic": 42.7, + "Chinese": 35.4, + "English": 62.3, + "Catalan": 55.2, + "Portuguese": 16.4, + "Sanskrit": 1.5, + "Turkish": 31.3, + "Persian": 38.0, + "Vietnamese": 38.8, + "Norwegian": 66.1, + "Khmer": 4.8 + } + } } ]

+ 🤗 Model • + 🗂️ Dataset • + 📄 Paper +
+ 📚 Docs • + 🏆 Leaderboard • + + GitHub Stars badge + +