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. diff --git a/README.md b/README.md index 77f2fde..81695cc 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,55 @@ -# 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 logo Churro -CHURRO is an OCR toolkit for historical document transcription, built to make handwritten and printed sources readable at high accuracy and lower cost. +
-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. +

+ 🤗 Model • + 🗂️ Dataset • + 📄 Paper +

+ 📚 Docs • + 🏆 Leaderboard • + + GitHub Stars badge + +

+ +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. + +## Supported OCR Models and Backends + +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. ```bash uv tool install churro-ocr @@ -46,6 +74,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 diff --git a/benchmark_results.json b/benchmark_results.json index 0ccccbe..02c9507 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,16 +222,268 @@ "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", "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 + "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", + "modelId": "gpt-5.4-2026-03-05", + "modelUrl": "https://platform.openai.com/docs/models/gpt-5.4", + "iconPath": "_static/img/openai-symbol.svg", + "printed": 75.8, + "handwritten": 52.8, + "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", + "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", + "printed": 70.2, + "handwritten": 39.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", + "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", + "printed": 56.8, + "handwritten": 27.9, + "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", @@ -60,7 +492,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)", @@ -69,7 +546,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)", @@ -78,26 +600,160 @@ "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", "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 + "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", "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 + "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)", @@ -106,7 +762,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", @@ -115,27 +816,160 @@ "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", "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 + "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", "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 + "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", @@ -144,7 +978,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", @@ -153,7 +1032,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)", @@ -162,16 +1086,214 @@ "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", "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 + "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", + "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, + "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)", + "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, + "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", @@ -180,7 +1302,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", @@ -189,16 +1356,106 @@ "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)", "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 + "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)", @@ -207,17 +1464,106 @@ "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", "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 + "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", @@ -226,17 +1572,106 @@ "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", "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 + "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", @@ -245,36 +1680,214 @@ "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", "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 + "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", + "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 + "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", "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 + "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", @@ -283,45 +1896,268 @@ "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", "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 + "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", "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 + "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", "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 + "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)", @@ -330,7 +2166,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)", @@ -339,63 +2220,430 @@ "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", "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 + "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", "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 + "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", "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 + "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", + "modelId": "opendatalab/MinerU2.5-2509-1.2B", + "modelUrl": "https://huggingface.co/opendatalab/MinerU2.5-2509-1.2B", + "iconPath": "_static/img/mineru-logo.png", + "printed": 56.0, + "handwritten": 20.4, + "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", "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 + "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", "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 + "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)", "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 + "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)", @@ -404,26 +2652,160 @@ "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", "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 + "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", "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 + "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", @@ -432,24 +2814,537 @@ "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", "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 + "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", "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 + "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", + "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, + "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 + } + } + }, + { + "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 + } + } + }, + { + "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 + } + } + }, + { + "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 + } + } + }, + { + "modelName": "Step 3 VL", + "modelId": "stepfun-ai/Step3-VL-10B", + "modelUrl": "https://huggingface.co/stepfun-ai/Step3-VL-10B", + "iconPath": "_static/img/stepfun-logo.svg", + "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 + } + } + }, + { + "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 + } + } + }, + { + "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 + } + } } ] diff --git a/docs/_static/css/custom.css b/docs/_static/css/custom.css index df6d205..0470d44 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); @@ -248,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; @@ -288,3 +342,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/img/azure-logo.png b/docs/_static/img/azure-logo.png index 23f6803..7517b39 100644 Binary files a/docs/_static/img/azure-logo.png and b/docs/_static/img/azure-logo.png differ diff --git a/docs/_static/img/deepseek-logo.png b/docs/_static/img/deepseek-logo.png new file mode 100644 index 0000000..2d3c3b8 Binary files /dev/null and b/docs/_static/img/deepseek-logo.png differ diff --git a/docs/_static/img/dots-logo.png b/docs/_static/img/dots-logo.png new file mode 100644 index 0000000..f25cf82 Binary files /dev/null and b/docs/_static/img/dots-logo.png differ diff --git a/docs/_static/img/firered-ocr-logo.png b/docs/_static/img/firered-ocr-logo.png new file mode 100644 index 0000000..5974815 Binary files /dev/null and b/docs/_static/img/firered-ocr-logo.png differ 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/_static/img/infly-logo.png b/docs/_static/img/infly-logo.png new file mode 100644 index 0000000..8dac298 Binary files /dev/null and b/docs/_static/img/infly-logo.png differ diff --git a/docs/_static/img/internvl-logo.png b/docs/_static/img/internvl-logo.png new file mode 100644 index 0000000..66dcbbd Binary files /dev/null and b/docs/_static/img/internvl-logo.png differ diff --git a/docs/_static/img/liquidai-logo.png b/docs/_static/img/liquidai-logo.png new file mode 100644 index 0000000..5fec530 Binary files /dev/null and b/docs/_static/img/liquidai-logo.png differ diff --git a/docs/_static/img/mimo-logo.png b/docs/_static/img/mimo-logo.png new file mode 100644 index 0000000..fa9a07f Binary files /dev/null and b/docs/_static/img/mimo-logo.png differ diff --git a/docs/_static/img/mineru-logo.png b/docs/_static/img/mineru-logo.png new file mode 100644 index 0000000..9593896 Binary files /dev/null and b/docs/_static/img/mineru-logo.png differ diff --git a/docs/_static/img/minicpm-logo.png b/docs/_static/img/minicpm-logo.png new file mode 100644 index 0000000..c3438f4 Binary files /dev/null and b/docs/_static/img/minicpm-logo.png differ diff --git a/docs/_static/img/nanonets-logo.png b/docs/_static/img/nanonets-logo.png new file mode 100644 index 0000000..51722e4 Binary files /dev/null and b/docs/_static/img/nanonets-logo.png differ diff --git a/docs/_static/img/numarkdown-logo.png b/docs/_static/img/numarkdown-logo.png new file mode 100644 index 0000000..c5efa3c Binary files /dev/null and b/docs/_static/img/numarkdown-logo.png differ diff --git a/docs/_static/img/paddleocr-logo.png b/docs/_static/img/paddleocr-logo.png new file mode 100644 index 0000000..021dabd Binary files /dev/null and b/docs/_static/img/paddleocr-logo.png differ diff --git a/docs/_static/img/phi4-logo.png b/docs/_static/img/phi4-logo.png new file mode 100644 index 0000000..a8bbc29 Binary files /dev/null and b/docs/_static/img/phi4-logo.png differ diff --git a/docs/_static/img/qianfan-ocr-logo.png b/docs/_static/img/qianfan-ocr-logo.png new file mode 100644 index 0000000..42c6e98 Binary files /dev/null and b/docs/_static/img/qianfan-ocr-logo.png differ diff --git a/docs/_static/img/r4b-logo.png b/docs/_static/img/r4b-logo.png new file mode 100644 index 0000000..439bacc Binary files /dev/null and b/docs/_static/img/r4b-logo.png differ diff --git a/docs/_static/img/skywork-logo.png b/docs/_static/img/skywork-logo.png new file mode 100644 index 0000000..3bc6b6f Binary files /dev/null and b/docs/_static/img/skywork-logo.png differ 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 diff --git a/docs/_static/js/benchmark-leaderboard.js b/docs/_static/js/benchmark-leaderboard.js index 240a202..7481448 100644 --- a/docs/_static/js/benchmark-leaderboard.js +++ b/docs/_static/js/benchmark-leaderboard.js @@ -1,19 +1,14 @@ (function () { const SCORE_FORMATTER = new Intl.NumberFormat(undefined, { - minimumFractionDigits: 2, - maximumFractionDigits: 2, + 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"; @@ -50,9 +95,6 @@ const icon = document.createElement("img"); icon.className = "benchmark-model-icon"; - if (row.iconInvertDark) { - icon.classList.add("benchmark-model-icon--invert-dark"); - } icon.alt = `${row.modelName} icon`; icon.src = resolvePath(row.iconPath); iconFrame.append(icon); @@ -63,13 +105,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 +158,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 +276,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 +283,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 +393,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 +425,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/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: ``` diff --git a/docs/benchmarking.md b/docs/benchmarking.md index f21609a..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 @@ -49,9 +50,10 @@ 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 | | --- | --- | --- | --- | | 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` | diff --git a/docs/cli.md b/docs/cli.md index 4c85737..a61041e 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1,31 +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. -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 \ @@ -44,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 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 @@ -71,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 \ @@ -79,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 @@ -102,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/conf.py b/docs/conf.py index 9e8786a..66322e0 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 @@ -49,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 @@ -100,7 +102,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 +122,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/docs/getting-started.md b/docs/getting-started.md index 92cef8b..f9f8829 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -1,75 +1,103 @@ # 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 -Use UV as the supported install path. +For the CLI-first workflow used in this guide, install Churro with UV as a tool. ```bash uv tool install churro-ocr -# or, in a project: -uv add churro-ocr ``` -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 -``` - -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 +If you are adding `churro-ocr` to a project instead, use `uv add churro-ocr` and prefix the CLI commands below with `uv run`. -Use `OCRClient` when your input is already one page per image. -This example uses `provider="litellm"`, so install the `llm` runtime first. +## Install the First Runtime -```python -from churro_ocr.ocr import OCRClient -from churro_ocr.providers import OCRBackendSpec, build_ocr_backend +The canonical getting-started path uses the local Hugging Face backend and the `stanford-oval/churro-3B` model. -backend = build_ocr_backend( - OCRBackendSpec( - provider="litellm", - model="vertex_ai/gemini-2.5-flash", - ) -) +```bash +churro-ocr install hf +``` -page = OCRClient(backend).ocr_image(image_path="scan.png") +Use `--torch-backend` with `hf` when you need a specific PyTorch build: -print(page.text) -print(page.provider_name) -print(page.model_name) +```bash +churro-ocr install hf --torch-backend cu126 ``` -When an API accepts both `image` and `image_path`, pass exactly one of them. - -## Quick CLI Sanity Check +For hosted providers, self-hosted OpenAI-compatible servers, Azure, Mistral, or PDF support, continue with [Providers And Configuration](guides/providers.md). -Use the CLI when you want to confirm a model or backend before writing Python code. +## First Successful Run ```bash -uv tool install churro-ocr -churro-ocr install hf churro-ocr transcribe \ --image scan.png \ --backend hf \ --model stanford-oval/churro-3B ``` -## Working From A Repo Checkout +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` | + +For the page-and-pipeline mental model behind those types, read [Core Concepts](core-concepts.md). + +## Where To Go Next + +::::{grid} 1 1 2 2 +:gutter: 2 + +:::{grid-item-card} CLI +:link: cli +:link-type: doc + +Stay in the shell for OCR checks, page extraction, and runtime installs. +::: + +:::{grid-item-card} OCR Workflows +:link: guides/ocr-workflows +:link-type: doc + +Use the Python API for single-page OCR, PDFs, photographed spreads, and async flows. +::: + +:::{grid-item-card} Page Detection +:link: guides/page-detection +:link-type: doc + +Extract page crops without OCR, or choose a detector backend for boundary discovery. +::: + +:::{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 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..6341c37 --- /dev/null +++ b/docs/guides/advanced-customization.md @@ -0,0 +1,90 @@ +# 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`. | +| `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`. | +| `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`. | +| `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_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 + +- 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 f352c79..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,18 @@ 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 -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 @@ -35,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, @@ -51,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 @@ -121,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( @@ -151,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 1b061bb..d5adc49 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,106 +10,83 @@ 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 OCR Backend Should You Use? +## Runtime Install Matrix 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. +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 | +| `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`. ## 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 | -| higher-throughput local serving | `openai-compatible` + your own OpenAI-style server | good when you already run a served local backend such as vLLM | +| 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 or llama.cpp | +| 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", + provider="hf", + model="stanford-oval/churro-3B", ) ) ``` -Override transport or completion settings when you need to: +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. -```python -from churro_ocr.providers import LiteLLMTransportConfig, OCRBackendSpec, build_ocr_backend +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. -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}, - ), - ) -) -``` +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. -### Azure Document Intelligence +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. -```python -from churro_ocr.providers import ( - AzureDocumentIntelligenceOptions, - OCRBackendSpec, - build_ocr_backend, -) +For `zai-org/GLM-OCR`, the built-in `hf` and `openai-compatible` backends both use the model's documented `Text Recognition:` prompt -backend = build_ocr_backend( - OCRBackendSpec( - provider="azure", - options=AzureDocumentIntelligenceOptions( - endpoint="https://.cognitiveservices.azure.com/", - api_key="", - ), - ) -) -``` +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. -### 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 @@ -127,101 +107,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 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). -### 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`, 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 import HFChatTemplate -from churro_ocr.providers import ( - HuggingFaceOptions, - OCRBackendSpec, - OCRModelProfile, - build_ocr_backend, -) +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: +## Next Steps -| Export | Module | Use case | -| --- | --- | --- | -| `HFChatTemplate` | `churro_ocr.templates` | Build a Hugging Face chat-style multimodal prompt. | -| `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`. | -| `OCRPromptTemplate` | `churro_ocr.templates` | Base protocol for custom profile integration. | - -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. | -| `parse_chandra_response(...)` | `churro_ocr.prompts` | Convert Chandra HTML-layout output to plain text and preserve raw HTML 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 ``` 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. 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 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 869de7c..dca2948 100644 --- a/ruff.toml +++ b/ruff.toml @@ -1,15 +1,66 @@ line-length = 110 -required-version = "==0.15.9" +required-version = "==0.15.10" [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 +] +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__ +] 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/src/churro_ocr/__init__.py b/src/churro_ocr/__init__.py index 3d9fc09..48ec16e 100644 --- a/src/churro_ocr/__init__.py +++ b/src/churro_ocr/__init__.py @@ -16,30 +16,26 @@ CHURRO_3B_MODEL_ID, CHURRO_3B_XML_TEMPLATE, DEFAULT_OCR_TEMPLATE, - HFChatTemplate, - OCRPromptTemplate, ) __all__ = [ "CHURRO_3B_MODEL_ID", "CHURRO_3B_XML_TEMPLATE", + "DEFAULT_OCR_TEMPLATE", "BatchOCRBackend", "ChurroError", "ConfigurationError", - "DocumentPage", "DocumentOCRPipeline", "DocumentOCRResult", + "DocumentPage", "DocumentPageDetector", - "DEFAULT_OCR_TEMPLATE", - "HFChatTemplate", - "OCRPromptTemplate", "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/litellm.py b/src/churro_ocr/_internal/litellm.py index eaed37c..1688cb8 100644 --- a/src/churro_ocr/_internal/litellm.py +++ b/src/churro_ocr/_internal/litellm.py @@ -2,26 +2,107 @@ from __future__ import annotations +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 typing import Any, cast - -from PIL import Image +from time import monotonic +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 _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: @@ -29,11 +110,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 = [] @@ -54,15 +134,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 @@ -95,6 +177,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.""" @@ -160,7 +295,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: @@ -171,43 +306,41 @@ async def complete_text( _ensure_initialized() from litellm import acompletion - kwargs: dict[str, object] = { - "model": model, - "messages": messages, - "timeout": timeout_seconds, - } - 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. + deadline = monotonic() + request_timeout_seconds + + async def _run_completion() -> object: + return await asyncio.wait_for( + acompletion(**dict(kwargs)), + timeout=_remaining_completion_timeout( + deadline=deadline, + total_timeout_seconds=request_timeout_seconds, + ), + ) 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 + 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 - 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): - raise ProviderError(f"LiteLLM returned empty output for model '{model}'.") - raise ProviderError(f"LiteLLM returned empty output for model '{model}'.") + answer = cast("Any", response).choices[0].message.content + 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 @@ -264,19 +397,36 @@ def _extract_response_cost(*, model: str, response: object) -> float | None: return float(raw_cost) try: + import litellm from litellm import completion_cost - except Exception: + 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 Exception: + except (AttributeError, KeyError, LookupError, TypeError, ValueError): return None if not isinstance(cost, (int, float)): return 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, *, @@ -285,7 +435,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": @@ -339,7 +489,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, @@ -363,9 +513,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/_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 4595461..ffd7e83 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 @@ -19,14 +20,19 @@ { "APIConnectionError", "APITimeoutError", + "ClientConnectionError", + "ClientConnectorError", + "ClientOSError", "ConnectError", "ConnectTimeout", + "ConnectionError", "PoolTimeout", "RateLimitError", "ReadTimeout", "RemoteProtocolError", "ServiceRequestError", "ServiceResponseError", + "ServerDisconnectedError", "WriteTimeout", } ) @@ -37,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 @@ -107,10 +117,22 @@ 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): return True + if isinstance(exc, ConnectionError): + return True status_code = get_error_status_code(exc) if status_code is not None: @@ -157,25 +179,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, @@ -188,7 +233,8 @@ async def retry_api_call[T]( 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 9a00b21..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, @@ -29,12 +34,15 @@ 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 ) +def _bad_parameter(message: str) -> typer.BadParameter: + return typer.BadParameter(message) + + def _build_ocr_backend( *, backend: str, @@ -43,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", @@ -60,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", @@ -75,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", @@ -88,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: @@ -102,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", @@ -110,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( @@ -121,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( @@ -132,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 @@ -208,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/document.py b/src/churro_ocr/document.py index fca50c4..6619fa2 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,15 @@ PageDetectionRequest, ) +if TYPE_CHECKING: + from pathlib import Path + + from churro_ocr.types import MetadataDict + + +def _configuration_error(message: str) -> ConfigurationError: + return ConfigurationError(message) + @dataclass(slots=True) class DocumentOCRResult: @@ -29,7 +37,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. @@ -80,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 @@ -89,7 +98,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 +119,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 +136,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 +165,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 +189,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 +209,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..ef203ae 100644 --- a/src/churro_ocr/ocr.py +++ b/src/churro_ocr/ocr.py @@ -4,16 +4,28 @@ 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 + + +def _assertion_error(message: str) -> AssertionError: + return AssertionError(message) + + +def _configuration_error(message: str) -> ConfigurationError: + return ConfigurationError(message) + @dataclass(slots=True) class OCRResult: @@ -28,7 +40,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 +125,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 +154,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,10 +184,11 @@ 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`.") + 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, @@ -190,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 d0c6d86..a900e59 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,19 @@ 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 + + +def _assertion_error(message: str) -> AssertionError: + return AssertionError(message) + + +def _configuration_error(message: str) -> ConfigurationError: + return ConfigurationError(message) + @dataclass(slots=True) class PageCandidate: @@ -26,10 +38,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 +63,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 +88,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 +112,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 +135,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. @@ -164,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) @@ -183,7 +197,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 +375,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 +389,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/prompts/__init__.py b/src/churro_ocr/prompts/__init__.py index 19db07e..ac8a192 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__ = [ @@ -26,4 +25,5 @@ "parse_chandra_response", "parse_olmocr_response", "strip_ocr_output_tag", + "strip_rich_ocr_markup_to_plain_text", ] diff --git a/src/churro_ocr/prompts/ocr.py b/src/churro_ocr/prompts/ocr.py index a888d4a..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 @@ -153,7 +156,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: @@ -207,18 +210,18 @@ 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), { + return strip_rich_ocr_markup_to_plain_text(markdown_body), { "front_matter": front_matter, "raw_markdown": markdown_body, } -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), { + return strip_rich_ocr_markup_to_plain_text(raw_html), { "raw_html": raw_html, } 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/_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/_mineru25.py b/src/churro_ocr/providers/_mineru25.py new file mode 100644 index 0000000..b631a47 --- /dev/null +++ b/src/churro_ocr/providers/_mineru25.py @@ -0,0 +1,1270 @@ +"""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 dataclasses import dataclass, replace +from io import BytesIO +from typing import TYPE_CHECKING, Literal, cast + +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 + +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, +) +_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", +} + + +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.""" + + 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: + 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]: + message = f"Invalid MinerU2.5 bbox {bbox!r}." + raise _value_error(message) + 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: + 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": + 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): + 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 + 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": + message = "merge_prev is only valid for MinerU2.5 text blocks." + raise _attribute_error(message) + 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 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 + + +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: + message = "Exhausted MinerU2.5 table image token space." + raise _runtime_error(message) + + 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 _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 _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 + ] + + +def _pad_otsl_rows(rows: list[list[str]]) -> tuple[list[list[str]], int]: + max_cols = max(len(row) for row in rows) + padded_rows = [row + ([_OTSL_ECEL] * (max_cols - len(row))) for row in rows] + return padded_rows, max_cols + + +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_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_parts.append(mixed_texts[text_idx]) + text_idx += 1 + 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(parts): + if part in {_OTSL_FCEL, _OTSL_ECEL}: + 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, + ) + 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 + 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 = cells_by_position.get((row, col)) + 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 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() + 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() + + +@dataclass(slots=True) +class MinerU25PipelineHelper: + """Shared MinerU2.5 layout, extraction, and markdown postprocessing helper.""" + + 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: + 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) + 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/_ocr_processing.py b/src/churro_ocr/providers/_ocr_processing.py new file mode 100644 index 0000000..0fbbbfe --- /dev/null +++ b/src/churro_ocr/providers/_ocr_processing.py @@ -0,0 +1,347 @@ +"""Shared OCR preprocessing and postprocessing helpers for built-in profiles.""" + +from __future__ import annotations + +import math +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, + FIRERED_OCR_OCR_PROMPT, + GLM_OCR_OCR_PROMPT, + 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, + QIANFAN_OCR_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 +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", + "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 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 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 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() + 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 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]) + + +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/_page_detection_helpers.py b/src/churro_ocr/providers/_page_detection_helpers.py new file mode 100644 index 0000000..26e82fa --- /dev/null +++ b/src/churro_ocr/providers/_page_detection_helpers.py @@ -0,0 +1,638 @@ +"""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/_shared.py b/src/churro_ocr/providers/_shared.py index 42f7f01..7225e9d 100644 --- a/src/churro_ocr/providers/_shared.py +++ b/src/churro_ocr/providers/_shared.py @@ -3,18 +3,25 @@ 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 _configuration_error(message: str) -> ConfigurationError: + return ConfigurationError(message) + def preprocess_backend_page( page: DocumentPage, @@ -53,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: @@ -74,11 +82,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/builder.py b/src/churro_ocr/providers/builder.py index 09975ed..0e20367 100644 --- a/src/churro_ocr/providers/builder.py +++ b/src/churro_ocr/providers/builder.py @@ -2,22 +2,27 @@ 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, DotsMOCROCRBackend, DotsOCR15OCRBackend, + GlmOCROCRBackend, HuggingFaceVisionOCRBackend, LFM25VLOCRBackend, + MinerU25OCRBackend, PaddleOCRVL15OCRBackend, + QianfanOCROCRBackend, _default_dots_ocr_1_5_model_kwargs, ) from churro_ocr.providers.ocr import ( AzureDocumentIntelligenceOCRBackend, LiteLLMVisionOCRBackend, + MinerU25OpenAICompatibleOCRBackend, MistralOCRBackend, OpenAICompatibleOCRBackend, ) @@ -32,6 +37,14 @@ resolve_ocr_profile, validate_mistral_ocr_model, ) +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( @@ -108,10 +121,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 @@ -125,7 +139,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: + 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, @@ -139,14 +160,19 @@ 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`.") - return OpenAICompatibleOCRBackend( + 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 + return backend_cls( model=spec.model, model_prefix=options.model_prefix or "openai", model_name=_resolve_model_name(profile, spec.model, fallback=spec.model), @@ -159,7 +185,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), @@ -171,14 +198,20 @@ 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": 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": backend_cls = LFM25VLOCRBackend + elif options.backend_variant == "qianfan-ocr": + backend_cls = QianfanOCROCRBackend return backend_cls( model_id=spec.model, template=profile.template, @@ -196,9 +229,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, @@ -213,7 +245,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, @@ -244,7 +277,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 567f291..0811619 100644 --- a/src/churro_ocr/providers/hf.py +++ b/src/churro_ocr/providers/hf.py @@ -4,18 +4,24 @@ 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 types import MethodType -from typing import Any, cast +from typing import TYPE_CHECKING, Any, cast -from churro_ocr._internal.install import install_command_hint +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.errors import ConfigurationError, ProviderError from churro_ocr.ocr import OCRBackend, OCRResult from churro_ocr.page_detection import DocumentPage +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, @@ -41,340 +47,273 @@ 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, + MINERU2_5_2509_1_2B_IMAGE_ANALYSIS_TEMPLATE, + MINERU2_5_2509_1_2B_LAYOUT_TEMPLATE, + MINERU2_5_2509_1_2B_MODEL_ID, + MINERU2_5_2509_1_2B_OCR_TEMPLATE, + 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, ) -_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}" +if TYPE_CHECKING: + from churro_ocr.providers._mineru25 import MinerU25PipelineHelper + +_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 +_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], ) -@dataclass(slots=True) -class _HFRuntime: - processor_cls: Any - model_cls: Any - process_vision_info: Any - - -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 +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 - raise ConfigurationError( - f"Hugging Face OCR requires the `hf` runtime. {_HF_EXTRA_INSTALL_HINT}" - ) 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 - raise ConfigurationError( - f"Hugging Face OCR requires the `hf` runtime. {_HF_EXTRA_INSTALL_HINT}" - ) 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 - raise ConfigurationError( - f"Hugging Face OCR requires the `hf` runtime. {_HF_EXTRA_INSTALL_HINT}" - ) 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 _ensure_deepseek_ocr_2_cuda_runtime() -> Any: - _ensure_hf_torch_runtime() - torch = import_module("torch") - if not torch.cuda.is_available(): - raise ConfigurationError( - "DeepSeek-OCR-2 HF backend requires a CUDA-capable PyTorch runtime because " - "the upstream `infer(...)` implementation moves inputs to CUDA." - ) - 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 _load_hf_auto_processor_model_runtime() -> _HFRuntime: + return _runtime._load_hf_auto_processor_model_runtime_with_import(import_module) + + +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 - raise ConfigurationError( - f"Hugging Face OCR requires the `hf` runtime. {_HF_EXTRA_INSTALL_HINT}" - ) 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: Any) -> 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: Any, - 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, - ) -> Any: - 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 Exception: - 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 +def _patch_dots_ocr_prepare_inputs_for_generation(model: object) -> None: + _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 = import_module("torch") - 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 - - free_bytes, _ = torch.cuda.mem_get_info() - 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 = import_module("torch") - 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 _deepseek_ocr_2_prompt_from_conversation(conversation: OCRConversation) -> str: - prompt_lines: list[str] = [] - has_image = False + 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": - content_items = cast("list[dict[str, object]]", 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.") - continue - if message.get("role") != "user": continue - content_items = cast("list[dict[str, object]]", 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: - raise ConfigurationError("DeepSeek-OCR-2 requires a non-empty OCR prompt.") - if has_image: - return f"\n{prompt_text}" - return prompt_text - - -def _move_batch_to_model(batch: Any, model: Any) -> Any: - model_device = getattr(model, "device", None) - if hasattr(batch, "to") and model_device is not None: - batch = batch.to(model_device) + 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: - batch_mapping = cast(dict[str, object], batch) - for key, value in batch_mapping.items(): - if hasattr(value, "dtype") and getattr(value.dtype, "is_floating_point", False): - batch_mapping[key] = cast(Any, value).to(dtype=model_dtype) - return batch - - -def _decode_completion_texts(processor: Any, batch: Any, generated_ids: Any) -> list[str]: - 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 = [ - 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:] - return processor.batch_decode( - completion_ids, - skip_special_tokens=True, - clean_up_tokenization_spaces=False, - ) - - -def _paddleocr_vl_processor_kwargs(*, processor: Any, 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 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) @@ -465,9 +404,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, ) @@ -524,9 +464,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, ) @@ -544,7 +485,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: @@ -574,7 +515,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: @@ -585,7 +526,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: @@ -624,14 +565,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: @@ -649,11 +590,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, @@ -662,7 +609,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) @@ -704,7 +651,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), ) @@ -746,7 +694,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), ) @@ -771,6 +720,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``.""" @@ -788,7 +870,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: @@ -811,15 +893,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: @@ -833,7 +915,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" @@ -850,7 +933,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, @@ -897,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``. @@ -914,7 +1112,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 @@ -922,7 +1120,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: @@ -957,6 +1155,196 @@ class DotsMOCROCRBackend(DotsOCR15OCRBackend): model_name: str | None = "dots.mocr" +@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=_mineru._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) -> object: + 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 _infer_step( + self, + *, + context: _mineru._MinerU25StepContext, + image: Image.Image, + step_key: str, + ) -> str: + rendered, conversation = render_ocr_prompt( + 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 = _mineru._resolve_rendered_prompt( + rendered, + provider_error=_provider_error, + ) + self._log_prompt_payload( + rendered_prompt=rendered_prompt, + conversation=conversation, + batch_size=context.batch_size, + ) + 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( + context.model, + **self._generation_inputs(batch), + **_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( + context.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) + 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( + context=step_context, + image=image, + step_key=step_key, + ), + ) + 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) + 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( + 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( + context=step_context, + image=image, + step_key=step_key, + ), + ) + 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``.""" @@ -968,14 +1356,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): @@ -984,17 +1372,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, @@ -1028,7 +1416,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, @@ -1067,7 +1455,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( @@ -1089,14 +1477,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 @@ -1118,17 +1506,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, @@ -1159,7 +1547,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, @@ -1198,7 +1586,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( @@ -1217,7 +1605,10 @@ def _ocr_batch_sync(self, pages: list[DocumentPage]) -> list[OCRResult]: "DeepSeekOCR2OCRBackend", "DotsMOCROCRBackend", "DotsOCR15OCRBackend", + "GlmOCROCRBackend", "HuggingFaceVisionOCRBackend", "LFM25VLOCRBackend", + "MinerU25OCRBackend", "PaddleOCRVL15OCRBackend", + "QianfanOCROCRBackend", ] diff --git a/src/churro_ocr/providers/ocr.py b/src/churro_ocr/providers/ocr.py index 452b47d..217233d 100644 --- a/src/churro_ocr/providers/ocr.py +++ b/src/churro_ocr/providers/ocr.py @@ -7,9 +7,9 @@ from dataclasses import dataclass, field from io import BytesIO from threading import Lock -from typing import Any +from typing import TYPE_CHECKING, Protocol, cast -from churro_ocr._internal.image import image_to_base64 +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 +17,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,13 +33,78 @@ ) 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, ) +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) @@ -152,6 +221,203 @@ 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__ = ( + "_helper", + "formula_template", + "image_analysis_template", + "layout_template", + "table_template", + ) + + 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.""" + return [await self.ocr(page) for page in pages] + + @dataclass(slots=True) class AzureDocumentIntelligenceOCRBackend(OCRBackend): """Azure Document Intelligence OCR backend. @@ -170,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 @@ -188,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 @@ -226,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), @@ -240,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", @@ -265,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) @@ -274,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 @@ -286,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 @@ -321,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, @@ -336,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..025d239 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, 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,563 +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 - -_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 -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]: - 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, -) -> tuple[tuple[float, float], ...]: - 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 = int(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) -> tuple[float, float, float, float]: - 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: - raise ValueError("Expected 'page_index' key in page-detection response.") - required_keys = {"left", "top", "right", "bottom"} - if not required_keys.issubset(payload): - missing = required_keys - set(payload) - raise ValueError( - f"Page-detection response must include keys {sorted(required_keys)}, " - f"missing {sorted(missing)}." - ) - 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, 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)))) - 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, int(round(image.width * fraction))) - border_height = max(1, int(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, int(round(width * scale))), max(1, int(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: - raise ProviderError("LLM page detection returned invalid JSON.") from exc - - if not isinstance(payload, dict): - raise ProviderError("LLM page detection response must be a JSON object.") - - pages = payload.get("pages") - if not isinstance(pages, list): - raise ProviderError("LLM page detection response must include a `pages` list.") - - 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.") - 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 - 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: - raise ProviderError(f"{error_context} returned invalid JSON.") from exc - - if not isinstance(payload, dict): - raise ProviderError(f"{error_context} response must be a JSON object.") - 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 - - 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: - raise ProviderError(f"{error_context} bbox is invalid: {exc}") from exc - if raw_target is not None: - raise ProviderError(f"{error_context} response `{target_key}` must be an object or null.") - - 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 - - raise ProviderError( - f"{error_context} response must include a `{target_key}` object " - f"or explicitly mark `{found_key}` false." - ) - - -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): - raise ValueError(f"Review edge '{edge_name}' must be an object.") - 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'.") - 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'.") - 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)))) - except (TypeError, ValueError) as exc: - raise ValueError(f"Review edge '{edge_name}' amount must be numeric.") 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: - raise ValueError(f"Failed to decode edge-review response as JSON: {exc}") from exc - - if not isinstance(payload, dict): - raise ValueError("Edge-review response must be a JSON object.") - if "page_index" not in payload: - raise ValueError("Edge-review response must include 'page_index'.") - - raw_edge = payload.get("edge") - if not isinstance(raw_edge, str): - raise ValueError("Edge-review response must include string 'edge'.") - 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.") - - 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: - raise ValueError(f"Failed to decode text-block edge-review response as JSON: {exc}") from exc - - if not isinstance(payload, dict): - raise ValueError("Text-block edge-review response must be a JSON object.") - 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'.") - 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.") - - 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: tuple[float, float, float, float], -) -> tuple[tuple[float, float], ...]: - 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, int(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, int(round(box_width * margin_fraction))) - margin_y = max(outline_width * 2, int(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, 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))) - - 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: - raise ValueError(f"Unsupported edge '{edge_name}'. Expected left/top/right/bottom.") - - if x0 >= x1 or y0 >= y1: - raise ValueError(f"Invalid strip bounds for edge '{edge_name}'.") - 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: - raise ValueError("Expected at least one non-empty instruction prompt.") - return "\n\n".join(merged_parts) - - async def _complete_page_boxes( *, model: str, @@ -641,7 +134,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 +203,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, @@ -790,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, @@ -872,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, @@ -951,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, @@ -1074,7 +569,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 +585,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 +649,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 +700,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 +712,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,278 +747,6 @@ 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], ...]: - 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 = int(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", diff --git a/src/churro_ocr/providers/specs.py b/src/churro_ocr/providers/specs.py index cc6fec8..ee1e33b 100644 --- a/src/churro_ocr/providers/specs.py +++ b/src/churro_ocr/providers/specs.py @@ -2,21 +2,31 @@ from __future__ import annotations -import re -from collections.abc import Callable, Sequence +from collections.abc import Callable 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 -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, +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, + firered_ocr_text_postprocessor, + glm_ocr_image_preprocessor, + glm_ocr_text_postprocessor, + 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, + qianfan_ocr_text_postprocessor, ) from churro_ocr.templates import ( CHANDRA_OCR_2_MODEL_ID, @@ -24,50 +34,68 @@ 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, 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, + INFINITY_PARSER_7B_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, + 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, 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_TEMPLATE, 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 = 20_000 +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 +NANONETS_OCR2_3B_MAX_TOKENS = 15_000 OLMOCR_MAX_TOKENS = 8_000 PADDLEOCR_VL_MAX_TOKENS = 4_096 -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 +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, ...] = ( "mistral-ocr-2505", "mistral-ocr-2512", ) +def _configuration_error(message: str) -> ConfigurationError: + return ConfigurationError(message) + + def validate_mistral_ocr_model( model: str | None, *, @@ -76,190 +104,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) - - -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|>:", -} - - -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 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 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) + message = f"{context} only supports `model` values {supported_models}; got {model!r}." + raise _configuration_error(message) + return cast("MistralOCRModel", model) @dataclass(slots=True, frozen=True) @@ -450,6 +300,101 @@ 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 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 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( + 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. @@ -516,6 +461,50 @@ 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( + 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, @@ -578,23 +567,35 @@ 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() + 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() + qianfan_profile = qianfan_ocr_profile() return { default_profile.profile_name: default_profile, 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, + 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, + qianfan_profile.profile_name: qianfan_profile, } @@ -618,7 +619,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] @@ -626,8 +628,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", @@ -636,25 +650,25 @@ def resolve_ocr_profile( "default_ocr_image_preprocessor", "default_ocr_profile", "default_ocr_text_postprocessor", - "HuggingFaceOptions", + "firered_ocr_profile", + "firered_ocr_text_postprocessor", + "glm_ocr_image_preprocessor", + "glm_ocr_profile", + "glm_ocr_text_postprocessor", "identity_text_postprocessor", - "lfm2_5_vl_text_postprocessor", + "infinity_parser_7b_profile", + "infinity_parser_7b_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", + "nanonets_ocr2_3b_profile", + "nanonets_ocr2_3b_text_postprocessor", "olmocr_image_preprocessor", "olmocr_text_postprocessor", "paddleocr_vl_1_5_profile", "paddleocr_vl_text_postprocessor", - "OCRBackendSpec", - "OCRModelProfile", - "OCRProvider", - "OpenAICompatibleOptions", + "qianfan_ocr_profile", + "qianfan_ocr_text_postprocessor", "resolve_ocr_profile", - "TextPostprocessor", "validate_mistral_ocr_model", - "VisionInputBuilder", ] diff --git a/src/churro_ocr/templates/__init__.py b/src/churro_ocr/templates/__init__.py index 755da05..2a5cc9a 100644 --- a/src/churro_ocr/templates/__init__.py +++ b/src/churro_ocr/templates/__init__.py @@ -23,22 +23,50 @@ 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, + 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, + 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, + 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, 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__ = [ - "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", @@ -49,17 +77,47 @@ "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", + "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", + "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", + "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", "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", - "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/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/templates/presets.py b/src/churro_ocr/templates/presets.py index 6e31226..85ae8c8 100644 --- a/src/churro_ocr/templates/presets.py +++ b/src/churro_ocr/templates/presets.py @@ -13,8 +13,14 @@ 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" +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" +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" @@ -37,6 +43,60 @@ 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, +) +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, +) +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, +) +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, @@ -47,6 +107,47 @@ 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:" +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, @@ -64,10 +165,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", @@ -78,12 +179,41 @@ "DOTS_OCR_1_5_MODEL_ID", "DOTS_OCR_1_5_OCR_PROMPT", "DOTS_OCR_1_5_OCR_TEMPLATE", - "PADDLEOCR_VL_1_5_MODEL_ID", - "PADDLEOCR_VL_1_5_OCR_PROMPT", - "PADDLEOCR_VL_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", + "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", + "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", + "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", - "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", + "QIANFAN_OCR_MODEL_ID", + "QIANFAN_OCR_OCR_PROMPT", + "QIANFAN_OCR_OCR_TEMPLATE", ] 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", +] 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) diff --git a/tests/test_cli.py b/tests/test_cli.py index c564195..7108375 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 @@ -25,24 +25,103 @@ 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, + 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, PADDLEOCR_VL_1_5_OCR_TEMPLATE, + QIANFAN_OCR_MODEL_ID, + QIANFAN_OCR_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}", @@ -70,8 +149,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" @@ -80,7 +159,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=[ @@ -111,40 +190,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: @@ -160,43 +221,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 @@ -214,30 +247,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 @@ -255,30 +267,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 @@ -295,31 +286,92 @@ 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_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_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) + 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 = 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 @@ -336,31 +388,55 @@ def test_build_ocr_backend_aligns_templates_for_paddleocr_vl() -> None: } +def test_build_ocr_backend_aligns_templates_for_mineru2_5() -> 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 + 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_aligns_templates_for_infinity_parser() -> 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 + 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=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, + 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", - 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 @@ -370,30 +446,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 @@ -407,30 +462,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 @@ -446,7 +480,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] = {} @@ -483,10 +517,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_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_hf_ocr.py b/tests/test_hf_ocr.py index 70bdb71..ee74fdf 100644 --- a/tests/test_hf_ocr.py +++ b/tests/test_hf_ocr.py @@ -20,20 +20,33 @@ 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, DeepSeekOCR2OCRBackend, DotsMOCROCRBackend, DotsOCR15OCRBackend, + GlmOCROCRBackend, HuggingFaceVisionOCRBackend, LFM25VLOCRBackend, + MinerU25OCRBackend, PaddleOCRVL15OCRBackend, + QianfanOCROCRBackend, ) 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, + nanonets_ocr2_3b_text_postprocessor, + qianfan_ocr_text_postprocessor, ) from churro_ocr.templates import ( CHANDRA_OCR_2_MODEL_ID, @@ -48,13 +61,42 @@ 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, + 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, + 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, + 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, 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, ) @@ -106,6 +148,139 @@ 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_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_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")) + + 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")) + + 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_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" @@ -173,6 +348,122 @@ 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_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_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_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_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( @@ -182,6 +473,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", @@ -225,6 +523,113 @@ 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_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_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", + 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", @@ -246,6 +651,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", @@ -287,6 +720,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", @@ -471,34 +927,319 @@ async def test_deepseek_ocr_2_huggingface_backend_uses_upstream_infer_contract( class FakeTokenizer: pass - class FakeTokenizerCls: + 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 FakeModel: + def eval(self) -> FakeModel: + captured["eval_called"] = True + return self + + def cuda(self) -> FakeModel: + captured["cuda_called"] = True + return self + + def to(self, dtype: object) -> FakeModel: + captured["to_dtype"] = dtype + return self + + def infer(self, tokenizer: object, **kwargs: object) -> str: + captured["infer_tokenizer"] = tokenizer + captured["infer_kwargs"] = kwargs + image = Image.open(cast("str", kwargs["image_file"])) + captured["saved_image_mode"] = image.mode + captured["saved_image_size"] = image.size + captured["output_dir_exists"] = Path(cast("str", kwargs["output_path"])).exists() + return "\nFree OCR.\n<|Assistant|>\nDecoded text<|end▁of▁sentence|>" + + 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._ensure_deepseek_ocr_2_cuda_runtime", + lambda: SimpleNamespace(bfloat16="fake-bfloat16"), + ) + + backend = cast( + "DeepSeekOCR2OCRBackend", + build_ocr_backend( + OCRBackendSpec( + provider="hf", + model=DEEPSEEK_OCR_2_MODEL_ID, + ) + ), + ) + result = await backend.ocr( + DocumentPage.from_image(Image.new("RGBA", (32, 16), color=(255, 255, 255, 255))) + ) + + assert result.text == "Decoded text" + assert result.metadata == {} + assert captured["tokenizer_model_id"] == DEEPSEEK_OCR_2_MODEL_ID + assert captured["model_model_id"] == DEEPSEEK_OCR_2_MODEL_ID + assert captured["tokenizer_from_pretrained_kwargs"] == {"trust_remote_code": True} + assert captured["model_from_pretrained_kwargs"] == { + "trust_remote_code": True, + "use_safetensors": True, + } + assert captured["eval_called"] is True + assert captured["cuda_called"] is True + assert captured["to_dtype"] == "fake-bfloat16" + assert captured["infer_tokenizer"].__class__ is FakeTokenizer + infer_kwargs = cast("dict[str, object]", captured["infer_kwargs"]) + assert infer_kwargs == { + "prompt": "\nFree OCR.", + "image_file": infer_kwargs["image_file"], + "output_path": infer_kwargs["output_path"], + "base_size": 1_024, + "image_size": 768, + "crop_mode": True, + "save_results": False, + "eval_mode": True, + } + assert captured["saved_image_mode"] == "RGB" + assert captured["saved_image_size"] == (32, 16) + 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, +) -> 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) -> FakeTokenizer: - captured["tokenizer_model_id"] = model_id - captured["tokenizer_from_pretrained_kwargs"] = kwargs - return FakeTokenizer() + 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 cuda(self) -> FakeModel: - captured["cuda_called"] = True - return self - - def to(self, dtype: object) -> FakeModel: - captured["to_dtype"] = dtype - return self - - def infer(self, tokenizer: object, **kwargs: object) -> str: - captured["infer_tokenizer"] = tokenizer - captured["infer_kwargs"] = kwargs - image = Image.open(cast("str", kwargs["image_file"])) - captured["saved_image_mode"] = image.mode - captured["saved_image_size"] = image.size - captured["output_dir_exists"] = Path(cast("str", kwargs["output_path"])).exists() - return "\nFree OCR.\n<|Assistant|>\nDecoded text<|end▁of▁sentence|>" + def generate(self, **kwargs: object) -> list[list[int | str]]: + captured["generate_kwargs"] = kwargs + return [[0, 1, 2, 3, "completion"]] class FakeModelCls: @staticmethod @@ -508,58 +1249,59 @@ def from_pretrained(model_id: str, **kwargs: object) -> FakeModel: return FakeModel() monkeypatch.setattr( - "churro_ocr.providers.hf._load_hf_auto_model_runtime", + "churro_ocr.providers.hf._load_hf_runtime", lambda: SimpleNamespace( - processor_cls=FakeTokenizerCls, + processor_cls=FakeProcessorCls, model_cls=FakeModelCls, process_vision_info=None, ), ) - monkeypatch.setattr( - "churro_ocr.providers.hf._ensure_deepseek_ocr_2_cuda_runtime", - lambda: SimpleNamespace(bfloat16="fake-bfloat16"), - ) backend = cast( - "DeepSeekOCR2OCRBackend", + "GlmOCROCRBackend", build_ocr_backend( OCRBackendSpec( provider="hf", - model=DEEPSEEK_OCR_2_MODEL_ID, + model=GLM_OCR_MODEL_ID, ) ), ) - result = await backend.ocr( - DocumentPage.from_image(Image.new("RGBA", (32, 16), color=(255, 255, 255, 255))) - ) + result = await backend.ocr(DocumentPage.from_image(Image.new("RGB", (32, 32), color="white"))) - assert result.text == "Decoded text" - assert result.metadata == {} - assert captured["tokenizer_model_id"] == DEEPSEEK_OCR_2_MODEL_ID - assert captured["model_model_id"] == DEEPSEEK_OCR_2_MODEL_ID - assert captured["tokenizer_from_pretrained_kwargs"] == {"trust_remote_code": True} - assert captured["model_from_pretrained_kwargs"] == { - "trust_remote_code": True, - "use_safetensors": True, - } + 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 captured["cuda_called"] is True - assert captured["to_dtype"] == "fake-bfloat16" - assert captured["infer_tokenizer"].__class__ is FakeTokenizer - infer_kwargs = cast("dict[str, object]", captured["infer_kwargs"]) - assert infer_kwargs == { - "prompt": "\nFree OCR.", - "image_file": infer_kwargs["image_file"], - "output_path": infer_kwargs["output_path"], - "base_size": 1_024, - "image_size": 768, - "crop_mode": True, - "save_results": False, - "eval_mode": 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 captured["saved_image_mode"] == "RGB" - assert captured["saved_image_size"] == (32, 16) - assert captured["output_dir_exists"] is True + 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 @@ -612,7 +1354,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, @@ -756,7 +1500,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, @@ -1062,6 +1808,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"] @@ -1167,6 +1914,268 @@ 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, +) -> 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"",) + 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) + return FakeBatch( + { + "input_ids": object(), + "attention_mask": object(), + "pixel_values": object(), + } + ) + + 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() @@ -1247,7 +2256,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, @@ -1479,6 +2490,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 { @@ -1571,9 +2583,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() @@ -1625,7 +2635,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: @@ -1814,15 +2825,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) @@ -1871,8 +2882,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) @@ -1918,12 +2929,32 @@ 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 +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]: + message = "cudaMemGetInfo failed" + raise RuntimeError(message) + + 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, @@ -1969,8 +3000,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: @@ -2040,8 +3071,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( @@ -2078,10 +3109,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") @@ -2130,7 +3161,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_install.py b/tests/test_install.py index 7748202..2d2e5b7 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -19,6 +19,11 @@ def test_install_runtime_dependencies_installs_hf_and_torch_with_uv( ) -> 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, "distribution", @@ -30,11 +35,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", @@ -72,17 +73,18 @@ def test_install_runtime_dependencies_installs_local_client_with_uv( ) -> 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, "distribution", 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 427bdb4..347af01 100644 --- a/tests/test_internal_helpers.py +++ b/tests/test_internal_helpers.py @@ -1,11 +1,12 @@ from __future__ import annotations +import asyncio import re import sys 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 @@ -23,15 +24,30 @@ 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.model_cost = {"example/model": {}} 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 @@ -40,7 +56,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"): @@ -97,6 +113,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] = {} @@ -206,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] = [] @@ -234,12 +285,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) @@ -253,7 +306,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) @@ -327,6 +380,168 @@ 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 + message = "still failing" + raise _connection_error(message) + + 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_stable_provider_timeout_with_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: Callable[[], Awaitable[object]], + *, + 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, 10.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_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, +) -> 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_rejects_empty_output(monkeypatch: pytest.MonkeyPatch) -> None: async def _empty_acompletion(**_: object) -> object: @@ -471,17 +686,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_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_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/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 c7c9828..b5cf15c 100644 --- a/tests/test_providers.py +++ b/tests/test_providers.py @@ -4,14 +4,14 @@ 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 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, @@ -32,10 +32,9 @@ 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, ) @@ -53,14 +52,38 @@ 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, + 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, + 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, 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: + 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"] @@ -95,7 +118,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]], @@ -103,7 +126,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 @@ -147,7 +170,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( @@ -209,7 +232,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( @@ -234,7 +257,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( @@ -259,7 +282,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) @@ -296,21 +319,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() @@ -379,21 +402,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) @@ -599,7 +622,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 @@ -778,6 +801,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", @@ -798,6 +843,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=r"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", @@ -838,6 +911,79 @@ 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" + + +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" + + +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, @@ -853,7 +999,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]], @@ -900,7 +1046,7 @@ async def _fake_complete_text( @pytest.mark.asyncio -async def test_openai_compatible_backend_uses_olmocr_prompt_and_plain_text_postprocessing( +async def test_openai_compatible_backend_uses_firered_prompt_and_markdown_postprocessing( monkeypatch: pytest.MonkeyPatch, ) -> None: captured: dict[str, object] = {} @@ -914,7 +1060,194 @@ def _fake_prepare_messages_from_conversation( 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_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_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]], @@ -927,7 +1260,96 @@ async def _fake_complete_text( 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, +) -> 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 ( "---\n" "primary_language: en\n" @@ -999,6 +1421,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( + _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"{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, @@ -1159,11 +1669,123 @@ 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( + _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 + 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, ) -> 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": [ @@ -1188,7 +1810,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) @@ -1246,7 +1868,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 @@ -1276,7 +1898,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 @@ -1356,7 +1978,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) @@ -1475,7 +2097,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 @@ -1539,22 +2161,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: @@ -1565,9 +2187,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) @@ -1605,14 +2227,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 @@ -1626,9 +2248,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) @@ -1672,21 +2294,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"}) @@ -1703,9 +2325,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) 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, diff --git a/tests/test_tooling_benchmark.py b/tests/test_tooling_benchmark.py index 98bd750..586f6c4 100644 --- a/tests/test_tooling_benchmark.py +++ b/tests/test_tooling_benchmark.py @@ -1,26 +1,44 @@ 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 from PIL import Image from churro_ocr.ocr import OCRResult -from churro_ocr.providers.hf import HuggingFaceVisionOCRBackend -from churro_ocr.providers.ocr import LiteLLMVisionOCRBackend +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, 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, + NANONETS_OCR2_3B_OCR_TEMPLATE, PADDLEOCR_VL_1_5_OCR_TEMPLATE, + QIANFAN_OCR_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( @@ -177,6 +195,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( @@ -214,6 +249,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", @@ -232,6 +305,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", @@ -367,6 +461,203 @@ 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_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_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", + 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", @@ -411,6 +702,96 @@ 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", + 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", @@ -472,7 +853,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( @@ -491,28 +872,51 @@ 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" 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) + 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 + 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 captured["output_prefix"] = output_prefix @@ -541,6 +945,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( @@ -657,7 +1063,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( @@ -739,7 +1147,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}", @@ -787,7 +1195,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( @@ -810,7 +1220,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( @@ -847,7 +1257,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"), @@ -859,7 +1269,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}", @@ -896,7 +1306,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"), @@ -909,7 +1321,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}", @@ -941,3 +1353,136 @@ 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: pytest.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: + 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: benchmark.DocumentPage) -> OCRResult: + if page.width == 1: + message = "timed out" + raise _benchmark_runtime_error(message) + 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: pytest.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: + 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: list[benchmark.DocumentPage]) -> list[OCRResult]: + call_count["ocr_batch"] += 1 + if call_count["ocr_batch"] == 1: + message = "batch timed out" + raise _benchmark_runtime_error(message) + 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/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 32b3ad6..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: @@ -53,6 +129,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: @@ -94,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 == { @@ -121,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 @@ -173,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" @@ -184,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 @@ -203,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} @@ -239,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"], ) @@ -291,54 +339,32 @@ 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) + 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( 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"], ) @@ -349,6 +375,43 @@ 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) -> Never: + del args, kwargs + 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) + 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: _page_evaluation_result(str(inputs[0]["example_id"])), + ) + + aggregate, rows = evaluate_page_module.batch_evaluate( + dataset=[ + _benchmark_dataset_example("row-1", dataset_id="dataset-1"), + _benchmark_dataset_example("row-2", dataset_id="dataset-2"), + ], + 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( @@ -370,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") @@ -378,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 88ae9e5..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,32 +20,37 @@ sys.path.remove(_REPO_SRC_PATH_STR) sys.path.insert(0, _REPO_SRC_PATH_STR) -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"} @@ -62,6 +66,13 @@ "main_language", "main_script", ) +_PREDICTION_FAILURES = ( + AssertionError, + OSError, + RuntimeError, + TypeError, + ValueError, +) @dataclass(slots=True) @@ -81,6 +92,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 +126,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 +147,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 +161,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 @@ -192,14 +211,24 @@ 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: 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 +264,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 +280,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(), ) @@ -298,6 +329,42 @@ def _log_first_benchmark_output(*, options: BenchmarkOptions, text: str) -> None ) +def _failure_metadata(exc: BaseException) -> dict[str, object]: + message = str(exc).strip() + metadata: dict[str, object] = { + "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: + del exc + 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, @@ -333,35 +400,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 _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) + 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 _PREDICTION_FAILURES 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), @@ -407,7 +484,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) @@ -445,26 +522,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: 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 7c9038d..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, @@ -207,6 +221,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 +239,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( 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 2c6f9bd..181286a 100644 --- a/tooling/evaluation/normalization.py +++ b/tooling/evaluation/normalization.py @@ -4,8 +4,16 @@ 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 @@ -17,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 @@ -61,13 +77,17 @@ 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: """Normalize raw OCR text before metric computation.""" + text = strip_rich_ocr_markup_to_plain_text(text) + if normalize_arabic: if ( strip_tashkeel is None @@ -76,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) @@ -94,14 +113,9 @@ 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 re.sub(r"\s+", " ", normalize_characters(text, keep_long_s=False)).strip() 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: 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: 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"